Merge "Change wording of 'wlshowtime' for ease of localisation"
[lhc/web/wiklou.git] / includes / libs / objectcache / WANObjectCache.php
index fefecdf..8bdafcf 100644 (file)
  * @author Aaron Schulz
  */
 
+use Psr\Log\LoggerAwareInterface;
+use Psr\Log\LoggerInterface;
+use Psr\Log\NullLogger;
+
 /**
  * Multi-datacenter aware caching interface
  *
  * This class is intended for caching data from primary stores.
  * If the get() method does not return a value, then the caller
  * should query the new value and backfill the cache using set().
+ * When querying the store on cache miss, the closest DB replica
+ * should be used. Try to avoid heavyweight DB master or quorum reads.
  * When the source data changes, a purge method should be called.
  * Since purges are expensive, they should be avoided. One can do so if:
  *   - a) The object cached is immutable; or
  *   - b) Validity is checked against the source after get(); or
  *   - c) Using a modest TTL is reasonably correct and performant
+ *
  * The simplest purge method is delete().
  *
  * Instances of this class must be configured to point to a valid
  * @ingroup Cache
  * @since 1.26
  */
-class WANObjectCache {
+class WANObjectCache implements IExpiringStore, LoggerAwareInterface {
        /** @var BagOStuff The local datacenter cache */
        protected $cache;
+       /** @var HashBagOStuff Script instance PHP cache */
+       protected $procCache;
        /** @var string Cache pool name */
        protected $pool;
-       /** @var EventRelayer */
+       /** @var EventRelayer Bus that handles purge broadcasts */
        protected $relayer;
+       /** @var LoggerInterface */
+       protected $logger;
 
-       /** @var int */
+       /** @var int ERR_* constant for the "last error" registry */
        protected $lastRelayError = self::ERR_NONE;
 
        /** Max time expected to pass between delete() and DB commit finishing */
        const MAX_COMMIT_DELAY = 3;
-       /** Max replication lag before applying TTL_LAGGED to set() */
-       const MAX_REPLICA_LAG = 5;
-       /** Max time since snapshot transaction start to avoid no-op of set() */
-       const MAX_SNAPSHOT_LAG = 5;
+       /** Max replication+snapshot lag before applying TTL_LAGGED or disallowing set() */
+       const MAX_READ_LAG = 7;
        /** Seconds to tombstone keys on delete() */
-       const HOLDOFF_TTL = 14; // MAX_COMMIT_DELAY + MAX_REPLICA_LAG + MAX_SNAPSHOT_LAG + 1
+       const HOLDOFF_TTL = 11; // MAX_COMMIT_DELAY + MAX_READ_LAG + 1
 
        /** Seconds to keep dependency purge keys around */
-       const CHECK_KEY_TTL = 31536000; // 1 year
+       const CHECK_KEY_TTL = self::TTL_YEAR;
        /** Seconds to keep lock keys around */
-       const LOCK_TTL = 5;
+       const LOCK_TTL = 10;
        /** Default remaining TTL at which to consider pre-emptive regeneration */
        const LOW_TTL = 30;
        /** Default time-since-expiry on a miss that makes a key "hot" */
        const LOCK_TSE = 1;
 
-       /** Idiom for set()/getWithSetCallback() TTL */
-       const TTL_NONE = 0;
        /** Idiom for getWithSetCallback() callbacks to avoid calling set() */
        const TTL_UNCACHEABLE = -1;
        /** Idiom for getWithSetCallback() callbacks to 'lockTSE' logic */
        const TSE_NONE = -1;
        /** Max TTL to store keys when a data sourced is lagged */
        const TTL_LAGGED = 30;
+       /** Idiom for delete() for "no hold-off" */
+       const HOLDOFF_NONE = 0;
+
+       /** Tiny negative float to use when CTL comes up >= 0 due to clock skew */
+       const TINY_NEGATIVE = -0.000001;
 
        /** Cache format version number */
        const VERSION = 1;
 
-       /** Fields of value holder arrays */
        const FLD_VERSION = 0;
        const FLD_VALUE = 1;
        const FLD_TTL = 2;
        const FLD_TIME = 3;
+       const FLD_FLAGS = 4;
+       const FLD_HOLDOFF = 5;
+
+       /** @var integer Treat this value as expired-on-arrival */
+       const FLG_STALE = 1;
 
-       /** Possible values for getLastError() */
        const ERR_NONE = 0; // no error
        const ERR_NO_RESPONSE = 1; // no response
        const ERR_UNREACHABLE = 2; // can't connect
@@ -117,20 +132,31 @@ class WANObjectCache {
 
        const PURGE_VAL_PREFIX = 'PURGED:';
 
+       const MAX_PC_KEYS = 1000; // max keys to keep in process cache
+
        /**
         * @param array $params
         *   - cache   : BagOStuff object
         *   - pool    : pool name
         *   - relayer : EventRelayer object
+        *   - logger  : LoggerInterface object
         */
        public function __construct( array $params ) {
                $this->cache = $params['cache'];
                $this->pool = $params['pool'];
                $this->relayer = $params['relayer'];
+               $this->procCache = new HashBagOStuff( array( 'maxKeys' => self::MAX_PC_KEYS ) );
+               $this->setLogger( isset( $params['logger'] ) ? $params['logger'] : new NullLogger() );
+       }
+
+       public function setLogger( LoggerInterface $logger ) {
+               $this->logger = $logger;
        }
 
        /**
-        * @return WANObjectCache Cache that wraps EmptyBagOStuff
+        * Get an instance that wraps EmptyBagOStuff
+        *
+        * @return WANObjectCache
         */
        public static function newEmpty() {
                return new self( array(
@@ -143,11 +169,11 @@ class WANObjectCache {
        /**
         * Fetch the value of a key from cache
         *
-        * If passed in, $curTTL is set to the remaining TTL (current time left):
-        *   - a) INF; if the key exists, has no TTL, and is not expired by $checkKeys
-        *   - b) float (>=0); if the key exists, has a TTL, and is not expired by $checkKeys
-        *   - c) float (<0); if the key is tombstoned or existing but expired by $checkKeys
-        *   - d) null; if the key does not exist and is not tombstoned
+        * If supplied, $curTTL is set to the remaining TTL (current time left):
+        *   - a) INF; if $key exists, has no TTL, and is not expired by $checkKeys
+        *   - b) float (>=0); if $key exists, has a TTL, and is not expired by $checkKeys
+        *   - c) float (<0); if $key is tombstoned, stale, or existing but expired by $checkKeys
+        *   - d) null; if $key does not exist and is not tombstoned
         *
         * If a key is tombstoned, $curTTL will reflect the time since delete().
         *
@@ -171,7 +197,7 @@ class WANObjectCache {
         * However, pre-snapshot values might still be seen if an update was made
         * in a remote datacenter but the purge from delete() didn't relay yet.
         *
-        * Consider using getWithSetCallback() instead of get()/set() cycles.
+        * Consider using getWithSetCallback() instead of get() and set() cycles.
         * That method has cache slam avoiding features for hot/expensive keys.
         *
         * @param string $key Cache key
@@ -194,7 +220,8 @@ class WANObjectCache {
         *
         * @param array $keys List of cache keys
         * @param array $curTTLs Map of (key => approximate TTL left) for existing keys [returned]
-        * @param array $checkKeys List of "check" keys
+        * @param array $checkKeys List of check keys to apply to all $keys. May also apply "check"
+        *  keys to specific cache keys only by using cache keys as keys in the $checkKeys array.
         * @return array Map of (key => value) for keys that exist
         */
        final public function getMulti(
@@ -205,26 +232,34 @@ class WANObjectCache {
 
                $vPrefixLen = strlen( self::VALUE_KEY_PREFIX );
                $valueKeys = self::prefixCacheKeys( $keys, self::VALUE_KEY_PREFIX );
-               $checkKeys = self::prefixCacheKeys( $checkKeys, self::TIME_KEY_PREFIX );
+
+               $checkKeysForAll = array();
+               $checkKeysByKey = array();
+               $checkKeysFlat = array();
+               foreach ( $checkKeys as $i => $keys ) {
+                       $prefixed = self::prefixCacheKeys( (array)$keys, self::TIME_KEY_PREFIX );
+                       $checkKeysFlat = array_merge( $checkKeysFlat, $prefixed );
+                       // Is this check keys for a specific cache key, or for all keys being fetched?
+                       if ( is_int( $i ) ) {
+                               $checkKeysForAll = array_merge( $checkKeysForAll, $prefixed );
+                       } else {
+                               $checkKeysByKey[$i] = isset( $checkKeysByKey[$i] )
+                                       ? array_merge( $checkKeysByKey[$i], $prefixed )
+                                       : $prefixed;
+                       }
+               }
 
                // Fetch all of the raw values
-               $wrappedValues = $this->cache->getMulti( array_merge( $valueKeys, $checkKeys ) );
+               $wrappedValues = $this->cache->getMulti( array_merge( $valueKeys, $checkKeysFlat ) );
+               // Time used to compare/init "check" keys (derived after getMulti() to be pessimistic)
                $now = microtime( true );
 
-               // Get/initialize the timestamp of all the "check" keys
-               $checkKeyTimes = array();
-               foreach ( $checkKeys as $checkKey ) {
-                       $timestamp = isset( $wrappedValues[$checkKey] )
-                               ? self::parsePurgeValue( $wrappedValues[$checkKey] )
-                               : false;
-                       if ( !is_float( $timestamp ) ) {
-                               // Key is not set or invalid; regenerate
-                               $this->cache->add( $checkKey,
-                                       self::PURGE_VAL_PREFIX . $now, self::CHECK_KEY_TTL );
-                               $timestamp = $now;
-                       }
-
-                       $checkKeyTimes[] = $timestamp;
+               // Collect timestamps from all "check" keys
+               $purgeValuesForAll = $this->processCheckKeys( $checkKeysForAll, $wrappedValues, $now );
+               $purgeValuesByKey = array();
+               foreach ( $checkKeysByKey as $cacheKey => $checks ) {
+                       $purgeValuesByKey[$cacheKey] =
+                               $this->processCheckKeys( $checks, $wrappedValues, $now );
                }
 
                // Get the main cache value for each key and validate them
@@ -238,16 +273,23 @@ class WANObjectCache {
                        list( $value, $curTTL ) = $this->unwrap( $wrappedValues[$vKey], $now );
                        if ( $value !== false ) {
                                $result[$key] = $value;
-                               foreach ( $checkKeyTimes as $checkKeyTime ) {
-                                       // Force dependant keys to be invalid for a while after purging
-                                       // to reduce race conditions involving stale data getting cached
-                                       $safeTimestamp = $checkKeyTime + self::HOLDOFF_TTL;
+
+                               // Force dependant keys to be invalid for a while after purging
+                               // to reduce race conditions involving stale data getting cached
+                               $purgeValues = $purgeValuesForAll;
+                               if ( isset( $purgeValuesByKey[$key] ) ) {
+                                       $purgeValues = array_merge( $purgeValues, $purgeValuesByKey[$key] );
+                               }
+                               foreach ( $purgeValues as $purge ) {
+                                       $safeTimestamp = $purge[self::FLD_TIME] + $purge[self::FLD_HOLDOFF];
                                        if ( $safeTimestamp >= $wrappedValues[$vKey][self::FLD_TIME] ) {
-                                               $curTTL = min( $curTTL, $checkKeyTime - $now );
+                                               // How long ago this value was expired by *this* check key
+                                               $ago = min( $purge[self::FLD_TIME] - $now, self::TINY_NEGATIVE );
+                                               // How long ago this value was expired by *any* known check key
+                                               $curTTL = min( $curTTL, $ago );
                                        }
                                }
                        }
-
                        $curTTLs[$key] = $curTTL;
                }
 
@@ -255,7 +297,33 @@ class WANObjectCache {
        }
 
        /**
-        * Set the value of a key from cache
+        * @since 1.27
+        * @param array $timeKeys List of prefixed time check keys
+        * @param array $wrappedValues
+        * @param float $now
+        * @return array List of purge value arrays
+        */
+       private function processCheckKeys( array $timeKeys, array $wrappedValues, $now ) {
+               $purgeValues = array();
+               foreach ( $timeKeys as $timeKey ) {
+                       $purge = isset( $wrappedValues[$timeKey] )
+                               ? self::parsePurgeValue( $wrappedValues[$timeKey] )
+                               : false;
+                       if ( $purge === false ) {
+                               // Key is not set or invalid; regenerate
+                               $this->cache->add( $timeKey,
+                                       $this->makePurgeValue( $now, self::HOLDOFF_TTL ),
+                                       self::CHECK_KEY_TTL
+                               );
+                               $purge = array( self::FLD_TIME => $now, self::FLD_HOLDOFF => self::HOLDOFF_TTL );
+                       }
+                       $purgeValues[] = $purge;
+               }
+               return $purgeValues;
+       }
+
+       /**
+        * Set the value of a key in cache
         *
         * Simply calling this method when source data changes is not valid because
         * the changes do not replicate to the other WAN sites. In that case, delete()
@@ -269,7 +337,7 @@ class WANObjectCache {
         *   - d) T1 reads the row and calls set() due to a cache miss
         *   - e) Stale value is stuck in cache
         *
-        * Setting 'lag' helps avoids keys getting stuck in long-term stale states.
+        * Setting 'lag' and 'since' help avoids keys getting stuck in stale states.
         *
         * Example usage:
         * @code
@@ -277,27 +345,31 @@ class WANObjectCache {
         *     $setOpts = Database::getCacheSetOptions( $dbr );
         *     // Fetch the row from the DB
         *     $row = $dbr->selectRow( ... );
-        *     $key = wfMemcKey( 'building', $buildingId );
-        *     $cache->set( $key, $row, 86400, $setOpts );
+        *     $key = $cache->makeKey( 'building', $buildingId );
+        *     $cache->set( $key, $row, $cache::TTL_DAY, $setOpts );
         * @endcode
         *
         * @param string $key Cache key
         * @param mixed $value
-        * @param integer $ttl Seconds to live [0=forever]
+        * @param integer $ttl Seconds to live. Special values are:
+        *   - WANObjectCache::TTL_INDEFINITE: Cache forever
         * @param array $opts Options map:
         *   - lag     : Seconds of slave lag. Typically, this is either the slave lag
         *               before the data was read or, if applicable, the slave lag before
         *               the snapshot-isolated transaction the data was read from started.
-        *               [Default: 0 seconds]
+        *               Default: 0 seconds
         *   - since   : UNIX timestamp of the data in $value. Typically, this is either
         *               the current time the data was read or (if applicable) the time when
         *               the snapshot-isolated transaction the data was read from started.
-        *               [Default: 0 seconds]
-        *   - lockTSE : if excessive possible snapshot lag is detected,
-        *               then stash the value into a temporary location
-        *               with this TTL. This is only useful if the reads
-        *               use getWithSetCallback() with "lockTSE" set.
-        *               [Default: WANObjectCache::TSE_NONE]
+        *               Default: 0 seconds
+        *   - pending : Whether this data is possibly from an uncommitted write transaction.
+        *               Generally, other threads should not see values from the future and
+        *               they certainly should not see ones that ended up getting rolled back.
+        *               Default: false
+        *   - lockTSE : if excessive replication/snapshot lag is detected, then store the value
+        *               with this TTL and flag it as stale. This is only useful if the reads for
+        *               this key use getWithSetCallback() with "lockTSE" set.
+        *               Default: WANObjectCache::TSE_NONE
         * @return bool Success
         */
        final public function set( $key, $value, $ttl = 0, array $opts = array() ) {
@@ -305,21 +377,39 @@ class WANObjectCache {
                $age = isset( $opts['since'] ) ? max( 0, microtime( true ) - $opts['since'] ) : 0;
                $lag = isset( $opts['lag'] ) ? $opts['lag'] : 0;
 
-               if ( $lag > self::MAX_REPLICA_LAG ) {
-                       // Too much lag detected; lower TTL so it converges faster
-                       $ttl = $ttl ? min( $ttl, self::TTL_LAGGED ) : self::TTL_LAGGED;
+               // Do not cache potentially uncommitted data as it might get rolled back
+               if ( !empty( $opts['pending'] ) ) {
+                       $this->logger->info( "Rejected set() for $key due to pending writes." );
+
+                       return true; // no-op the write for being unsafe
                }
 
-               if ( $age > self::MAX_SNAPSHOT_LAG ) {
+               $wrapExtra = array(); // additional wrapped value fields
+               // Check if there's a risk of writing stale data after the purge tombstone expired
+               if ( $lag === false || ( $lag + $age ) > self::MAX_READ_LAG ) {
+                       // Case A: read lag with "lockTSE"; save but record value as stale
                        if ( $lockTSE >= 0 ) {
-                               $tempTTL = max( 1, (int)$lockTSE ); // set() expects seconds
-                               $this->cache->set( self::STASH_KEY_PREFIX . $key, $value, $tempTTL );
-                       }
+                               $ttl = max( 1, (int)$lockTSE ); // set() expects seconds
+                               $wrapExtra[self::FLD_FLAGS] = self::FLG_STALE; // mark as stale
+                       // Case B: any long-running transaction; ignore this set()
+                       } elseif ( $age > self::MAX_READ_LAG ) {
+                               $this->logger->warning( "Rejected set() for $key due to snapshot lag." );
+
+                               return true; // no-op the write for being unsafe
+                       // Case C: high replication lag; lower TTL instead of ignoring all set()s
+                       } elseif ( $lag === false || $lag > self::MAX_READ_LAG ) {
+                               $ttl = $ttl ? min( $ttl, self::TTL_LAGGED ) : self::TTL_LAGGED;
+                               $this->logger->warning( "Lowered set() TTL for $key due to replication lag." );
+                       // Case D: medium length request with medium replication lag; ignore this set()
+                       } else {
+                               $this->logger->warning( "Rejected set() for $key due to high read lag." );
 
-                       return true; // no-op the write for being unsafe
+                               return true; // no-op the write for being unsafe
+                       }
                }
 
-               $wrapped = $this->wrap( $value, $ttl );
+               // Wrap that value with time/TTL/version metadata
+               $wrapped = $this->wrap( $value, $ttl ) + $wrapExtra;
 
                $func = function ( $cache, $key, $cWrapped ) use ( $wrapped ) {
                        return ( is_string( $cWrapped ) )
@@ -344,10 +434,12 @@ class WANObjectCache {
         * This is implemented by storing a special "tombstone" value at the cache
         * key that this class recognizes; get() calls will return false for the key
         * and any set() calls will refuse to replace tombstone values at the key.
-        * For this to always avoid writing stale values, the following must hold:
+        * For this to always avoid stale value writes, the following must hold:
         *   - a) Replication lag is bounded to being less than HOLDOFF_TTL; or
         *   - b) If lag is higher, the DB will have gone into read-only mode already
         *
+        * Note that set() can also be lag-aware and lower the TTL if it's high.
+        *
         * When using potentially long-running ACID transactions, a good pattern is
         * to use a pre-commit hook to issue the delete. This means that immediately
         * after commit, callers will see the tombstone in cache in the local datacenter
@@ -364,7 +456,7 @@ class WANObjectCache {
         *     ... <execute some stuff> ...
         *     // Update the row in the DB
         *     $dbw->update( ... );
-        *     $key = wfMemcKey( 'homes', $homeId );
+        *     $key = $cache->makeKey( 'homes', $homeId );
         *     // Purge the corresponding cache entry just before committing
         *     $dbw->onTransactionPreCommitOrIdle( function() use ( $cache, $key ) {
         *         $cache->delete( $key );
@@ -373,23 +465,37 @@ class WANObjectCache {
         *     $dbw->commit(); // end of request
         * @endcode
         *
-        * If called twice on the same key, then the last hold-off TTL takes
-        * precedence. For idempotence, the $ttl should not vary for different
-        * delete() calls on the same key. Also note that lowering $ttl reduces
-        * the effective range of the 'lockTSE' parameter to getWithSetCallback().
+        * The $ttl parameter can be used when purging values that have not actually changed
+        * recently. For example, a cleanup script to purge cache entries does not really need
+        * a hold-off period, so it can use HOLDOFF_NONE. Likewise for user-requested purge.
+        * Note that $ttl limits the effective range of 'lockTSE' for getWithSetCallback().
+        *
+        * If called twice on the same key, then the last hold-off TTL takes precedence. For
+        * idempotence, the $ttl should not vary for different delete() calls on the same key.
         *
         * @param string $key Cache key
-        * @param integer $ttl How long to block writes to the key [seconds]
+        * @param integer $ttl Tombstone TTL; Default: WANObjectCache::HOLDOFF_TTL
         * @return bool True if the item was purged or not found, false on failure
         */
        final public function delete( $key, $ttl = self::HOLDOFF_TTL ) {
                $key = self::VALUE_KEY_PREFIX . $key;
-               // Avoid indefinite key salting for sanity
-               $ttl = max( $ttl, 1 );
-               // Update the local datacenter immediately
-               $ok = $this->cache->set( $key, self::PURGE_VAL_PREFIX . microtime( true ), $ttl );
-               // Publish the purge to all datacenters
-               return $this->relayPurge( $key, $ttl ) && $ok;
+
+               if ( $ttl <= 0 ) {
+                       // Update the local datacenter immediately
+                       $ok = $this->cache->delete( $key );
+                       // Publish the purge to all datacenters
+                       $ok = $this->relayDelete( $key ) && $ok;
+               } else {
+                       // Update the local datacenter immediately
+                       $ok = $this->cache->set( $key,
+                               $this->makePurgeValue( microtime( true ), self::HOLDOFF_NONE ),
+                               $ttl
+                       );
+                       // Publish the purge to all datacenters
+                       $ok = $this->relayPurge( $key, $ttl, self::HOLDOFF_NONE ) && $ok;
+               }
+
+               return $ok;
        }
 
        /**
@@ -406,20 +512,25 @@ class WANObjectCache {
         * if the key was evicted from cache, such calculations may show the
         * time since expiry as ~0 seconds.
         *
-        * Note that "check" keys won't collide with other regular keys
+        * Note that "check" keys won't collide with other regular keys.
         *
         * @param string $key
-        * @return float UNIX timestamp of the key
+        * @return float UNIX timestamp of the check key
         */
        final public function getCheckKeyTime( $key ) {
                $key = self::TIME_KEY_PREFIX . $key;
 
-               $time = self::parsePurgeValue( $this->cache->get( $key ) );
-               if ( $time === false ) {
+               $purge = self::parsePurgeValue( $this->cache->get( $key ) );
+               if ( $purge !== false ) {
+                       $time = $purge[self::FLD_TIME];
+               } else {
                        // Casting assures identical floats for the next getCheckKeyTime() calls
-                       $time = (string)microtime( true );
-                       $this->cache->add( $key, self::PURGE_VAL_PREFIX . $time, self::CHECK_KEY_TTL );
-                       $time = (float)$time;
+                       $now = (string)microtime( true );
+                       $this->cache->add( $key,
+                               $this->makePurgeValue( $now, self::HOLDOFF_TTL ),
+                               self::CHECK_KEY_TTL
+                       );
+                       $time = (float)$now;
                }
 
                return $time;
@@ -434,38 +545,44 @@ class WANObjectCache {
         * keys, the relevant "check" keys must be supplied for this to work.
         *
         * The "check" key essentially represents a last-modified field.
-        * It is set in the future a few seconds when this is called, to
-        * avoid race conditions where dependent keys get updated with a
-        * stale value (e.g. from a DB slave).
+        * When touched, keys using it via get(), getMulti(), or getWithSetCallback()
+        * will be invalidated. It is treated as being HOLDOFF_TTL seconds in the future
+        * by those methods to avoid race conditions where dependent keys get updated
+        * with stale values (e.g. from a DB slave).
         *
-        * This is typically useful for keys with static names or some cases
+        * This is typically useful for keys with hardcoded names or in some cases
         * dynamically generated names where a low number of combinations exist.
         * When a few important keys get a large number of hits, a high cache
-        * time is usually desired as well as lockTSE logic. The resetCheckKey()
+        * time is usually desired as well as "lockTSE" logic. The resetCheckKey()
         * method is less appropriate in such cases since the "time since expiry"
         * cannot be inferred.
         *
-        * Note that "check" keys won't collide with other regular keys
+        * Note that "check" keys won't collide with other regular keys.
         *
         * @see WANObjectCache::get()
+        * @see WANObjectCache::getWithSetCallback()
+        * @see WANObjectCache::resetCheckKey()
         *
         * @param string $key Cache key
+        * @param int $holdoff HOLDOFF_TTL or HOLDOFF_NONE constant
         * @return bool True if the item was purged or not found, false on failure
         */
-       final public function touchCheckKey( $key ) {
+       final public function touchCheckKey( $key, $holdoff = self::HOLDOFF_TTL ) {
                $key = self::TIME_KEY_PREFIX . $key;
                // Update the local datacenter immediately
                $ok = $this->cache->set( $key,
-                       self::PURGE_VAL_PREFIX . microtime( true ), self::CHECK_KEY_TTL );
+                       $this->makePurgeValue( microtime( true ), $holdoff ),
+                       self::CHECK_KEY_TTL
+               );
                // Publish the purge to all datacenters
-               return $this->relayPurge( $key, self::CHECK_KEY_TTL ) && $ok;
+               return $this->relayPurge( $key, self::CHECK_KEY_TTL, $holdoff ) && $ok;
        }
 
        /**
         * Delete a "check" key from all datacenters, invalidating keys that use it
         *
-        * This is similar to touchCheckKey() in that keys using it via
-        * getWithSetCallback() will be invalidated. The differences are:
+        * This is similar to touchCheckKey() in that keys using it via get(), getMulti(),
+        * or getWithSetCallback() will be invalidated. The differences are:
         *   - a) The timestamp will be deleted from all caches and lazily
         *        re-initialized when accessed (rather than set everywhere)
         *   - b) Thus, dependent keys will be known to be invalid, but not
@@ -479,10 +596,11 @@ class WANObjectCache {
         * This is typically useful for keys with dynamically generated names
         * where a high number of combinations exist.
         *
-        * Note that "check" keys won't collide with other regular keys
+        * Note that "check" keys won't collide with other regular keys.
         *
-        * @see WANObjectCache::touchCheckKey()
         * @see WANObjectCache::get()
+        * @see WANObjectCache::getWithSetCallback()
+        * @see WANObjectCache::touchCheckKey()
         *
         * @param string $key Cache key
         * @return bool True if the item was purged or not found, false on failure
@@ -499,24 +617,21 @@ class WANObjectCache {
         * Method to fetch/regenerate cache keys
         *
         * On cache miss, the key will be set to the callback result via set()
-        * unless the callback returns false. The arguments supplied to it are:
-        *     (current value or false, &$ttl, &$setOpts)
-        * The callback function returns the new value given the current
-        * value (false if not present). Preemptive re-caching and $checkKeys
-        * can result in a non-false current value. The TTL of the new value
-        * can be set dynamically by altering $ttl in the callback (by reference).
-        * The $setOpts array can be altered and is given to set() when called;
-        * it is recommended to set the 'since' field to avoid race conditions.
-        * Setting 'lag' helps avoids keys getting stuck in long-term stale states.
-        *
-        * Usually, callbacks ignore the current value, but it can be used
-        * to maintain "most recent X" values that come from time or sequence
-        * based source data, provided that the "as of" id/time is tracked.
-        *
-        * Usage of $checkKeys is similar to get() and getMulti(). However,
-        * rather than the caller having to inspect a "current time left"
-        * variable (e.g. $curTTL, $curTTLs), a cache regeneration will be
-        * triggered using the callback.
+        * (unless the callback returns false) and that result will be returned.
+        * The arguments supplied to the callback are:
+        *   - $oldValue : current cache value or false if not present
+        *   - &$ttl : a reference to the TTL which can be altered
+        *   - &$setOpts : a reference to options for set() which can be altered
+        *
+        * It is strongly recommended to set the 'lag' and 'since' fields to avoid race conditions
+        * that can cause stale values to get stuck at keys. Usually, callbacks ignore the current
+        * value, but it can be used to maintain "most recent X" values that come from time or
+        * sequence based source data, provided that the "as of" id/time is tracked. Note that
+        * preemptive regeneration and $checkKeys can result in a non-false current value.
+        *
+        * Usage of $checkKeys is similar to get() and getMulti(). However, rather than the caller
+        * having to inspect a "current time left" variable (e.g. $curTTL, $curTTLs), a cache
+        * regeneration will automatically be triggered using the callback.
         *
         * The simplest way to avoid stampedes for hot keys is to use
         * the 'lockTSE' option in $opts. If cache purges are needed, also:
@@ -527,7 +642,9 @@ class WANObjectCache {
         * @code
         *     $catInfo = $cache->getWithSetCallback(
         *         // Key to store the cached value under
-        *         wfMemcKey( 'cat-attributes', $catId ),
+        *         $cache->makeKey( 'cat-attributes', $catId ),
+        *         // Time-to-live (in seconds)
+        *         $cache::TTL_MINUTE,
         *         // Function that derives the new key value
         *         function ( $oldValue, &$ttl, array &$setOpts ) {
         *             $dbr = wfGetDB( DB_SLAVE );
@@ -535,9 +652,7 @@ class WANObjectCache {
         *             $setOpts += Database::getCacheSetOptions( $dbr );
         *
         *             return $dbr->selectRow( ... );
-        *        },
-        *        // Time-to-live (seconds)
-        *        60
+        *        }
         *     );
         * @endcode
         *
@@ -545,7 +660,9 @@ class WANObjectCache {
         * @code
         *     $catConfig = $cache->getWithSetCallback(
         *         // Key to store the cached value under
-        *         wfMemcKey( 'site-cat-config' ),
+        *         $cache->makeKey( 'site-cat-config' ),
+        *         // Time-to-live (in seconds)
+        *         $cache::TTL_DAY,
         *         // Function that derives the new key value
         *         function ( $oldValue, &$ttl, array &$setOpts ) {
         *             $dbr = wfGetDB( DB_SLAVE );
@@ -553,13 +670,13 @@ class WANObjectCache {
         *             $setOpts += Database::getCacheSetOptions( $dbr );
         *
         *             return CatConfig::newFromRow( $dbr->selectRow( ... ) );
-        *        },
-        *        // Time-to-live (seconds)
-        *        86400,
-        *        // Calling touchCheckKey() on this key invalidates the cache
-        *        wfMemcKey( 'site-cat-config' ),
-        *        // Try to only let one datacenter thread manage cache updates at a time
-        *        array( 'lockTSE' => 30 )
+        *         },
+        *         array(
+        *             // Calling touchCheckKey() on this key invalidates the cache
+        *             'checkKeys' => array( $cache->makeKey( 'site-cat-config' ) ),
+        *             // Try to only let one datacenter thread manage cache updates at a time
+        *             'lockTSE' => 30
+        *         )
         *     );
         * @endcode
         *
@@ -567,7 +684,9 @@ class WANObjectCache {
         * @code
         *     $catState = $cache->getWithSetCallback(
         *         // Key to store the cached value under
-        *         wfMemcKey( 'cat-state', $cat->getId() ),
+        *         $cache->makeKey( 'cat-state', $cat->getId() ),
+        *         // Time-to-live (seconds)
+        *         $cache::TTL_HOUR,
         *         // Function that derives the new key value
         *         function ( $oldValue, &$ttl, array &$setOpts ) {
         *             // Determine new value from the DB
@@ -576,15 +695,15 @@ class WANObjectCache {
         *             $setOpts += Database::getCacheSetOptions( $dbr );
         *
         *             return CatState::newFromResults( $dbr->select( ... ) );
-        *        },
-        *        // Time-to-live (seconds)
-        *        900,
-        *        // The "check" keys that represent things the value depends on;
-        *        // Calling touchCheckKey() on any of them invalidates the cache
-        *        array(
-        *             wfMemcKey( 'sustenance-bowls', $cat->getRoomId() ),
-        *             wfMemcKey( 'people-present', $cat->getHouseId() ),
-        *             wfMemcKey( 'cat-laws', $cat->getCityId() ),
+        *         },
+        *         array(
+        *              // The "check" keys that represent things the value depends on;
+        *              // Calling touchCheckKey() on any of them invalidates the cache
+        *             'checkKeys' => array(
+        *                 $cache->makeKey( 'sustenance-bowls', $cat->getRoomId() ),
+        *                 $cache->makeKey( 'people-present', $cat->getHouseId() ),
+        *                 $cache->makeKey( 'cat-laws', $cat->getCityId() ),
+        *             )
         *         )
         *     );
         * @endcode
@@ -593,7 +712,9 @@ class WANObjectCache {
         * @code
         *     $lastCatActions = $cache->getWithSetCallback(
         *         // Key to store the cached value under
-        *         wfMemcKey( 'cat-last-actions', 100 ),
+        *         $cache->makeKey( 'cat-last-actions', 100 ),
+        *         // Time-to-live (in seconds)
+        *         10,
         *         // Function that derives the new key value
         *         function ( $oldValue, &$ttl, array &$setOpts ) {
         *             $dbr = wfGetDB( DB_SLAVE );
@@ -608,10 +729,6 @@ class WANObjectCache {
         *             // Merge them and get the new "last 100" rows
         *             return array_slice( array_merge( $new, $list ), 0, 100 );
         *        },
-        *        // Time-to-live (seconds)
-        *        10,
-        *        // No "check" keys
-        *        array(),
         *        // Try to only let one datacenter thread manage cache updates at a time
         *        array( 'lockTSE' => 30 )
         *     );
@@ -622,11 +739,12 @@ class WANObjectCache {
         *
         * @param string $key Cache key
         * @param integer $ttl Seconds to live for key updates. Special values are:
-        *   - WANObjectCache::TTL_NONE : Cache forever
+        *   - WANObjectCache::TTL_INDEFINITE: Cache forever
         *   - WANObjectCache::TTL_UNCACHEABLE: Do not cache at all
         * @param callable $callback Value generation function
         * @param array $opts Options map:
-        *   - checkKeys: List of "check" keys.
+        *   - checkKeys: List of "check" keys. The key at $key will be seen as invalid when either
+        *      touchCheckKey() or resetCheckKey() is called on any of these keys.
         *   - lowTTL: Consider pre-emptive updates when the current TTL (sec) of the key is less than
         *      this. It becomes more likely over time, becoming a certainty once the key is expired.
         *      Default: WANObjectCache::LOW_TTL seconds.
@@ -636,28 +754,49 @@ class WANObjectCache {
         *      expiration is low, the assumption is that the key is hot and that a stampede is worth
         *      avoiding. Setting this above WANObjectCache::HOLDOFF_TTL makes no difference. The
         *      higher this is set, the higher the worst-case staleness can be.
-        *      Use WANObjectCache::TSE_NONE to disable this logic. Default: WANObjectCache::TSE_NONE.
+        *      Use WANObjectCache::TSE_NONE to disable this logic.
+        *      Default: WANObjectCache::TSE_NONE.
+        *   - pcTTL : process cache the value in this PHP instance with this TTL. This avoids
+        *      network I/O when a key is read several times. This will not cache if the callback
+        *      returns false however. Note that any purges will not be seen while process cached;
+        *      since the callback should use slave DBs and they may be lagged or have snapshot
+        *      isolation anyway, this should not typically matter.
+        *      Default: WANObjectCache::TTL_UNCACHEABLE.
         * @return mixed Value to use for the key
         */
-       final public function getWithSetCallback(
-               $key, $ttl, $callback, array $opts = array(), $oldOpts = array()
-       ) {
-               // Back-compat with 1.26: Swap $ttl and $callback
-               if ( is_int( $callback ) ) {
-                       $temp = $ttl;
-                       $ttl = $callback;
-                       $callback = $temp;
-               }
-               // Back-compat with 1.26: $checkKeys as separate parameter
-               if ( $oldOpts || ( is_array( $opts ) && isset( $opts[0] ) ) ) {
-                       $checkKeys = $opts;
-                       $opts = $oldOpts;
-               } else {
-                       $checkKeys = isset( $opts['checkKeys'] ) ? $opts['checkKeys'] : array();
+       final public function getWithSetCallback( $key, $ttl, $callback, array $opts = array() ) {
+               $pcTTL = isset( $opts['pcTTL'] ) ? $opts['pcTTL'] : self::TTL_UNCACHEABLE;
+
+               // Try the process cache if enabled
+               $value = ( $pcTTL >= 0 ) ? $this->procCache->get( $key ) : false;
+
+               if ( $value === false ) {
+                       // Fetch the value over the network
+                       $value = $this->doGetWithSetCallback( $key, $ttl, $callback, $opts );
+                       // Update the process cache if enabled
+                       if ( $pcTTL >= 0 && $value !== false ) {
+                               $this->procCache->set( $key, $value, $pcTTL );
+                       }
                }
 
+               return $value;
+       }
+
+       /**
+        * Do the actual I/O for getWithSetCallback() when needed
+        *
+        * @see WANObjectCache::getWithSetCallback()
+        *
+        * @param string $key
+        * @param integer $ttl
+        * @param callback $callback
+        * @param array $opts
+        * @return mixed
+        */
+       protected function doGetWithSetCallback( $key, $ttl, $callback, array $opts ) {
                $lowTTL = isset( $opts['lowTTL'] ) ? $opts['lowTTL'] : min( self::LOW_TTL, $ttl );
                $lockTSE = isset( $opts['lockTSE'] ) ? $opts['lockTSE'] : self::TSE_NONE;
+               $checkKeys = isset( $opts['checkKeys'] ) ? $opts['checkKeys'] : array();
 
                // Get the current key value
                $curTTL = null;
@@ -726,6 +865,26 @@ class WANObjectCache {
                return $value;
        }
 
+       /**
+        * @see BagOStuff::makeKey()
+        * @param string ... Key component
+        * @return string
+        * @since 1.27
+        */
+       public function makeKey() {
+               return call_user_func_array( array( $this->cache, __FUNCTION__ ), func_get_args() );
+       }
+
+       /**
+        * @see BagOStuff::makeGlobalKey()
+        * @param string ... Key component
+        * @return string
+        * @since 1.27
+        */
+       public function makeGlobalKey() {
+               return call_user_func_array( array( $this->cache, __FUNCTION__ ), func_get_args() );
+       }
+
        /**
         * Get the "last error" registered; clearLastError() should be called manually
         * @return int ERR_* constant for the "last error" registry
@@ -764,17 +923,18 @@ class WANObjectCache {
        /**
         * Do the actual async bus purge of a key
         *
-        * This must set the key to "PURGED:<UNIX timestamp>"
+        * This must set the key to "PURGED:<UNIX timestamp>:<holdoff>"
         *
         * @param string $key Cache key
         * @param integer $ttl How long to keep the tombstone [seconds]
+        * @param integer $holdoff HOLDOFF_* constant controlling how long to ignore sets for this key
         * @return bool Success
         */
-       protected function relayPurge( $key, $ttl ) {
+       protected function relayPurge( $key, $ttl, $holdoff ) {
                $event = $this->cache->modifySimpleRelayEvent( array(
                        'cmd' => 'set',
                        'key' => $key,
-                       'val' => 'PURGED:$UNIXTIME$',
+                       'val' => 'PURGED:$UNIXTIME$:' . (int)$holdoff,
                        'ttl' => max( $ttl, 1 ),
                        'sbt' => true, // substitute $UNIXTIME$ with actual microtime
                ) );
@@ -836,7 +996,7 @@ class WANObjectCache {
         *
         * @param mixed $value
         * @param integer $ttl [0=forever]
-        * @return string
+        * @return array
         */
        protected function wrap( $value, $ttl ) {
                return array(
@@ -856,10 +1016,10 @@ class WANObjectCache {
         */
        protected function unwrap( $wrapped, $now ) {
                // Check if the value is a tombstone
-               $purgeTimestamp = self::parsePurgeValue( $wrapped );
-               if ( is_float( $purgeTimestamp ) ) {
+               $purge = self::parsePurgeValue( $wrapped );
+               if ( $purge !== false ) {
                        // Purged values should always have a negative current $ttl
-                       $curTTL = min( -0.000001, $purgeTimestamp - $now );
+                       $curTTL = min( $purge[self::FLD_TIME] - $now, self::TINY_NEGATIVE );
                        return array( false, $curTTL );
                }
 
@@ -870,7 +1030,12 @@ class WANObjectCache {
                        return array( false, null );
                }
 
-               if ( $wrapped[self::FLD_TTL] > 0 ) {
+               $flags = isset( $wrapped[self::FLD_FLAGS] ) ? $wrapped[self::FLD_FLAGS] : 0;
+               if ( ( $flags & self::FLG_STALE ) == self::FLG_STALE ) {
+                       // Treat as expired, with the cache time as the expiration
+                       $age = $now - $wrapped[self::FLD_TIME];
+                       $curTTL = min( -$age, self::TINY_NEGATIVE );
+               } elseif ( $wrapped[self::FLD_TTL] > 0 ) {
                        // Get the approximate time left on the key
                        $age = $now - $wrapped[self::FLD_TIME];
                        $curTTL = max( $wrapped[self::FLD_TTL] - $age, 0.0 );
@@ -897,17 +1062,36 @@ class WANObjectCache {
        }
 
        /**
-        * @param string $value String like "PURGED:<timestamp>"
-        * @return float|bool UNIX timestamp or false on failure
+        * @param string $value Wrapped value like "PURGED:<timestamp>:<holdoff>"
+        * @return array|bool Array containing a UNIX timestamp (float) and holdoff period (integer),
+        *  or false if value isn't a valid purge value
         */
        protected static function parsePurgeValue( $value ) {
-               $m = array();
-               if ( is_string( $value ) &&
-                       preg_match( '/^' . self::PURGE_VAL_PREFIX . '([^:]+)$/', $value, $m )
+               if ( !is_string( $value ) ) {
+                       return false;
+               }
+               $segments = explode( ':', $value, 3 );
+               if ( !isset( $segments[0] ) || !isset( $segments[1] )
+                       || "{$segments[0]}:" !== self::PURGE_VAL_PREFIX
                ) {
-                       return (float)$m[1];
-               } else {
                        return false;
                }
+               if ( !isset( $segments[2] ) ) {
+                       // Back-compat with old purge values without holdoff
+                       $segments[2] = self::HOLDOFF_TTL;
+               }
+               return array(
+                       self::FLD_TIME => (float)$segments[1],
+                       self::FLD_HOLDOFF => (int)$segments[2],
+               );
+       }
+
+       /**
+        * @param float $timestamp
+        * @param int $holdoff In seconds
+        * @return string Wrapped purge value
+        */
+       protected function makePurgeValue( $timestamp, $holdoff ) {
+               return self::PURGE_VAL_PREFIX . (float)$timestamp . ':' . (int)$holdoff;
        }
 }