From a7fb4add867dc58becd9c89bdba12ab7e9961686 Mon Sep 17 00:00:00 2001 From: mkomorski Date: Tue, 3 Dec 2024 17:18:45 +0100 Subject: [PATCH] Currency Module: Adding auction delay handling (#12364) * Delay auction param on currency module * hookConfig change * test improvement * review fixes * introducing timeoutQueue * fix --------- Co-authored-by: Marcin Komorski --- libraries/timeoutQueue/timeoutQueue.js | 22 ++++++++++ modules/currency.js | 26 +++++++++++- modules/priceFloors.js | 37 +++++------------ test/spec/modules/currency_spec.js | 56 +++++++++++++++++++++++++- 4 files changed, 110 insertions(+), 31 deletions(-) create mode 100644 libraries/timeoutQueue/timeoutQueue.js diff --git a/libraries/timeoutQueue/timeoutQueue.js b/libraries/timeoutQueue/timeoutQueue.js new file mode 100644 index 00000000000..5046eed150b --- /dev/null +++ b/libraries/timeoutQueue/timeoutQueue.js @@ -0,0 +1,22 @@ +export function timeoutQueue() { + const queue = []; + return { + submit(timeout, onResume, onTimeout) { + const item = [ + onResume, + setTimeout(() => { + queue.splice(queue.indexOf(item), 1); + onTimeout(); + }, timeout) + ]; + queue.push(item); + }, + resume() { + while (queue.length) { + const [onResume, timerId] = queue.shift(); + clearTimeout(timerId); + onResume(); + } + } + } +} diff --git a/modules/currency.js b/modules/currency.js index 8ac2b8cbead..d040dc2cf49 100644 --- a/modules/currency.js +++ b/modules/currency.js @@ -6,11 +6,13 @@ import {config} from '../src/config.js'; import {getHook} from '../src/hook.js'; import {defer} from '../src/utils/promise.js'; import {registerOrtbProcessor, REQUEST} from '../src/pbjsORTB.js'; -import {timedBidResponseHook} from '../src/utils/perfMetrics.js'; +import {timedAuctionHook, timedBidResponseHook} from '../src/utils/perfMetrics.js'; import {on as onEvent, off as offEvent} from '../src/events.js'; +import { timeoutQueue } from '../libraries/timeoutQueue/timeoutQueue.js'; const DEFAULT_CURRENCY_RATE_URL = 'https://cdn.jsdelivr.net/gh/prebid/currency-file@1/latest.json?date=$$TODAY$$'; const CURRENCY_RATE_PRECISION = 4; +const MODULE_NAME = 'currency'; let ratesURL; let bidResponseQueue = []; @@ -26,6 +28,9 @@ let defaultRates; export let responseReady = defer(); +const delayedAuctions = timeoutQueue(); +let auctionDelay = 0; + /** * Configuration function for currency * @param {object} config @@ -77,6 +82,7 @@ export function setConfig(config) { } if (typeof config.adServerCurrency === 'string') { + auctionDelay = config.auctionDelay; logInfo('enabling currency support', arguments); adServerCurrency = config.adServerCurrency; @@ -106,6 +112,7 @@ export function setConfig(config) { initCurrency(); } else { // currency support is disabled, setting defaults + auctionDelay = 0; logInfo('disabling currency support'); resetCurrency(); } @@ -137,6 +144,7 @@ function loadRates() { conversionCache = {}; currencyRatesLoaded = true; processBidResponseQueue(); + delayedAuctions.resume(); } catch (e) { errorSettingsRates('Failed to parse currencyRates response: ' + response); } @@ -145,6 +153,7 @@ function loadRates() { errorSettingsRates(...args); currencyRatesLoaded = true; processBidResponseQueue(); + delayedAuctions.resume(); needToCallForCurrencyFile = true; } } @@ -162,6 +171,7 @@ function initCurrency() { getGlobal().convertCurrency = (cpm, fromCurrency, toCurrency) => parseFloat(cpm) * getCurrencyConversion(fromCurrency, toCurrency); getHook('addBidResponse').before(addBidResponseHook, 100); getHook('responsesReady').before(responsesReadyHook); + getHook('requestBids').before(requestBidsHook, 50); onEvent(EVENTS.AUCTION_TIMEOUT, rejectOnAuctionTimeout); onEvent(EVENTS.AUCTION_INIT, loadRates); loadRates(); @@ -172,6 +182,7 @@ function resetCurrency() { if (currencySupportEnabled) { getHook('addBidResponse').getHooks({hook: addBidResponseHook}).remove(); getHook('responsesReady').getHooks({hook: responsesReadyHook}).remove(); + getHook('requestBids').getHooks({hook: requestBidsHook}).remove(); offEvent(EVENTS.AUCTION_TIMEOUT, rejectOnAuctionTimeout); offEvent(EVENTS.AUCTION_INIT, loadRates); delete getGlobal().convertCurrency; @@ -335,3 +346,16 @@ export function setOrtbCurrency(ortbRequest, bidderRequest, context) { } registerOrtbProcessor({type: REQUEST, name: 'currency', fn: setOrtbCurrency}); + +export const requestBidsHook = timedAuctionHook('currency', function requestBidsHook(fn, reqBidsConfigObj) { + const continueAuction = ((that) => () => fn.call(that, reqBidsConfigObj))(this); + + if (!currencyRatesLoaded && auctionDelay > 0) { + delayedAuctions.submit(auctionDelay, continueAuction, () => { + logWarn(`${MODULE_NAME}: Fetch attempt did not return in time for auction ${reqBidsConfigObj.auctionId}`) + continueAuction(); + }); + } else { + continueAuction(); + } +}); diff --git a/modules/priceFloors.js b/modules/priceFloors.js index 54353a15c4e..d14a82af360 100644 --- a/modules/priceFloors.js +++ b/modules/priceFloors.js @@ -3,7 +3,6 @@ import { deepAccess, deepClone, deepSetValue, - generateUUID, getParameterByName, isNumber, logError, @@ -13,7 +12,8 @@ import { parseGPTSingleSizeArray, parseUrl, pick, - deepEqual + deepEqual, + generateUUID } from '../src/utils.js'; import {getGlobal} from '../src/prebidGlobal.js'; import {config} from '../src/config.js'; @@ -30,6 +30,7 @@ import {timedAuctionHook, timedBidResponseHook} from '../src/utils/perfMetrics.j import {adjustCpm} from '../src/utils/cpm.js'; import {getGptSlotInfoForAdUnitCode} from '../libraries/gptUtils/gptUtils.js'; import {convertCurrency} from '../libraries/currencyUtils/currency.js'; +import { timeoutQueue } from '../libraries/timeoutQueue/timeoutQueue.js'; export const FLOOR_SKIPPED_REASON = { NOT_FOUND: 'not_found', @@ -72,7 +73,7 @@ let _floorsConfig = {}; /** * @summary If a auction is to be delayed by an ongoing fetch we hold it here until it can be resumed */ -let _delayedAuctions = []; +const _delayedAuctions = timeoutQueue(); /** * @summary Each auction can have differing floors data depending on execution time or per adunit setup @@ -440,17 +441,11 @@ export function createFloorsDataForAuction(adUnits, auctionId) { * @summary This is the function which will be called to exit our module and continue the auction. */ export function continueAuction(hookConfig) { - // only run if hasExited if (!hookConfig.hasExited) { - // if this current auction is still fetching, remove it from the _delayedAuctions - _delayedAuctions = _delayedAuctions.filter(auctionConfig => auctionConfig.timer !== hookConfig.timer); - // We need to know the auctionId at this time. So we will use the passed in one or generate and set it ourselves hookConfig.reqBidsConfigObj.auctionId = hookConfig.reqBidsConfigObj.auctionId || generateUUID(); - // now we do what we need to with adUnits and save the data object to be used for getFloor and enforcement calls _floorDataForAuction[hookConfig.reqBidsConfigObj.auctionId] = createFloorsDataForAuction(hookConfig.reqBidsConfigObj.adUnits || getGlobal().adUnits, hookConfig.reqBidsConfigObj.auctionId); - hookConfig.nextFn.apply(hookConfig.context, [hookConfig.reqBidsConfigObj]); hookConfig.hasExited = true; } @@ -581,36 +576,22 @@ export const requestBidsHook = timedAuctionHook('priceFloors', function requestB reqBidsConfigObj, context: this, nextFn: fn, - haveExited: false, + hasExited: false, timer: null }; // If auction delay > 0 AND we are fetching -> Then wait until it finishes if (_floorsConfig.auctionDelay > 0 && fetching) { - hookConfig.timer = setTimeout(() => { + _delayedAuctions.submit(_floorsConfig.auctionDelay, () => continueAuction(hookConfig), () => { logWarn(`${MODULE_NAME}: Fetch attempt did not return in time for auction`); _floorsConfig.fetchStatus = 'timeout'; continueAuction(hookConfig); - }, _floorsConfig.auctionDelay); - _delayedAuctions.push(hookConfig); + }); } else { continueAuction(hookConfig); } }); -/** - * @summary If an auction was queued to be delayed (waiting for a fetch) then this function will resume - * those delayed auctions when delay is hit or success return or fail return - */ -function resumeDelayedAuctions() { - _delayedAuctions.forEach(auctionConfig => { - // clear the timeout - clearTimeout(auctionConfig.timer); - continueAuction(auctionConfig); - }); - _delayedAuctions = []; -} - /** * This function handles the ajax response which comes from the user set URL to fetch floors data from * @param {object} fetchResponse The floors data response which came back from the url configured in config.floors @@ -635,7 +616,7 @@ export function handleFetchResponse(fetchResponse) { } // if any auctions are waiting for fetch to finish, we need to continue them! - resumeDelayedAuctions(); + _delayedAuctions.resume(); } function handleFetchError(status) { @@ -644,7 +625,7 @@ function handleFetchError(status) { logError(`${MODULE_NAME}: Fetch errored with: `, status); // if any auctions are waiting for fetch to finish, we need to continue them! - resumeDelayedAuctions(); + _delayedAuctions.resume(); } /** diff --git a/test/spec/modules/currency_spec.js b/test/spec/modules/currency_spec.js index e96867f4e84..149a4380036 100644 --- a/test/spec/modules/currency_spec.js +++ b/test/spec/modules/currency_spec.js @@ -3,7 +3,7 @@ import { getCurrencyRates } from 'test/fixtures/fixtures.js'; -import { getGlobal } from 'src/prebidGlobal.js'; +import {getGlobal} from 'src/prebidGlobal.js'; import { setConfig, @@ -13,9 +13,11 @@ import { responseReady } from 'modules/currency.js'; import {createBid} from '../../../src/bidfactory.js'; -import { EVENTS, STATUS, REJECTION_REASON } from '../../../src/constants.js'; +import * as utils from 'src/utils.js'; +import {EVENTS, STATUS, REJECTION_REASON} from '../../../src/constants.js'; import {server} from '../../mocks/xhr.js'; import * as events from 'src/events.js'; +import {requestBidsHook} from '../../../modules/currency.js'; var assert = require('chai').assert; var expect = require('chai').expect; @@ -522,4 +524,54 @@ describe('currency', function () { expect(innerBid.currency).to.equal('CNY'); }); }); + + describe('auctionDelay param', () => { + const continueAuction = sinon.stub(); + let logWarnSpy; + + beforeEach(function() { + sandbox = sinon.sandbox.create(); + clock = sinon.useFakeTimers(1046952000000); // 2003-03-06T12:00:00Z + logWarnSpy = sinon.spy(utils, 'logWarn'); + }); + + afterEach(function () { + clock.runAll(); + sandbox.restore(); + clock.restore(); + utils.logWarn.restore(); + continueAuction.resetHistory(); + }); + + it('should delay auction start when auctionDelay set in module config', () => { + setConfig({auctionDelay: 2000, adServerCurrency: 'USD'}); + const reqBidsConfigObj = { + auctionId: '128937' + }; + requestBidsHook(continueAuction, reqBidsConfigObj); + clock.tick(1000); + expect(continueAuction.notCalled).to.be.true; + }); + + it('should start auction when auctionDelay time passed', () => { + setConfig({auctionDelay: 2000, adServerCurrency: 'USD'}); + const reqBidsConfigObj = { + auctionId: '128937' + }; + requestBidsHook(continueAuction, reqBidsConfigObj); + clock.tick(3000); + expect(logWarnSpy.calledOnce).to.equal(true); + expect(continueAuction.calledOnce).to.be.true; + }); + + it('should run auction if rates were fetched before auctionDelay time', () => { + setConfig({auctionDelay: 3000, adServerCurrency: 'USD'}); + const reqBidsConfigObj = { + auctionId: '128937' + }; + fakeCurrencyFileServer.respond(); + requestBidsHook(continueAuction, reqBidsConfigObj); + expect(continueAuction.calledOnce).to.be.true; + }); + }); });