AndyRussG has uploaded a new change for review. https://gerrit.wikimedia.org/r/205193
Change subject: Allocation: choose campaign first, fix throttling bug ...................................................................... Allocation: choose campaign first, fix throttling bug This patch rewrites allocation client-side allocation code and its PHP mirror used by the admin interface. Instead of allocating from a flat list of banners from all campaigns targeting the user, we choose a campaign first, then choose a banner within that campaign. Along the way, we fix a bug in how throttling works. Bug: T96194 Change-Id: I42985cf4de2dfeba9244b8d82f63b3d9d8319c81 --- M includes/BannerAllocationCalculator.php M includes/BannerChoiceDataProvider.php M modules/ext.centralNotice.bannerController/bannerController.js M modules/ext.centralNotice.bannerController/bannerController.lib.js M special/SpecialBannerAllocation.php M tests/qunit/ext.centralNotice.bannerController.lib/bannerController.lib.tests.js 6 files changed, 654 insertions(+), 462 deletions(-) git pull ssh://gerrit.wikimedia.org:29418/mediawiki/extensions/CentralNotice refs/changes/93/205193/1 diff --git a/includes/BannerAllocationCalculator.php b/includes/BannerAllocationCalculator.php index 871319f..b6798a5 100644 --- a/includes/BannerAllocationCalculator.php +++ b/includes/BannerAllocationCalculator.php @@ -96,62 +96,231 @@ } /** - * Allocation helper. Maps an array of campaigns with banners to a flattened - * list of banners, omitting those not available for the specified logged-in - * status, device and bucket. + * Filter an array in the format output by + * BannerCHoiceDataProvider::getChoices(), based on country, logged-in + * status and device. This method is the server-side equivalent of + * mw.cnBannerControllerLib.filterChoiceData(). (However, this method does + * not perform campaign freshness checks.) * - * @param array $campaigns campaigns with banners as returned by + * @param array $choiceData Campaigns with banners as returned by * @see BannerChoiceDataProvider::getChoicesForCountry + * + * @param string $country Country of interest * * @param integer $status A status constant defined by this class (i.e., * BannerAllocationCalculator::ANONYMOUS or * BannerAllocationCalculator::LOGGED_IN). * * @param string $device target device code - * @param integer $bucket target bucket number * - * @return array banners with properties suitable for - * @see BannerAllocationCalculator::calculateAllocations + * @return array A filtered copy of $choiceData */ - static function filterAndTransformBanners( - $campaigns, $status, $device, $bucket ) { + static function filterChoiceData( &$choiceData, $country, $status, $device ) { - // Set which property we need to check to filter logged-in status - switch ( $status ) { - case self::ANONYMOUS: - $status_prop = 'display_anon'; - break; + $filteredChoiceData = array(); - case self::LOGGED_IN: - $status_prop = 'display_account'; - break; + foreach ( $choiceData as $campaign ) { - default: - throw new MWException( $this->status . 'is not a valid status ' - . 'for BannerAllocationsCalculator.' ); - } + $keepCampaign = false; - $banners = array(); - foreach( $campaigns as $campaign ) { + // Filter for country if geotargeted + if ( $campaign['geotargeted'] && + !in_array( $country, $campaign['countries'] ) ) { + + continue; + } + + // Now filter by banner logged-in status and device foreach ( $campaign['banners'] as $banner ) { - if ( !$banner[$status_prop] ) { + + // Logged-in status + if ( $status === BannerAllocationCalculator::ANONYMOUS && + !$banner['display_anon'] ) { continue; } + if ( $status === BannerAllocationCalculator::LOGGED_IN && + !$banner['display_account'] ) { + continue; + } + + // Device if ( !in_array( $device, $banner['devices'] ) ) { continue; } - if ( $bucket % $campaign['bucket_count'] != $banner['bucket'] ) { - continue; - } - $banner['campaign'] = $campaign['name']; - $banner['campaign_throttle'] = $campaign['throttle']; - $banner['campaign_z_index'] = $campaign['preferred']; - $banners[] = $banner; + + // We get here if the campaign targets the requested country, + // and has at least one banner for the requested logged-in status + // and device. + $keepCampaign = true; + } + + if ( $keepCampaign ) { + $filteredChoiceData[] = $campaign; } } + $choiceData = $filteredChoiceData; + } + + /** + * On $filteredChoiceData calculate the probability that + * of receiving each campaign in this.choiceData. This takes into account + * campaign priority and throttling. The equivalent client-side method + * is mw.cnBannerControllerLib.calculateCampaignAllocations(). + * + * @param array $filteredChoiceData Data in the format provided by + * filteredChoiceData(). + */ + public static function calculateCampaignAllocations( &$filteredChoiceData ) { + + // Make an index of campaigns by priority level. + // Note that the actual values of priority levels are integers, + // and higher integers represent higher priority. These values are + // defined by class constants in CentralNotice. + + $campaignsByPriority = array(); + foreach ( $filteredChoiceData as &$campaign ) { + $priority = $campaign['preferred']; + $campaignsByPriority[$priority][] = &$campaign; + } + + // Sort the index by priority, in descending order + krsort( $campaignsByPriority ); + + // Now go through the priority levels from highest to lowest. If + // campaigns are not throttled, then campaigns with a higher + // priority level will eclipse all campaigns with lower priority. + // Only if some campaigns are throttled will they allow some space + // for campaigns at the next level down. + + $remainingAllocation = 1; + + foreach ( $campaignsByPriority as $priority => &$campaignsAtThisPriority ) { + + // If we fully allocated at a previous level, set allocations + // at this level to zero. (We check with 0.01 instead of 0 in + // case of issues due to finite precision.) + if ( $remainingAllocation <= 0.01 ) { + foreach ( $campaignsAtThisPriority as &$campaign ) { + $campaign['allocation'] = 0; + } + continue; + } + + // If we are here, there is some allocation remaining. + + // All campaigns at a given priority level are alloted the same + // allocation, unless they are throttled, in which case the + // throttling value (taken as a percentage of the whole + // allocation pie) is their maximum possible allocation. + + // To calculate this, we'll loop through the campaigns at this + // level in order from the most throttled (lowest throttling + // value) to the least throttled (highest value) and on each + // loop, we'll re-calculate the remaining total allocation and + // the proportional (i.e. unthrottled) allocation available to + // each campaign. + + // First, sort the campaigns by throttling value + + usort( $campaignsAtThisPriority, function ( $a, $b ) { + if ( $a['throttle'] < $b['throttle'] ) { + return -1; + } + if ( $a['throttle'] > $b['throttle'] ) { + return 1; + } + return 0; + } ); + + $campaignsAtThisPriorityCount = count( $campaignsAtThisPriority ); + foreach ( $campaignsAtThisPriority as $i => &$campaign ) { + + // Calculate the proportional, unthrottled allocation now + // available to a campaign at this level. + $currentFullAllocation = + $remainingAllocation / ( $campaignsAtThisPriorityCount - $i ); + + // A campaign may get the above amount, or less, if + // throttling indicates that'd be too much. + $actualAllocation = + min( $currentFullAllocation, $campaign['throttle'] / 100 ); + + $campaign['allocation'] = $actualAllocation; + + // Update remaining allocation + $remainingAllocation -= $actualAllocation; + } + } + } + + /** + * Filter banners for $campaign on $bucket, $status and $device, and return + * a list of possible banners for this context. The equivalent client-side method + * is mw.cnBannerControllerLib.makePossibleBanners(). + * + * @param array $campaign Campaign data in the format of a single entry + * in the array provided by filteredChoiceData(). + * + * @param int $bucket Bucket of interest + * + * @param integer $status A status constant defined by this class (i.e., + * BannerAllocationCalculator::ANONYMOUS or + * BannerAllocationCalculator::LOGGED_IN). + * + * @param string $device target device code + */ + public static function makePossibleBanners( $campaign, $bucket, $status, $device ) { + + $banners = array(); + + foreach ( $campaign['banners'] as $banner ) { + + // Filter for bucket + if ( $bucket % $campaign['bucket_count'] != $banner['bucket'] ) { + continue; + } + + // Filter for logged-in status + if ( $status === BannerAllocationCalculator::ANONYMOUS && + !$banner['display_anon'] ) { + continue; + } + if ( $status === BannerAllocationCalculator::LOGGED_IN && + !$banner['display_account'] ) { + continue; + } + + // Filter for device + if ( !in_array( $device, $banner['devices'] ) ) { + continue; + } + + $banners[] = $banner; + } + return $banners; } + /** + * Calculate banner allocation based on relative weights. The equivalent + * client-side method is + * mw.cnBannerControllerLib.calculateBannerAllocations(). + */ + public static function calculateBannerAllocations( &$banners ) { + + $totalWeights = 0; + + // Find the sum of all banner weights + foreach ( $banners as $banner ) { + $totalWeights += $banner['weight']; + } + + // Set allocation property to the normalized weight + foreach ( $banners as &$banner ) { + $banner['allocation'] = $banner['weight'] / $totalWeights; + } + } + static public function getLoggedInStatusFromString( $s ) { switch ( $s ) { case 'anonymous': diff --git a/includes/BannerChoiceDataProvider.php b/includes/BannerChoiceDataProvider.php index e71d451..9c1914f 100644 --- a/includes/BannerChoiceDataProvider.php +++ b/includes/BannerChoiceDataProvider.php @@ -248,24 +248,4 @@ return $choices; } - - /** - * Gets banner choices filtered by country - * - * @param string $country Country of interest - * @return array An array of the form returned by @see BannerChoiceDataProvider::getChoices - * Omits all geotargeted campaigns not aimed at the given country - */ - public function getChoicesForCountry( $country ) { - $choices = $this->getChoices(); - - $filteredChoices = array(); - // Remove campaigns that are geotargeted but not to selected country - foreach( $choices as $campaign ) { - if ( !$campaign['geotargeted'] || in_array( $country, $campaign['countries'] ) ) { - $filteredChoices[] = $campaign; - } - } - return $filteredChoices; - } } diff --git a/modules/ext.centralNotice.bannerController/bannerController.js b/modules/ext.centralNotice.bannerController/bannerController.js index 47d0897..9e5692e 100644 --- a/modules/ext.centralNotice.bannerController/bannerController.js +++ b/modules/ext.centralNotice.bannerController/bannerController.js @@ -171,63 +171,32 @@ }, loadRandomBanner: function () { - var fetchBannerQueryParams = { + var fetchBannerQueryParams, + scriptUrl; + + mw.centralNotice.chooseRandomBanner(); + + if ( mw.centralNotice.data.banner ) { + + // A banner was chosen! Let's fetch it. + + // TODO remove unneeded params + fetchBannerQueryParams = { + banner: mw.centralNotice.data.banner, + campaign: mw.centralNotice.data.campaign, uselang: mw.config.get( 'wgUserLanguage' ), project: mw.config.get( 'wgNoticeProject' ), anonymous: mw.config.get( 'wgUserName' ) === null, country: mw.centralNotice.data.country, device: mw.centralNotice.data.device, debug: mw.centralNotice.data.getVars.debug - }, - scriptUrl, - random; - - // Choose the banner on the client. - - // Continue only if the server sent choices - if ( mw.cnBannerControllerLib.choiceData - && mw.cnBannerControllerLib.choiceData.length > 0 - ) { - - // If the server did send one or more choices: let the - // processing begin! - - // Filter choiceData on country and device. Only campaigns that - // target the user's country and have at least one banner for - // the user's logged-in status and device pass this filter. - mw.cnBannerControllerLib.filterChoiceData(); - - // Again check if there are choices available. This result may - // have changed following the above call to filterChoiceData(). - if ( mw.cnBannerControllerLib.choiceData.length > 0 ) { - - // Do all things bucket. Retrieve or generate buckets for all - // the campaigns remaining in choiceData. Then update expiry - // dates and remove expired buckets as necessary. - mw.cnBannerControllerLib.processBuckets(); - - // Create a flat list of possible banners available for the - // user's buckets, logged-in status and device, and calculate - // allocations. - mw.cnBannerControllerLib.makePossibleBanners(); - mw.cnBannerControllerLib.calculateBannerAllocations(); - - // Get a random seed or use the random= parameter from the URL, - // and choose the banner. - random = mw.centralNotice.data.getVars.random || Math.random(); - mw.cnBannerControllerLib.chooseBanner( random ); - } - } - - // Only fetch a banner if we need to :) - if ( mw.centralNotice.data.banner ) { - fetchBannerQueryParams.banner = mw.centralNotice.data.banner; - fetchBannerQueryParams.campaign = mw.centralNotice.data.campaign; + }; scriptUrl = new mw.Uri( mw.config.get( 'wgCentralSelectedBannerDispatcher' ) ); scriptUrl.extend( fetchBannerQueryParams ); - // This will call insertBanner() after the banner is retrieved + // The returned javascript will call insertBanner() after the + // banner is retrieved. $.ajax( { url: scriptUrl.toString(), dataType: 'script', @@ -235,12 +204,107 @@ } ); } else { - // Call insertBanner and set the onlySampleRI flag to true - // to sample empty results and return them via - // Special:RecordImpression. + + // No banner for this user! + + // Set the onlySampleRI flag to true to sample empty results + // and return them via Special:RecordImpression. // TODO Refactor mw.centralNotice.onlySampleRI = true; mw.centralNotice.insertBanner( false ); + } + }, + /** + * Choose a banner or no banner randomly (or using the random1 and + * random2 URL parameters). If a banner is chosen its name will be put + * in mw.centralNotice.data.banner and the campaign name will be put in + * mw.centralNotice.data.campaign. + */ + chooseRandomBanner: function () { + + var random1, random2; + + // Check that we got choiceData (sanity check) and see if there + // are choices. TODO Log if there was no choiceData. + if ( !mw.cnBannerControllerLib.choiceData + || mw.cnBannerControllerLib.choiceData.length === 0 ) { + return; + } + + // The server did send one or more choices: let the processing begin! + + // Filter choiceData on country and device. Only campaigns that + // target the user's country and have at least one banner for + // the user's logged-in status and device pass this filter. + mw.cnBannerControllerLib.filterChoiceData(); + + // Again check if there are choices available. This result may + // have changed following the above call to filterChoiceData(). + if ( mw.cnBannerControllerLib.choiceData.length === 0 ) { + return; + } + + // Optimize for the common scenario of a single unthrottled campaign + if ( ( mw.cnBannerControllerLib.choiceData.length === 1 ) && + ( mw.cnBannerControllerLib.choiceData[0].throttle === 100 ) ) { + + mw.cnBannerControllerLib.setCampaign( + mw.cnBannerControllerLib.choiceData[0] ); + + } else { + + // Otherwise, we need to calculate allocations and choose randomly + + // Calculate the user's probability of getting each campaign + mw.cnBannerControllerLib.calculateCampaignAllocations(); + + // Get a random seed or use the random1= parameter from the URL, and + // choose the campaign. + random1 = mw.centralNotice.data.getVars.random1 || Math.random(); + mw.cnBannerControllerLib.chooseCampaign( random1 ); + } + + // Check if a campaign was selected (we might have chosen an + // unallocated block). + if ( !mw.cnBannerControllerLib.campaign ) { + return; + } + + // Do all things bucket. Retrieve or generate a bucket for this + // campaign. Then, update expiry dates and remove expired buckets as + // necessary. + mw.cnBannerControllerLib.processBuckets(); + + // Create a list of possible banners available in this campaign for + // the user's bucket, logged-in status and device, and calculate + // allocations. + mw.cnBannerControllerLib.makePossibleBanners(); + + // Because of our wonky domain model, it's possible to have banners + // with different criteria (logged-in status, device) in different + // buckets in the same campaign. So we might be in a bucket with no + // banners for this user, which would mean no possible banners. + if ( mw.cnBannerControllerLib.possibleBanners.length === 0 ) { + return; + } + + // If there's just one banner available for the user in this + // campaign, choose it. This is by far our most common scenario, so + // we'll optimize for it. In this case the random2 URL parameter is + // ignored. + if ( mw.cnBannerControllerLib.possibleBanners.length === 1 ) { + + mw.cnBannerControllerLib.setBanner( + mw.cnBannerControllerLib.possibleBanners[0] ); + + } else { + // Otherwise, calculate allocations. + mw.cnBannerControllerLib.calculateBannerAllocations(); + + // Get another random seed or use the random2= parameter from the + // URL, and choose the banner. + random2 = mw.centralNotice.data.getVars.random2 || Math.random(); + mw.cnBannerControllerLib.chooseBanner( random2 ); } }, // TODO: move function definitions once controller cache has cleared @@ -290,27 +354,20 @@ } ); }, /** - * Legacy function for getting the legacy global bucket. Left here for - * compatibility. + * Legacy function for getting the legacy global bucket. Left here + * for paranoid JS-breakage avoidance debugging fun fun. * @deprecated */ getBucket: function() { - var bucket = mw.cnBannerControllerLib.retrieveLegacyBucket(); - if ( !bucket ) { - bucket = mw.cnBannerControllerLib.getRandomBucket(); - } - return bucket; + mw.log( 'Legacy mw.bannerController.getBucket() is deprecated no-op.' ); }, /** - * Legacy function for storing the legacy global bucket. Left here for - * compatibility. Stores - * mw.centralNotice.data.bucket. See - * mw.cnBannerControllerLib.storeLegacyBucket. + * Legacy function for storing the legacy global bucket Left here + * for paranoid JS-breakage avoidance. * @deprecated */ storeBucket: function() { - mw.cnBannerControllerLib.storeLegacyBucket( - mw.centralNotice.data.bucket ); + mw.log( 'Legacy mw.bannerController.storeBucket() is deprecated no-op.' ); }, initialize: function () { // === Do not allow CentralNotice to be re-initialized. === diff --git a/modules/ext.centralNotice.bannerController/bannerController.lib.js b/modules/ext.centralNotice.bannerController/bannerController.lib.js index 04c47bd..8782899 100644 --- a/modules/ext.centralNotice.bannerController/bannerController.lib.js +++ b/modules/ext.centralNotice.bannerController/bannerController.lib.js @@ -1,7 +1,42 @@ ( function ( $, mw ) { - var bucketValidityFromServer = mw.config.get( 'wgNoticeNumberOfBuckets' ) - + '.' + mw.config.get( 'wgNoticeNumberOfControllerBuckets' ); + + /** + * Method used for choosing a campaign or banner from an array of + * allocated campaigns or banners. + * + * Given an array of objects with 'allocation' properties, the sum of which + * is greater than or equal to 0 and less than or equal to 1, return the + * object whose allocation block is indicated by a number greater than or + * equal to 0 and less than 1. + * + * @param array allocatedArray + * @param random float A random number, greater or equal to 0 and less + * than 1, to use in choosing an object. + */ + function chooseObjInAllocatedArray( random, allocatedArray ) { + var blockStart = 0, + i, obj, blockEnd; + + // Cycle through objects, calculating which piece of + // the allocation pie they should get. When random is in the piece, + // choose the object. + + for ( i = 0; i < allocatedArray.length; i ++ ) { + obj = allocatedArray[i]; + blockEnd = blockStart + obj.allocation; + + if ( ( random >= blockStart ) && ( random < blockEnd ) ) { + return obj; + } + + blockStart = blockEnd; + } + + // We get here if there is less than full allocation (including no + // allocation) and random points to the unallocated chunk. + return null; + } // FIXME Temporary location of this object on the mw hierarchy. See FIXME // in bannerController.js. @@ -16,6 +51,13 @@ CAMPAIGN_STALENESS_LEEWAY: 15, choiceData: null, + + /** + * Once a campaign is chosen, this will receive a copy of the data for + * that campaign. (This is different from mw.centralNotice.data.campaign, + * which will hold just the name.) + */ + campaign: null, bucketsByCampaign: null, possibleBanners: null, @@ -23,94 +65,210 @@ * Set possible campaign and banner choices. Called by * ext.centralNotice.bannerChoices. */ - setChoiceData: function ( choices ) { + setChoiceData: function( choices ) { this.choiceData = choices; }, /** + * For targeted users (users meeting the same logged-in status, country, + * and language criteria as this user) calculate the probability that + * of receiving each campaign in this.choiceData. This takes into account + * campaign priority and throttling. The equivalent server-side method + * is BannerAllocationCalculator::calculateCampaignAllocations(). + */ + calculateCampaignAllocations: function() { + + var i, campaign, campaignPriority, + campaignsByPriority =[], + priorities = [], + priority, campaignsAtThisPriority, + remainingAllocation = 1, + j, campaignsAtThisPriorityCount, currentFullAllocation, + actualAllocation; + + // Make an index of campaigns by priority level. + // Note that the actual values of priority levels are integers, + // and higher integers represent higher priority. These values are + // defined by class constants in the CentralNotice PHP class. + + for ( i = 0; i < this.choiceData.length ; i ++ ) { + + campaign = this.choiceData[i]; + campaignPriority = campaign.preferred; + + // Initialize index the first time we hit this priority + if ( !campaignsByPriority[campaignPriority] ) { + campaignsByPriority[campaignPriority] = []; + } + + campaignsByPriority[campaignPriority].push( campaign ); + } + + // Make an array of priority levels and sort in descending order. + for ( priority in campaignsByPriority ) { + priorities.push( priority ); + } + priorities.sort(); + priorities.reverse(); + + // Now go through the priority levels from highest to lowest. If + // campaigns are not throttled, then campaigns with a higher + // priority level will eclipse all campaigns with lower priority. + // Only if some campaigns are throttled will they allow some space + // for campaigns at the next level down. + + for ( i = 0; i < priorities.length; i++ ) { + + campaignsAtThisPriority = campaignsByPriority[priorities[i]]; + + // If we fully allocated at a previous level, set allocations + // at this level to zero. (We check with 0.01 instead of 0 in + // case of issues due to finite precision.) + if ( remainingAllocation <= 0.01 ) { + for ( j = 0; j < campaignsAtThisPriority.length; j++ ) { + campaignsAtThisPriority[j].allocation = 0; + } + continue; + } + + // If we are here, there is some allocation remaining. + + // All campaigns at a given priority level are alloted the same + // allocation, unless they are throttled, in which case the + // throttling value (taken as a percentage of the whole + // allocation pie) is their maximum possible allocation. + + // To calculate this, we'll loop through the campaigns at this + // level in order from the most throttled (lowest throttling + // value) to the least throttled (highest value) and on each + // loop, we'll re-calculate the remaining total allocation and + // the proportional (i.e. unthrottled) allocation available to + // each campaign. + + // First, sort the campaigns by throttling value + + campaignsAtThisPriority.sort( function ( a, b ) { + if ( a.throttle < b.throttle ) { + return -1; + } + if ( a.throttle > b.throttle ) { + return 1; + } + return 0; + } ); + + campaignsAtThisPriorityCount = campaignsAtThisPriority.length; + for ( j = 0; j < campaignsAtThisPriorityCount; j++ ) { + + campaign = campaignsAtThisPriority[j]; + + // Calculate the proportional, unthrottled allocation now + // available to a campaign at this level. + currentFullAllocation = + remainingAllocation / ( campaignsAtThisPriorityCount - j ); + + // A campaign may get the above amount, or less, if + // throttling indicates that'd be too much. + actualAllocation = + Math.min( currentFullAllocation, campaign.throttle / 100 ); + + campaign.allocation = actualAllocation; + + // Update remaining allocation + remainingAllocation -= actualAllocation; + } + } + }, + + /** + * Choose a campaign (or no campaign) as determined by random and the + * allocations in this.choiceData. + * + * @param random float A random number, greater or equal to 0 and less + * than 1, to use in choosing a campaign. + */ + chooseCampaign: function( random ) { + this.setCampaign( + chooseObjInAllocatedArray( random, this.choiceData ) ); + }, + + /** + * Set the campaign. Set this.campaign to the object received and set + * mw.centralnotice.data.campaign to the campaign name, or both to + * null if campaign is null. + */ + setCampaign: function( campaign ) { + this.campaign = campaign; + mw.centralNotice.data.campaign = campaign ? campaign.name : null; + }, + + /** * Do all things bucket: - * - * - Go through choiceData and retrieve or generate buckets for all - * campaigns. If we don't already have a bucket for a campaign, but - * we still have legacy buckets, copy those in. Otherwise choose a - * random bucket. If we did already have a bucket for a campaign, - * check and possibly update its expiry date. - * - * - Go through all the buckets stored, purging expired buckets. - * + * - Retrieve or generate a random bucket for the campaign in + * this.campaign. * - Store the updated bucket data in a cookie. + * - Go through all the buckets stored, purging expired buckets. */ processBuckets: function() { - var campaign, campaignName, bucket, - campaignStartDate, retrievedBucketEndDate, bucketEndDate, - now = new Date(), - bucketsModified = false, - val, + var campaign = this.campaign, + campaignName = campaign.name, + campaignStartDate, + bucket, bucketEndDate, retrievedBucketEndDate, val, extension = mw.config.get( 'wgCentralNoticePerCampaignBucketExtension' ), - i; + now = new Date(), + bucketsModified = false; + + campaignStartDate = new Date(); + campaignStartDate.setTime( campaign.start * 1000 ); + + // Buckets should end the time indicated by extension after + // the campaign's end + bucketEndDate = new Date(); + bucketEndDate.setTime( campaign.end * 1000 ); + bucketEndDate.setUTCDate( bucketEndDate.getUTCDate() + extension ); this.retrieveBuckets(); + bucket = this.bucketsByCampaign[campaignName]; - for ( i = 0; i < this.choiceData.length; i++ ) { + // If we have a valid bucket, just check and possibly update its + // expiry. - campaign = this.choiceData[i]; - campaignName = campaign.name; - campaignStartDate = new Date(); - campaignStartDate.setTime( campaign.start * 1000 ); + // Note that buckets that are expired but that are found in + // the cookie (because they didn't have the chance to get + // purged) are not considered valid. In that case, for + // consistency, we choose a new random bucket, just as if + // no bucket had been found. - // Buckets should end the time indicated by extension after - // the campaign's end - bucketEndDate = new Date(); - bucketEndDate.setTime( campaign.end * 1000 ); - bucketEndDate.setUTCDate( bucketEndDate.getUTCDate() + extension ); + if ( bucket && bucketEndDate > now ) { - bucket = this.bucketsByCampaign[campaignName]; + retrievedBucketEndDate = new Date(); + retrievedBucketEndDate.setTime( bucket.end * 1000 ); - // If we have a valid bucket for this campaign, just check - // and possibly update its expiry. - // Note that buckets that are expired but that are found in - // the cookie (because they didn't have the chance to get - // purged) are not considered valid. In that case, for - // consistency, we choose a new random bucket, just as if - // no bucket had been found. - if ( bucket && bucketEndDate > now ) { + if ( retrievedBucketEndDate.getTime() + !== bucketEndDate.getTime() ) { - retrievedBucketEndDate = new Date(); - retrievedBucketEndDate.setTime( bucket.end * 1000 ); - - if ( retrievedBucketEndDate.getTime() - !== bucketEndDate.getTime() ) { - - bucket.end = bucketEndDate.getTime() / 1000; - bucketsModified = true; - } - - } else { - - // First try to get a legacy bucket value. These are only - // expected to be around for one week after the activation - // of per-campaign buckets. Doing this eases the transition. - val = this.retrieveLegacyBucket(); - - if ( !val ) { - // We always use wgNoticeNumberOfControllerBuckets, and - // not the campaign's number of buckets, to determine - // how many possible buckets to randomly choose from. If - // the campaign actually has less buckets than that, - // the value is mapped down as necessary. This lets - // campaigns modify the number of buckets they use. - val = this.getRandomBucket(); - } - - this.bucketsByCampaign[campaignName] = { - val: val, - start: campaignStartDate.getTime() / 1000, - end: bucketEndDate.getTime() / 1000 - }; - + bucket.end = bucketEndDate.getTime() / 1000; bucketsModified = true; } + + } else { + + // We always use wgNoticeNumberOfControllerBuckets, and + // not the campaign's number of buckets, to determine + // how many possible buckets to randomly choose from. If + // the campaign actually has less buckets than that, + // the value is mapped down as necessary. This lets + // campaigns modify the number of buckets they use. + val = this.getRandomBucket(); + + this.bucketsByCampaign[campaignName] = { + val: val, + start: campaignStartDate.getTime() / 1000, + end: bucketEndDate.getTime() / 1000 + }; + + bucketsModified = true; } // Purge any expired buckets @@ -190,45 +348,18 @@ }, /** - * Retrieve the user's legacy global bucket from the legacy bucket - * cookie. Follow the legacy procedure for determining validity. If a - * valid bucket was available, return it, otherwise return null. - */ - retrieveLegacyBucket: function() { - var dataString = $.cookie( 'centralnotice_bucket' ) || '', - bucket = dataString.split('-')[0], - validity = dataString.split('-')[1]; - - if ( ( bucket === null ) || ( validity !== bucketValidityFromServer ) ) { - return null; - } - - return bucket; - }, - - /** - * Store the legacy bucket. - * Puts the bucket in the legacy global bucket cookie. - * If such a cookie already exists, extends its expiry date as - * indicated by wgNoticeBucketExpiry. - */ - storeLegacyBucket: function( bucket ) { - $.cookie( - 'centralnotice_bucket', - bucket + '-' + bucketValidityFromServer, - { expires: mw.config.get( 'wgNoticeBucketExpiry' ), path: '/' } - ); - }, - - /** * Filter choiceData on the user's country, logged-in status and device. * Campaigns that don't target the user's country or have no banners for * their logged-in status and device will be removed. * + * The server-side equivalent of this method is + * BannerAllocationCalculator::filterChoiceData(). + * * We also check for campaigns that are have already ended, which might * happen due to incorrect caching of choiceData between us and the user. * If that happens we just toss everything out because one stale campaign - * spoils the basket. TODO: Log when this happens. + * spoils the basket. (This freshness check is not performed in the + * server-side method.) TODO: Log when this happens. * * We operate on this.choiceData. */ @@ -267,9 +398,6 @@ } // Now filter by banner logged-in status and device. - // To make buckets work consistently even for strangely - // configured campaigns, we won't chose buckets yet, so we'll - // filter on them a little later. for ( j = 0; j < campaign.banners.length; j++ ) { banner = campaign.banners[j]; @@ -303,259 +431,91 @@ }, /** - * Filter the choice data on the user's logged-in status, device and - * per-campaign buckets (some banners that are not for the user's status - * or device may remain following previous filters) and create a flat - * list of possible banners to chose from. Add some extra data on to - * each banner entry. The result is placed in possibleBanners. + * Filter banners for this.campaign on the user's logged-in status, + * device and bucket (some banners that are not for the user's status + * or device may remain following previous filters) and create a list + * of possible banners to chose from. The result is placed in + * this.possibleBanners. * - * Note that if the same actual banner is assigned to more than one - * campaign it can have more than one entry in that list. That's the - * desired result; here "banners" would be more accurately called - * "banner assignments". - * - * The procedure followed here resembles legacy PHP code in - * BannerChooser and current PHP code in BannerAllocationCalculator. - * - * FIXME Re-organize code in all those places to make it easier to - * understand. + * The equivalent server-side method + * BannerAllocationCalculator::makePossibleBanners(). */ makePossibleBanners: function() { - var i, campaign, campaignName, j, banner; + var i, campaign, campaignName, banner; this.possibleBanners = []; - for ( i = 0; i < this.choiceData.length; i++ ) { - campaign = this.choiceData[i]; - campaignName = campaign.name; + campaign = this.campaign; + campaignName = campaign.name; - for ( j = 0; j < campaign.banners.length; j++ ) { - banner = campaign.banners[j]; + for ( i = 0; i < campaign.banners.length; i++ ) { + banner = campaign.banners[i]; - // Filter for bucket - if ( this.bucketsByCampaign[campaignName].val % - campaign.bucket_count !== banner.bucket ) { - continue; - } - - // Filter for logged-in status - if ( mw.centralNotice.data.anonymous && !banner.display_anon ) { - continue; - } - if ( !mw.centralNotice.data.anonymous && !banner.display_account ) { - continue; - } - - // Filter for device - if ( $.inArray( - mw.centralNotice.data.device, banner.devices ) === -1 ) { - continue; - } - - // Add in data about the campaign the banner is part of. - // This will be used in the calculateBannerAllocations(), - // the next step in choosing a banner. - banner.campaignName = campaignName; - banner.campaignThrottle = campaign.throttle; - banner.campaignZIndex = campaign.preferred; - - this.possibleBanners.push( banner ); - } - } - }, - - /** - * Calculate the allocation of possible banners (i.e., the required - * relative distribution among users that meet the same criteria as - * this user). This calculation will be used to randomly select a - * banner. This method operates on the values of this.possibleBanners. - * - * The procedure followed here closely resembles legacy PHP code in - * BannerChooser and current PHP code in BannerAllocationCalculator. - * - * FIXME Re-organize code in all those places to make it easier to - * understand. - */ - calculateBannerAllocations: function() { - - var campaignTotalWeights = [], - priorityTotalAllocations = [], - bannersByPriority = [], - priorities = [], - remainingAllocation = 1, - i, j, - banner, campaignName, campaignZIndex, priority, - bannersAtThisPriority, totalAllocationAtThisPriority, scaling; - - // Calculate the sum of the weight properties for banners in each - // campaign. This will be used to calculate their proportional - // weight within each campaign and normalize weights across - // campaigns. - for ( i = 0; i < this.possibleBanners.length ; i ++ ) { - - banner = this.possibleBanners[i]; - campaignName = banner.campaignName; - - if ( !campaignTotalWeights[campaignName] ) { - campaignTotalWeights[campaignName] = 0; - } - - campaignTotalWeights[campaignName] += banner.weight; - } - - // Calculate the normalized maximum allocation of each banner - // within the campaign it's assigned to. First we normalize the - // banner's weight, then scale down as necessary if the campaign - // is throttled. This is the maximum allocation because it may - // be scaled down further in subsequent steps. - for ( i = 0; i < this.possibleBanners.length ; i ++ ) { - banner = this.possibleBanners[i]; - banner.maxAllocation = - ( banner.weight / campaignTotalWeights[banner.campaignName] ) - * ( banner.campaignThrottle / 100 ); - } - - // Make an index of banners by priority level, and find the sum of - // all maximum allocations for each priority level. - - // Note that here we are using a variety of terms for the same thing. - // Priority level = priority = preferred (DB column) = z-index - // (as copied in filterChoiceData()). This needs to be fixed, but - // it's being left as-is for the transition to choosing banners on - // the client, to make it easier to compare legacy and new code. - - // Note also that the actual values of priority levels are integers, - // and higher integers represent higher priority. These values are - // defined by class constants in the CentralNotice PHP class. - - for ( i = 0; i < this.possibleBanners.length ; i ++ ) { - - banner = this.possibleBanners[i]; - campaignZIndex = banner.campaignZIndex; - - // Initialize index vars the first time we hit this priority - // level/zIndex - if ( !bannersByPriority[campaignZIndex] ) { - bannersByPriority[campaignZIndex] = []; - priorityTotalAllocations[campaignZIndex] = 0; - } - - bannersByPriority[campaignZIndex].push( banner ); - priorityTotalAllocations[campaignZIndex] += banner.maxAllocation; - } - - // Dole out chunks of allocation to create the final allocation - // values. Full allocation is 1 and no allocation is 0; this is - // tracked by the remainingAllocation variable. - - // First, make an array of priority levels and sort in descending - // order. - for ( priority in bannersByPriority ) { - priorities.push( priority ); - } - priorities.sort(); - priorities.reverse(); - - // Now go through the priority levels from highest to lowest. If - // campaigns are not throttled, then campaigns with a higher - // priority level will eclipse all campaigns with lower priority. - // Only if some campaigns are throttled will they allow some space - // for campaigns at the next level down. - - // Also note that since priority and throttling are set at the - // campaign level, there will never be banners from a single - // campaign at more than one priority level. (This is important for - // the per-campaign normalizations performed above to be useful - // here.) - - for ( i = 0; i < priorities.length; i++ ) { - - bannersAtThisPriority = bannersByPriority[priorities[i]]; - totalAllocationAtThisPriority = - priorityTotalAllocations[priorities[i]]; - - // If we fully allocated at a previous level, set allocations - // at this level to zero. (We check with 0.01 instead of 0 in - // case of issues due to finite precision.) - if ( remainingAllocation <= 0.01 ) { - for ( j = 0; j < bannersAtThisPriority.length; j++ ) { - bannersAtThisPriority[j].allocation = 0; - } + // Filter for bucket + if ( this.bucketsByCampaign[campaignName].val % + campaign.bucket_count !== banner.bucket ) { continue; } - // If we are here, there is some allocation remaining. - - // First see if the total allocation for this level is greater - // than the remaining allocation. This can happen in two - // circumstances: (1) if there is more than one campaign - // at this level, and (2) if we are using up some remaining - // allocation left over from a higher priority level. In both - // cases, we'll scale the allocation of banners at this level to - // exactly fill the remaining allocation. - if ( totalAllocationAtThisPriority > remainingAllocation ) { - scaling = remainingAllocation / totalAllocationAtThisPriority; - remainingAllocation = 0; - - } else { - // If we are here, it means that whatever allocations there - // are at this level will either not fully take up or will - // exactly take up the remaining allocation. The former - // case means some campaigns are throttled, so we don't want - // to scale up, but rather leave some allocation tidbits for - // the next level. In the latter case, also, no scaling - // is needed. So we set scaling to 1, and take the chunk - // we are due from remainingAllocation. - - scaling = 1; - remainingAllocation -= totalAllocationAtThisPriority; + // Filter for logged-in status + if ( mw.centralNotice.data.anonymous && !banner.display_anon ) { + continue; + } + if ( !mw.centralNotice.data.anonymous && !banner.display_account ) { + continue; } - // Set the allocation property of all the banners at this level, - // scaling the previously set maxAllocation property as required. - for ( j = 0; j < bannersAtThisPriority.length; j++ ) { - banner = bannersAtThisPriority[j]; - banner.allocation = banner.maxAllocation * scaling; + // Filter for device + if ( $.inArray( + mw.centralNotice.data.device, banner.devices ) === -1 ) { + continue; } + + this.possibleBanners.push( banner ); } }, /** - * Choose a banner (or choose not to show one) as determined by random - * and the allocations in this.possibleBanners. If a banner is chosen, - * set the banner's name in mw.centralNotice.data.banner and the - * campaign it's associated with in mw.centralNotice.data.campaign. If - * no banner is chosen, set mw.centralNotice.data.banner to null. - * - * @param random float A random number, greater or equal to 0 and less - * than 1, to use in choosing a banner. + * Calculate banner allocation based on relative weights. + * The equivalent server-side method + * BannerAllocationCalculator::calculateBannerAllocations(). */ - chooseBanner: function( random ) { - var blockStart = 0, - i, banner, blockEnd; + calculateBannerAllocations: function() { + var i, banner, + totalWeights = 0; - // Cycle through possible banners, calculating which piece of - // the allocation pie they should get. When random is in the piece, - // choose the banner. Note that the order of the contents of - // possibleBanners is not guaranteed to be consistent between - // requests, but that shouldn't matter. - - for ( i = 0; i < this.possibleBanners.length; i ++ ) { - banner = this.possibleBanners[i]; - blockEnd = blockStart + banner.allocation; - - if ( ( random >= blockStart ) && ( random < blockEnd ) ) { - mw.centralNotice.data.banner = banner.name; - mw.centralNotice.data.campaign = banner.campaignName; - return; - } - - blockStart = blockEnd; + // Find the sum of all banner weights + for ( i = 0; i < this.possibleBanners.length; i++ ) { + totalWeights += this.possibleBanners[i].weight; } - // We get here if there is less than full allocation (including no - // allocation) and random points to the unallocated chunk. - mw.centralNotice.data.banner = null; + // Set allocation property to the normalized weight + for ( i = 0; i < this.possibleBanners.length; i++ ) { + banner = this.possibleBanners[i]; + banner.allocation = banner.weight / totalWeights; + } + }, + + /** + * Choose a banner as determined by random and the allocations in + * this.possibleBanners. Set the banner's name in + * mw.centralNotice.data.banner. + * + * @param random float A random number, greater or equal to 0 and less + * than 1, to use in choosing a banner. + */ + chooseBanner: function( random ) { + // Since we never have an allocation gap for banners, this should + // always work. + this.setBanner( + chooseObjInAllocatedArray( random, this.possibleBanners ) ); + }, + + /** + * Set the banner (should never be null). + */ + setBanner: function( banner ) { + mw.centralNotice.data.banner = banner.name; } }; diff --git a/special/SpecialBannerAllocation.php b/special/SpecialBannerAllocation.php index 4e13a64..2ab058c 100644 --- a/special/SpecialBannerAllocation.php +++ b/special/SpecialBannerAllocation.php @@ -206,15 +206,19 @@ // Given our project and language combination, get banner choice data, // then filter on country $provider = new BannerChoiceDataProvider( $project, $language ); - $choice_data = $provider->getChoicesForCountry( $country ); + $choiceDataBase = $provider->getChoices(); // Iterate through each possible device type and get allocation information $devices = CNDeviceTarget::getAvailableDevices(); - foreach( $devices as $device_id => $device_data ) { + foreach( $devices as $deviceId => $deviceData ) { + + // Use a copy of choiceData since it'll be modified in this process + $choiceData = $choiceDataBase; + $htmlOut .= Html::openElement( 'div', array( - 'id' => "cn-allocation-{$project}-{$language}-{$country}-{$device_id}", + 'id' => "cn-allocation-{$project}-{$language}-{$country}-{$deviceId}", 'class' => 'cn-allocation-group' ) ); @@ -226,10 +230,11 @@ htmlspecialchars( $language ), htmlspecialchars( $project ), htmlspecialchars( $country ), - $this->getOutput()->parseInline( $device_data['label'] ) + $this->getOutput()->parseInline( $deviceData['label'] ) )->text() ); + // FIXME Figure out the following comments and remove as needed // FIXME bannerstats is toast // Build campaign list for bannerstats.js //$campaignsUsed = array_keys($anonCampaigns + $accountCampaigns); @@ -258,14 +263,34 @@ $label .= ' -- ' . $this->msg( 'centralnotice-bucket-letter' )-> rawParams( chr( $target['bucket'] + 65 ) )->text(); - $banners = BannerAllocationCalculator::filterAndTransformBanners( - $choice_data, - $status, - $device_data['header'], - intval( $target['bucket'] ) - ); - $banners = BannerAllocationCalculator::calculateAllocations( $banners ); - $htmlOut .= $this->getTable( $label, $banners ); + BannerAllocationCalculator::filterChoiceData( + $choiceData, $country, $status, $deviceData['header'] ); + + BannerAllocationCalculator::calculateCampaignAllocations( $choiceData ); + + $possibelBannersAllCampaigns = array(); + foreach ( $choiceData as $campaign ) { + + $possibleBanners = BannerAllocationCalculator::makePossibleBanners( + $campaign, + intval( $target['bucket'] ), + $status, + $deviceData['header'] + ); + + BannerAllocationCalculator::calculateBannerAllocations( $possibleBanners ); + + foreach ( $possibleBanners as $banner ) { + $banner['campaign'] = $campaign['name']; + + $banner['allocation'] = + $banner['allocation'] * $campaign['allocation']; + + $possibelBannersAllCampaigns[] = $banner; + } + } + + $htmlOut .= $this->getTable( $label, $possibelBannersAllCampaigns ); } $htmlOut .= Html::closeElement( 'div' ); diff --git a/tests/qunit/ext.centralNotice.bannerController.lib/bannerController.lib.tests.js b/tests/qunit/ext.centralNotice.bannerController.lib/bannerController.lib.tests.js index a575a25..00e1d2d 100644 --- a/tests/qunit/ext.centralNotice.bannerController.lib/bannerController.lib.tests.js +++ b/tests/qunit/ext.centralNotice.bannerController.lib/bannerController.lib.tests.js @@ -65,6 +65,7 @@ // TODO: make separate tests for each method lib.setChoiceData( choices ); lib.filterChoiceData(); + lib.calculateCampaignAllocations(); lib.makePossibleBanners(); lib.calculateBannerAllocations(); -- To view, visit https://gerrit.wikimedia.org/r/205193 To unsubscribe, visit https://gerrit.wikimedia.org/r/settings Gerrit-MessageType: newchange Gerrit-Change-Id: I42985cf4de2dfeba9244b8d82f63b3d9d8319c81 Gerrit-PatchSet: 1 Gerrit-Project: mediawiki/extensions/CentralNotice Gerrit-Branch: master Gerrit-Owner: AndyRussG <andrew.green...@gmail.com> _______________________________________________ MediaWiki-commits mailing list MediaWiki-commits@lists.wikimedia.org https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits