diff --git a/src/auction.js b/src/auction.js index 48e1a8e3436..54621669c59 100644 --- a/src/auction.js +++ b/src/auction.js @@ -90,7 +90,7 @@ import {bidderSettings} from './bidderSettings.js'; import * as events from './events.js'; import adapterManager from './adapterManager.js'; import CONSTANTS from './constants.json'; -import {GreedyPromise} from './utils/promise.js'; +import {defer, GreedyPromise} from './utils/promise.js'; import {useMetrics} from './utils/perfMetrics.js'; import {adjustCpm} from './utils/cpm.js'; import {getGlobal} from './prebidGlobal.js'; @@ -143,6 +143,7 @@ export function newAuction({adUnits, adUnitCodes, callback, cbTimeout, labels, a const _auctionId = auctionId || generateUUID(); const _timeout = cbTimeout; const _timelyBidders = new Set(); + const done = defer(); let _bidsRejected = []; let _callback = callback; let _bidderRequests = []; @@ -193,7 +194,6 @@ export function newAuction({adUnits, adUnitCodes, callback, cbTimeout, labels, a if (cleartimer) { clearTimeout(_timer); } - if (_auctionEnd === undefined) { let timedOutBidders = []; if (timedOut) { @@ -209,6 +209,7 @@ export function newAuction({adUnits, adUnitCodes, callback, cbTimeout, labels, a metrics.checkpoint('auctionEnd'); metrics.timeBetween('requestBids', 'auctionEnd', 'requestBids.total'); metrics.timeBetween('callBids', 'auctionEnd', 'requestBids.callBids'); + done.resolve(); events.emit(CONSTANTS.EVENTS.AUCTION_END, getProperties()); bidsBackCallback(_adUnits, function () { @@ -392,6 +393,7 @@ export function newAuction({adUnits, adUnitCodes, callback, cbTimeout, labels, a setBidTargeting, getWinningBids: () => _winningBids, getAuctionStart: () => _auctionStart, + getAuctionEnd: () => _auctionEnd, getTimeout: () => _timeout, getAuctionId: () => _auctionId, getAuctionStatus: () => _auctionStatus, @@ -403,6 +405,7 @@ export function newAuction({adUnits, adUnitCodes, callback, cbTimeout, labels, a getNonBids: () => _nonBids, getFPD: () => ortb2Fragments, getMetrics: () => metrics, + end: done.promise }; } diff --git a/src/auctionManager.js b/src/auctionManager.js index 90d5fb543e2..498c200ba21 100644 --- a/src/auctionManager.js +++ b/src/auctionManager.js @@ -19,12 +19,16 @@ * @property {function(): void} clearAllAuctions - clear all auctions for testing */ -import { uniques, flatten, logWarn } from './utils.js'; +import { uniques, logWarn } from './utils.js'; import { newAuction, getStandardBidderSettings, AUCTION_COMPLETED } from './auction.js'; -import {find} from './polyfill.js'; import {AuctionIndex} from './auctionIndex.js'; import CONSTANTS from './constants.json'; import {useMetrics} from './utils/perfMetrics.js'; +import {ttlCollection} from './utils/ttlCollection.js'; +import {getTTL, onTTLBufferChange} from './bidTTL.js'; +import {config} from './config.js'; + +const CACHE_TTL_SETTING = 'minBidCacheTTL'; /** * Creates new instance of auctionManager. There will only be one instance of auctionManager but @@ -33,15 +37,42 @@ import {useMetrics} from './utils/perfMetrics.js'; * @returns {AuctionManager} auctionManagerInstance */ export function newAuctionManager() { - const _auctions = []; + let minCacheTTL = null; + + const _auctions = ttlCollection({ + startTime: (au) => au.end.then(() => au.getAuctionEnd()), + ttl: (au) => minCacheTTL == null ? null : au.end.then(() => { + return Math.max(minCacheTTL, ...au.getBidsReceived().map(getTTL)) * 1000 + }), + }); + + onTTLBufferChange(() => { + if (minCacheTTL != null) _auctions.refresh(); + }) + + config.getConfig(CACHE_TTL_SETTING, (cfg) => { + const prev = minCacheTTL; + minCacheTTL = cfg?.[CACHE_TTL_SETTING]; + minCacheTTL = typeof minCacheTTL === 'number' ? minCacheTTL : null; + if (prev !== minCacheTTL) { + _auctions.refresh(); + } + }) + const auctionManager = {}; + function getAuction(auctionId) { + for (const auction of _auctions) { + if (auction.getAuctionId() === auctionId) return auction; + } + } + auctionManager.addWinningBid = function(bid) { const metrics = useMetrics(bid.metrics); metrics.checkpoint('bidWon'); metrics.timeBetween('auctionEnd', 'bidWon', 'render.pending'); metrics.timeBetween('requestBids', 'bidWon', 'render.e2e'); - const auction = find(_auctions, auction => auction.getAuctionId() === bid.auctionId); + const auction = getAuction(bid.auctionId); if (auction) { bid.status = CONSTANTS.BID_STATUS.RENDERED; auction.addWinningBid(bid); @@ -50,48 +81,44 @@ export function newAuctionManager() { } }; - auctionManager.getAllWinningBids = function() { - return _auctions.map(auction => auction.getWinningBids()) - .reduce(flatten, []); - }; - - auctionManager.getBidsRequested = function() { - return _auctions.map(auction => auction.getBidRequests()) - .reduce(flatten, []); - }; - - auctionManager.getNoBids = function() { - return _auctions.map(auction => auction.getNoBids()) - .reduce(flatten, []); - }; - - auctionManager.getBidsReceived = function() { - return _auctions.map((auction) => { - if (auction.getAuctionStatus() === AUCTION_COMPLETED) { - return auction.getBidsReceived(); + Object.entries({ + getAllWinningBids: { + name: 'getWinningBids', + }, + getBidsRequested: { + name: 'getBidRequests' + }, + getNoBids: {}, + getAdUnits: {}, + getBidsReceived: { + pre(auction) { + return auction.getAuctionStatus() === AUCTION_COMPLETED; } - }).reduce(flatten, []) - .filter(bid => bid); - }; + }, + getAdUnitCodes: { + post: uniques, + } + }).forEach(([mgrMethod, {name = mgrMethod, pre, post}]) => { + const mapper = pre == null + ? (auction) => auction[name]() + : (auction) => pre(auction) ? auction[name]() : []; + const filter = post == null + ? (items) => items + : (items) => items.filter(post) + auctionManager[mgrMethod] = () => { + return filter(_auctions.toArray().flatMap(mapper)); + } + }) + + function allBidsReceived() { + return _auctions.toArray().flatMap(au => au.getBidsReceived()) + } auctionManager.getAllBidsForAdUnitCode = function(adUnitCode) { - return _auctions.map((auction) => { - return auction.getBidsReceived(); - }).reduce(flatten, []) + return allBidsReceived() .filter(bid => bid && bid.adUnitCode === adUnitCode) }; - auctionManager.getAdUnits = function() { - return _auctions.map(auction => auction.getAdUnits()) - .reduce(flatten, []); - }; - - auctionManager.getAdUnitCodes = function() { - return _auctions.map(auction => auction.getAdUnitCodes()) - .reduce(flatten, []) - .filter(uniques); - }; - auctionManager.createAuction = function(opts) { const auction = newAuction(opts); _addAuction(auction); @@ -99,7 +126,8 @@ export function newAuctionManager() { }; auctionManager.findBidByAdId = function(adId) { - return find(_auctions.map(auction => auction.getBidsReceived()).reduce(flatten, []), bid => bid.adId === adId); + return allBidsReceived() + .find(bid => bid.adId === adId); }; auctionManager.getStandardBidderAdServerTargeting = function() { @@ -111,24 +139,25 @@ export function newAuctionManager() { if (bid) bid.status = status; if (bid && status === CONSTANTS.BID_STATUS.BID_TARGETING_SET) { - const auction = find(_auctions, auction => auction.getAuctionId() === bid.auctionId); + const auction = getAuction(bid.auctionId); if (auction) auction.setBidTargeting(bid); } } auctionManager.getLastAuctionId = function() { - return _auctions.length && _auctions[_auctions.length - 1].getAuctionId() + const auctions = _auctions.toArray(); + return auctions.length && auctions[auctions.length - 1].getAuctionId() }; auctionManager.clearAllAuctions = function() { - _auctions.length = 0; + _auctions.clear(); } function _addAuction(auction) { - _auctions.push(auction); + _auctions.add(auction); } - auctionManager.index = new AuctionIndex(() => _auctions); + auctionManager.index = new AuctionIndex(() => _auctions.toArray()); return auctionManager; } diff --git a/src/bidTTL.js b/src/bidTTL.js new file mode 100644 index 00000000000..55ba0c026b0 --- /dev/null +++ b/src/bidTTL.js @@ -0,0 +1,25 @@ +import {config} from './config.js'; +import {logError} from './utils.js'; +let TTL_BUFFER = 1; + +const listeners = []; + +config.getConfig('ttlBuffer', (cfg) => { + if (typeof cfg.ttlBuffer === 'number') { + const prev = TTL_BUFFER; + TTL_BUFFER = cfg.ttlBuffer; + if (prev !== TTL_BUFFER) { + listeners.forEach(l => l(TTL_BUFFER)) + } + } else { + logError('Invalid value for ttlBuffer', cfg.ttlBuffer); + } +}) + +export function getTTL(bid) { + return bid.ttl - (bid.hasOwnProperty('ttlBuffer') ? bid.ttlBuffer : TTL_BUFFER); +} + +export function onTTLBufferChange(listener) { + listeners.push(listener); +} diff --git a/src/events.js b/src/events.js index 62f8c070deb..bea5d4ee4d9 100644 --- a/src/events.js +++ b/src/events.js @@ -3,23 +3,38 @@ */ import * as utils from './utils.js' import CONSTANTS from './constants.json'; +import {ttlCollection} from './utils/ttlCollection.js'; +import {config} from './config.js'; +const TTL_CONFIG = 'eventHistoryTTL'; -var slice = Array.prototype.slice; -var push = Array.prototype.push; +let eventTTL = null; -// define entire events -// var allEvents = ['bidRequested','bidResponse','bidWon','bidTimeout']; -var allEvents = utils._map(CONSTANTS.EVENTS, function (v) { - return v; +// keep a record of all events fired +const eventsFired = ttlCollection({ + monotonic: true, + ttl: () => eventTTL, +}) + +config.getConfig(TTL_CONFIG, (val) => { + const previous = eventTTL; + val = val?.[TTL_CONFIG]; + eventTTL = typeof val === 'number' ? val * 1000 : null; + if (previous !== eventTTL) { + eventsFired.refresh(); + } }); -var idPaths = CONSTANTS.EVENT_ID_PATHS; +let slice = Array.prototype.slice; +let push = Array.prototype.push; + +// define entire events +let allEvents = Object.values(CONSTANTS.EVENTS); + +const idPaths = CONSTANTS.EVENT_ID_PATHS; -// keep a record of all events fired -var eventsFired = []; const _public = (function () { - var _handlers = {}; - var _public = {}; + let _handlers = {}; + let _public = {}; /** * @@ -30,18 +45,18 @@ const _public = (function () { function _dispatch(eventString, args) { utils.logMessage('Emitting event for: ' + eventString); - var eventPayload = args[0] || {}; - var idPath = idPaths[eventString]; - var key = eventPayload[idPath]; - var event = _handlers[eventString] || { que: [] }; - var eventKeys = utils._map(event, function (v, k) { + let eventPayload = args[0] || {}; + let idPath = idPaths[eventString]; + let key = eventPayload[idPath]; + let event = _handlers[eventString] || { que: [] }; + let eventKeys = utils._map(event, function (v, k) { return k; }); - var callbacks = []; + let callbacks = []; // record the event: - eventsFired.push({ + eventsFired.add({ eventType: eventString, args: eventPayload, id: key, @@ -79,7 +94,7 @@ const _public = (function () { _public.on = function (eventString, handler, id) { // check whether available event or not if (_checkAvailableEvent(eventString)) { - var event = _handlers[eventString] || { que: [] }; + let event = _handlers[eventString] || { que: [] }; if (id) { event[id] = event[id] || { que: [] }; @@ -95,12 +110,12 @@ const _public = (function () { }; _public.emit = function (event) { - var args = slice.call(arguments, 1); + let args = slice.call(arguments, 1); _dispatch(event, args); }; _public.off = function (eventString, handler, id) { - var event = _handlers[eventString]; + let event = _handlers[eventString]; if (utils.isEmpty(event) || (utils.isEmpty(event.que) && utils.isEmpty(event[id]))) { return; @@ -112,14 +127,14 @@ const _public = (function () { if (id) { utils._each(event[id].que, function (_handler) { - var que = event[id].que; + let que = event[id].que; if (_handler === handler) { que.splice(que.indexOf(_handler), 1); } }); } else { utils._each(event.que, function (_handler) { - var que = event.que; + let que = event.que; if (_handler === handler) { que.splice(que.indexOf(_handler), 1); } @@ -142,13 +157,7 @@ const _public = (function () { * @return {Array} array of events fired */ _public.getEvents = function () { - var arrayCopy = []; - utils._each(eventsFired, function (value) { - var newProp = Object.assign({}, value); - arrayCopy.push(newProp); - }); - - return arrayCopy; + return eventsFired.toArray().map(val => Object.assign({}, val)) }; return _public; @@ -159,5 +168,5 @@ utils._setEventEmitter(_public.emit.bind(_public)); export const {on, off, get, getEvents, emit, addEvents} = _public; export function clearEvents() { - eventsFired.length = 0; + eventsFired.clear(); } diff --git a/src/targeting.js b/src/targeting.js index a75c9a2b52f..ed313c55684 100644 --- a/src/targeting.js +++ b/src/targeting.js @@ -24,19 +24,11 @@ import {hook} from './hook.js'; import {bidderSettings} from './bidderSettings.js'; import {find, includes} from './polyfill.js'; import CONSTANTS from './constants.json'; +import {getTTL} from './bidTTL.js'; var pbTargetingKeys = []; const MAX_DFP_KEYLENGTH = 20; -let DEFAULT_TTL_BUFFER = 1; - -config.getConfig('ttlBuffer', (cfg) => { - if (typeof cfg.ttlBuffer === 'number') { - DEFAULT_TTL_BUFFER = cfg.ttlBuffer; - } else { - logError('Invalid value for ttlBuffer', cfg.ttlBuffer); - } -}) const CFG_ALLOW_TARGETING_KEYS = `targetingControls.allowTargetingKeys`; const CFG_ADD_TARGETING_KEYS = `targetingControls.addTargetingKeys`; @@ -47,7 +39,7 @@ export const TARGETING_KEYS = Object.keys(CONSTANTS.TARGETING_KEYS).map( ); // return unexpired bids -const isBidNotExpired = (bid) => (bid.responseTimestamp + (bid.ttl - (bid.hasOwnProperty('ttlBuffer') ? bid.ttlBuffer : DEFAULT_TTL_BUFFER)) * 1000) > timestamp(); +const isBidNotExpired = (bid) => (bid.responseTimestamp + getTTL(bid) * 1000) > timestamp(); // return bids whose status is not set. Winning bids can only have a status of `rendered`. const isUnusedBid = (bid) => bid && ((bid.status && !includes([CONSTANTS.BID_STATUS.RENDERED], bid.status)) || !bid.status); diff --git a/src/utils.js b/src/utils.js index ece29732723..2ce4f9cc8bb 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1408,3 +1408,31 @@ export const escapeUnsafeChars = (() => { return str.replace(/[<>\b\f\n\r\t\0\u2028\u2029\\]/g, x => escapes[x]) } })(); + +/** + * Perform a binary search for `el` on an ordered array `arr`. + * + * @returns the lowest nonnegative integer I that satisfies: + * key(arr[i]) >= key(el) for each i between I and arr.length + * + * (if one or more matches are found for `el`, returns the index of the first; + * if the element is not found, return the index of the first element that's greater; + * if no greater element exists, return `arr.length`) + */ +export function binarySearch(arr, el, key = (el) => el) { + let left = 0; + let right = arr.length && arr.length - 1; + const target = key(el); + while (right - left > 1) { + const middle = left + Math.round((right - left) / 2); + if (target > key(arr[middle])) { + left = middle; + } else { + right = middle; + } + } + while (arr.length > left && target > key(arr[left])) { + left++; + } + return left; +} diff --git a/src/utils/ttlCollection.js b/src/utils/ttlCollection.js new file mode 100644 index 00000000000..392ed1c9ad7 --- /dev/null +++ b/src/utils/ttlCollection.js @@ -0,0 +1,139 @@ +import {GreedyPromise} from './promise.js'; +import {binarySearch, timestamp} from '../utils.js'; + +/** + * Create a set-like collection that automatically forgets items after a certain time. + * + * @param {({}) => Number|Promise} startTime? a function taking an item added to this collection, + * and returning (a promise to) a timestamp to be used as the starting time for the item + * (the item will be dropped after `ttl(item)` milliseconds have elapsed since this timestamp). + * Defaults to the time the item was added to the collection. + * @param {({}) => Number|void|Promise} ttl a function taking an item added to this collection, + * and returning (a promise to) the duration (in milliseconds) the item should be kept in it. + * May return null to indicate that the item should be persisted indefinitely. + * @param {boolean} monotonic? set to true for better performance, but only if, given any two items A and B in this collection: + * if A was added before B, then: + * - startTime(A) + ttl(A) <= startTime(B) + ttl(B) + * - Promise.all([startTime(A), ttl(A)]) never resolves later than Promise.all([startTime(B), ttl(B)]) + * @param {number} slack? maximum duration (in milliseconds) that an item is allowed to persist + * once past its TTL. This is also roughly the interval between "garbage collection" sweeps. + */ +export function ttlCollection( + { + startTime = timestamp, + ttl = () => null, + monotonic = false, + slack = 5000 + } = {} +) { + const items = new Map(); + const pendingPurge = []; + const markForPurge = monotonic + ? (entry) => pendingPurge.push(entry) + : (entry) => pendingPurge.splice(binarySearch(pendingPurge, entry, (el) => el.expiry), 0, entry) + let nextPurge, task; + + function reschedulePurge() { + task && clearTimeout(task); + if (pendingPurge.length > 0) { + const now = timestamp(); + nextPurge = Math.max(now, pendingPurge[0].expiry + slack); + task = setTimeout(() => { + const now = timestamp(); + let cnt = 0; + for (const entry of pendingPurge) { + if (entry.expiry > now) break; + items.delete(entry.item) + cnt++; + } + pendingPurge.splice(0, cnt); + task = null; + reschedulePurge(); + }, nextPurge - now); + } else { + task = null; + } + } + + function mkEntry(item) { + const values = {}; + const thisCohort = currentCohort; + let expiry; + + function update() { + if (thisCohort === currentCohort && values.start != null && values.delta != null) { + expiry = values.start + values.delta; + markForPurge(entry); + if (task == null || nextPurge > expiry + slack) { + reschedulePurge(); + } + } + } + + const [init, refresh] = Object.entries({ + start: startTime, + delta: ttl + }).map(([field, getter]) => { + let currentCall; + return function() { + const thisCall = currentCall = {}; + GreedyPromise.resolve(getter(item)).then((val) => { + if (thisCall === currentCall) { + values[field] = val; + update(); + } + }); + } + }) + + const entry = { + item, + refresh, + get expiry() { + return expiry; + }, + }; + + init(); + refresh(); + return entry; + } + + let currentCohort = {}; + + return { + [Symbol.iterator]: () => items.keys(), + /** + * Add an item to this collection. + * @param item + */ + add(item) { + !items.has(item) && items.set(item, mkEntry(item)); + }, + /** + * Clear this collection. + */ + clear() { + pendingPurge.length = 0; + reschedulePurge(); + items.clear(); + currentCohort = {}; + }, + /** + * @returns {[]} all the items in this collection, in insertion order. + */ + toArray() { + return Array.from(items.keys()); + }, + /** + * Refresh the TTL for each item in this collection. + */ + refresh() { + pendingPurge.length = 0; + reschedulePurge(); + for (const entry of items.values()) { + entry.refresh(); + } + }, + }; +} diff --git a/test/spec/auctionmanager_spec.js b/test/spec/auctionmanager_spec.js index 4061e757d97..d8598dc2063 100644 --- a/test/spec/auctionmanager_spec.js +++ b/test/spec/auctionmanager_spec.js @@ -763,9 +763,10 @@ describe('auctionmanager.js', function () { }); describe('createAuction', () => { - let adUnits, stubMakeBidRequests, stubCallAdapters + let adUnits, stubMakeBidRequests, stubCallAdapters, bids; beforeEach(() => { + bids = []; stubMakeBidRequests = sinon.stub(adapterManager, 'makeBidRequests').returns([{ bidderCode: BIDDER_CODE, bids: [{ @@ -773,6 +774,7 @@ describe('auctionmanager.js', function () { }] }]); stubCallAdapters = sinon.stub(adapterManager, 'callBids').callsFake((au, reqs, addBid, done) => { + bids.forEach(bid => addBid(bid.adUnitCode, bid)); reqs.forEach(r => done.apply(r)); }); adUnits = [{ @@ -787,6 +789,7 @@ describe('auctionmanager.js', function () { afterEach(() => { stubMakeBidRequests.restore(); stubCallAdapters.restore(); + auctionManager.clearAllAuctions(); }); it('passes global and bidder ortb2 to the auction', () => { @@ -814,6 +817,79 @@ describe('auctionmanager.js', function () { }); expect(auction.getNonBids()[0]).to.equal('test'); }); + + describe('stale auctions', () => { + let clock, auction; + beforeEach(() => { + clock = sinon.useFakeTimers(); + auction = auctionManager.createAuction({adUnits}); + indexAuctions.push(auction); + }); + afterEach(() => { + clock.restore(); + config.resetConfig(); + }); + + it('are dropped after their last bid becomes stale (if minBidCacheTTL is set)', () => { + config.setConfig({ + minBidCacheTTL: 0 + }); + bids = [ + { + adUnitCode: ADUNIT_CODE, + transactionId: ADUNIT_CODE, + ttl: 10 + }, { + adUnitCode: ADUNIT_CODE, + transactionId: ADUNIT_CODE, + ttl: 100 + } + ]; + auction.callBids(); + return auction.end.then(() => { + clock.tick(50 * 1000); + expect(auctionManager.getBidsReceived().length).to.equal(2); + clock.tick(56 * 1000); + expect(auctionManager.getBidsReceived()).to.eql([]); + }); + }); + + it('are dropped after `minBidCacheTTL` seconds if they had no bid', () => { + auction.callBids(); + config.setConfig({ + minBidCacheTTL: 2 + }); + return auction.end.then(() => { + expect(auctionManager.getNoBids().length).to.eql(1); + clock.tick(10 * 10000); + expect(auctionManager.getNoBids().length).to.eql(0); + }) + }); + + Object.entries({ + 'bids': { + bd: [{ + adUnitCode: ADUNIT_CODE, + transactionId: ADUNIT_CODE, + ttl: 10 + }], + entries: () => auctionManager.getBidsReceived() + }, + 'no bids': { + bd: [], + entries: () => auctionManager.getNoBids() + } + }).forEach(([t, {bd, entries}]) => { + it(`with ${t} are never dropped if minBidCacheTTL is not set`, () => { + bids = bd; + auction.callBids(); + return auction.end.then(() => { + clock.tick(100 * 1000); + expect(entries().length > 0).to.be.true; + }) + }) + }); + }) }); describe('addBidResponse #1', function () { @@ -1024,36 +1100,47 @@ describe('auctionmanager.js', function () { assert.strictEqual(addedBid.renderer.url, myBid.renderer.url); }); - it('bid for a regular unit and a video unit', function() { - let renderer = { - url: 'renderer.js', - render: (bid) => bid - }; - Object.assign(adUnits[0], {renderer}); - // make sure that if the renderer is only on the second ad unit, prebid - // still correctly uses it - let bid = mockBid(); - let bidRequests = [mockBidRequest(bid, {auctionId: auction.getAuctionId()})]; - - bidRequests[0].bids[1] = Object.assign({ - bidId: utils.getUniqueIdentifierStr() - }, bidRequests[0].bids[0]); - Object.assign(bidRequests[0].bids[0], { - adUnitCode: ADUNIT_CODE1, - transactionId: ADUNIT_CODE1, - }); + describe('bid for a regular unit and a video unit', () => { + beforeEach(() => { + const renderer = { + url: 'renderer.js', + render: (bid) => bid + }; + Object.assign(adUnits[0], {renderer}); + // make sure that if the renderer is only on the second ad unit, prebid + // still correctly uses it + let bid = mockBid(); + let bidRequests = [mockBidRequest(bid, {auctionId: auction.getAuctionId()})]; + + bidRequests[0].bids[1] = Object.assign({ + bidId: utils.getUniqueIdentifierStr() + }, bidRequests[0].bids[0]); + Object.assign(bidRequests[0].bids[0], { + adUnitCode: ADUNIT_CODE1, + transactionId: ADUNIT_CODE1, + }); - makeRequestsStub.returns(bidRequests); + makeRequestsStub.returns(bidRequests); - // this should correspond with the second bid in the bidReq because of the ad unit code - bid.mediaType = 'video-outstream'; - spec.interpretResponse.returns(bid); + // this should correspond with the second bid in the bidReq because of the ad unit code + bid.mediaType = 'video-outstream'; + spec.interpretResponse.returns(bid); + }); - auction.callBids(); + it('should use renderers on bid response', () => { + auction.callBids(); - const addedBid = find(auction.getBidsReceived(), bid => bid.adUnitCode == ADUNIT_CODE); - assert.equal(addedBid.renderer.url, 'renderer.js'); - }); + const addedBid = find(auction.getBidsReceived(), bid => bid.adUnitCode === ADUNIT_CODE); + assert.equal(addedBid.renderer.url, 'renderer.js'); + }); + + it('should resolve .end', () => { + auction.callBids(); + return auction.end.then(() => { + expect(auction.getBidsReceived().length).to.eql(1); + }) + }) + }) it('sets bidResponse.ttlBuffer from adUnit.ttlBuffer', () => { adUnits[0].ttlBuffer = 0; @@ -1087,15 +1174,33 @@ describe('auctionmanager.js', function () { events.emit.restore(); }); + function respondToRequest(requestIndex) { + server.requests[requestIndex].respond(200, {}, 'response body'); + } + + it('resolves .end on timeout', (done) => { + registerBidder(mockBidder(BIDDER_CODE, [bids[0]])); + registerBidder(mockBidder(BIDDER_CODE1, [bids[1]])); + let endResolved = false; + function callback() { + expect(endResolved).to.be.true; + done() + } + auction = auctionModule.newAuction({adUnits, adUnitCodes, callback, cbTimeout: 20}); + setupBids(auction.getAuctionId()); + auction.callBids(); + respondToRequest(0); + auction.end.then(() => { + endResolved = true; + }) + }) + it('should emit BID_TIMEOUT and AUCTION_END for timed out bids', function (done) { const spec1 = mockBidder(BIDDER_CODE, [bids[0]]); registerBidder(spec1); const spec2 = mockBidder(BIDDER_CODE1, [bids[1]]); registerBidder(spec2); - function respondToRequest(requestIndex) { - server.requests[requestIndex].respond(200, {}, 'response body'); - } function auctionCallback() { const bidTimeoutCall = eventsEmitSpy.withArgs(CONSTANTS.EVENTS.BID_TIMEOUT).getCalls()[0]; const timedOutBids = bidTimeoutCall.args[1]; @@ -1117,6 +1222,7 @@ describe('auctionmanager.js', function () { auction.callBids(); respondToRequest(0); }); + it('should NOT emit BID_TIMEOUT when all bidders responded in time', function (done) { const spec1 = mockBidder(BIDDER_CODE, [bids[0]]); registerBidder(spec1); @@ -1142,9 +1248,6 @@ describe('auctionmanager.js', function () { const spec2 = mockBidder(BIDDER_CODE1, []); registerBidder(spec2); - function respondToRequest(requestIndex) { - server.requests[requestIndex].respond(200, {}, 'response body'); - } function auctionCallback() { const bidTimeoutCall = eventsEmitSpy.withArgs(CONSTANTS.EVENTS.BID_TIMEOUT).getCalls()[0]; const timedOutBids = bidTimeoutCall.args[1]; diff --git a/test/spec/unit/core/events_spec.js b/test/spec/unit/core/events_spec.js new file mode 100644 index 00000000000..6551c9f2456 --- /dev/null +++ b/test/spec/unit/core/events_spec.js @@ -0,0 +1,30 @@ +import {config} from 'src/config.js'; +import {emit, clearEvents, getEvents} from '../../../../src/events.js'; + +describe('events', () => { + let clock; + beforeEach(() => { + clock = sinon.useFakeTimers(); + clearEvents(); + }); + afterEach(() => { + clock.restore(); + }); + + it('should clear event log using eventHistoryTTL config', () => { + emit('testEvent', {}); + expect(getEvents().length).to.eql(1); + config.setConfig({eventHistoryTTL: 1}); + clock.tick(500); + expect(getEvents().length).to.eql(1); + clock.tick(6000); + expect(getEvents().length).to.eql(0); + }); + + it('should take history TTL in seconds', () => { + emit('testEvent', {}); + config.setConfig({eventHistoryTTL: 1000}); + clock.tick(10000); + expect(getEvents().length).to.eql(1); + }) +}) diff --git a/test/spec/unit/pbjs_api_spec.js b/test/spec/unit/pbjs_api_spec.js index 5c361d186c0..b39c984316a 100644 --- a/test/spec/unit/pbjs_api_spec.js +++ b/test/spec/unit/pbjs_api_spec.js @@ -25,7 +25,6 @@ import {stubAuctionIndex} from '../../helpers/indexStub.js'; import {createBid} from '../../../src/bidfactory.js'; import {enrichFPD} from '../../../src/fpd/enrichment.js'; import {mockFpdEnrichments} from '../../helpers/fpd.js'; - var assert = require('chai').assert; var expect = require('chai').expect; @@ -43,13 +42,12 @@ var adUnits = getAdUnits(); var adUnitCodes = getAdUnits().map(unit => unit.code); var bidsBackHandler = function() {}; const timeout = 2000; -var auction = auctionManager.createAuction({adUnits, adUnitCodes, callback: bidsBackHandler, cbTimeout: timeout}); -auction.getBidRequests = getBidRequests; -auction.getBidsReceived = getBidResponses; -auction.getAdUnits = getAdUnits; -auction.getAuctionStatus = function() { return auctionModule.AUCTION_COMPLETED } +let auction; function resetAuction() { + if (auction == null) { + auction = auctionManager.createAuction({adUnits, adUnitCodes, callback: bidsBackHandler, cbTimeout: timeout}); + } $$PREBID_GLOBAL$$.setConfig({ enableSendAllBids: false }); auction.getBidRequests = getBidRequests; auction.getBidsReceived = getBidResponses; diff --git a/test/spec/unit/utils/ttlCollection_spec.js b/test/spec/unit/utils/ttlCollection_spec.js new file mode 100644 index 00000000000..29c6c438855 --- /dev/null +++ b/test/spec/unit/utils/ttlCollection_spec.js @@ -0,0 +1,180 @@ +import {ttlCollection} from '../../../../src/utils/ttlCollection.js'; + +describe('ttlCollection', () => { + it('can add & retrieve items', () => { + const coll = ttlCollection(); + expect(coll.toArray()).to.eql([]); + coll.add(1); + coll.add(2); + expect(coll.toArray()).to.eql([1, 2]); + }); + + it('can clear', () => { + const coll = ttlCollection(); + coll.add('item'); + coll.clear(); + expect(coll.toArray()).to.eql([]); + }); + + it('can be iterated over', () => { + const coll = ttlCollection(); + coll.add('1'); + coll.add('2'); + expect(Array.from(coll)).to.eql(['1', '2']); + }) + + describe('autopurge', () => { + let clock, pms, waitForPromises; + const SLACK = 2000; + beforeEach(() => { + clock = sinon.useFakeTimers(); + pms = []; + waitForPromises = () => Promise.all(pms); + }); + afterEach(() => { + clock.restore(); + }); + + Object.entries({ + 'defer': (value) => { + const pm = Promise.resolve(value); + pms.push(pm); + return pm; + }, + 'do not defer': (value) => value, + }).forEach(([t, resolve]) => { + describe(`when ttl/startTime ${t}`, () => { + let coll; + beforeEach(() => { + coll = ttlCollection({ + startTime: (item) => resolve(item.start == null ? new Date().getTime() : item.start), + ttl: (item) => resolve(item.ttl), + slack: SLACK + }) + }); + + it('should clear items after enough time has passed', () => { + coll.add({no: 'ttl'}); + coll.add({ttl: 1000}); + coll.add({ttl: 4000}); + return waitForPromises().then(() => { + clock.tick(500); + expect(coll.toArray()).to.eql([{no: 'ttl'}, {ttl: 1000}, {ttl: 4000}]); + clock.tick(SLACK + 500); + expect(coll.toArray()).to.eql([{no: 'ttl'}, {ttl: 4000}]); + clock.tick(3000); + expect(coll.toArray()).to.eql([{no: 'ttl'}]); + }); + }); + + it('should not wait too long if a shorter ttl shows up', () => { + coll.add({ttl: 4000}); + coll.add({ttl: 1000}); + return waitForPromises().then(() => { + clock.tick(1000 + SLACK); + expect(coll.toArray()).to.eql([ + {ttl: 4000} + ]); + }); + }); + + it('should not wait more if later ttls are within slack', () => { + coll.add({start: 0, ttl: 4000}); + return waitForPromises().then(() => { + clock.tick(4000); + coll.add({start: 0, ttl: 5000}); + return waitForPromises().then(() => { + clock.tick(SLACK); + expect(coll.toArray()).to.eql([]); + }); + }); + }); + + it('should clear items ASAP if they expire in the past', () => { + clock.tick(10000); + coll.add({start: 0, ttl: 1000}); + return waitForPromises().then(() => { + clock.tick(SLACK); + expect(coll.toArray()).to.eql([]); + }); + }); + + it('should clear items ASAP if they have ttl = 0', () => { + coll.add({ttl: 0}); + return waitForPromises().then(() => { + clock.tick(SLACK); + expect(coll.toArray()).to.eql([]); + }); + }); + + describe('refresh', () => { + it('should refresh missing TTLs', () => { + const item = {}; + coll.add(item); + return waitForPromises().then(() => { + item.ttl = 1000; + return waitForPromises().then(() => { + clock.tick(1000 + SLACK); + expect(coll.toArray()).to.eql([item]); + coll.refresh(); + return waitForPromises().then(() => { + clock.tick(1); + expect(coll.toArray()).to.eql([]); + }); + }); + }); + }); + + it('should refresh existing TTLs', () => { + const item = { + ttl: 1000 + }; + coll.add(item); + return waitForPromises().then(() => { + clock.tick(1000); + item.ttl = 4000; + coll.refresh(); + return waitForPromises().then(() => { + clock.tick(SLACK); + expect(coll.toArray()).to.eql([item]); + clock.tick(3000); + expect(coll.toArray()).to.eql([]); + }); + }); + }); + + it('should discard initial TTL if it does not resolve before a refresh', () => { + let resolveTTL; + const item = { + ttl: new Promise((resolve) => { + resolveTTL = resolve; + }) + }; + coll.add(item); + item.ttl = null; + coll.refresh(); + resolveTTL(1000); + return waitForPromises().then(() => { + clock.tick(1000 + SLACK + 1000); + expect(coll.toArray()).to.eql([item]); + }); + }); + + it('should discard TTLs on clear', () => { + const item = { + ttl: 1000 + }; + coll.add(item); + coll.clear(); + item.ttl = null; + coll.add(item); + return waitForPromises().then(() => { + clock.tick(1000 + SLACK + 1000); + expect(coll.toArray()).to.eql([item]); + }); + }); + }); + }); + }); + }); +}); diff --git a/test/spec/utils_spec.js b/test/spec/utils_spec.js index e26683074c8..6e7f8ba0e8f 100644 --- a/test/spec/utils_spec.js +++ b/test/spec/utils_spec.js @@ -2,7 +2,7 @@ import {getAdServerTargeting} from 'test/fixtures/fixtures.js'; import {expect} from 'chai'; import CONSTANTS from 'src/constants.json'; import * as utils from 'src/utils.js'; -import {deepEqual, memoize, waitForElementToLoad} from 'src/utils.js'; +import {binarySearch, deepEqual, memoize, waitForElementToLoad} from 'src/utils.js'; var assert = require('assert'); @@ -1233,5 +1233,44 @@ describe('memoize', () => { mem('one', 'three'); expect(mem('one', 'three')).to.eql(['one', 'three']); expect(fn.callCount).to.eql(2); - }) + }); + + describe('binarySearch', () => { + [ + { + arr: [], + tests: [ + ['any', 0] + ] + }, + { + arr: [10], + tests: [ + [5, 0], + [10, 0], + [20, 1], + ], + }, + { + arr: [10, 20, 30, 30, 40], + tests: [ + [5, 0], + [15, 1], + [10, 0], + [30, 2], + [35, 4], + [40, 4], + [100, 5] + ] + } + ].forEach(({arr, tests}) => { + describe(`on ${arr}`, () => { + tests.forEach(([el, pos]) => { + it(`finds index for ${el} => ${pos}`, () => { + expect(binarySearch(arr, el)).to.equal(pos); + }); + }); + }); + }) + }); })