Merge "Fix relayPurge() IDEA warnings"
[lhc/web/wiklou.git] / includes / libs / objectcache / WANObjectCache.php
index 0c7b7ed..e1ac56e 100644 (file)
@@ -64,7 +64,7 @@ use Psr\Log\NullLogger;
  * @ingroup Cache
  * @since 1.26
  */
-class WANObjectCache implements LoggerAwareInterface {
+class WANObjectCache implements IExpiringStore, LoggerAwareInterface {
        /** @var BagOStuff The local datacenter cache */
        protected $cache;
        /** @var HashBagOStuff Script instance PHP cache */
@@ -81,30 +81,31 @@ class WANObjectCache implements LoggerAwareInterface {
 
        /** 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 being "forever" */
-       const TTL_INDEFINITE = 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;
@@ -113,6 +114,11 @@ class WANObjectCache implements LoggerAwareInterface {
        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;
 
        const ERR_NONE = 0; // no error
        const ERR_NO_RESPONSE = 1; // no response
@@ -126,6 +132,8 @@ class WANObjectCache implements LoggerAwareInterface {
 
        const PURGE_VAL_PREFIX = 'PURGED:';
 
+       const MAX_PC_KEYS = 1000; // max keys to keep in process cache
+
        /**
         * @param array $params
         *   - cache   : BagOStuff object
@@ -137,7 +145,7 @@ class WANObjectCache implements LoggerAwareInterface {
                $this->cache = $params['cache'];
                $this->pool = $params['pool'];
                $this->relayer = $params['relayer'];
-               $this->procCache = new HashBagOStuff();
+               $this->procCache = new HashBagOStuff( array( 'maxKeys' => self::MAX_PC_KEYS ) );
                $this->setLogger( isset( $params['logger'] ) ? $params['logger'] : new NullLogger() );
        }
 
@@ -161,11 +169,11 @@ class WANObjectCache implements LoggerAwareInterface {
        /**
         * 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().
         *
@@ -212,7 +220,8 @@ class WANObjectCache implements LoggerAwareInterface {
         *
         * @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 to apply to all of $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(
@@ -223,26 +232,33 @@ class WANObjectCache implements LoggerAwareInterface {
 
                $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 ) );
                $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
@@ -256,22 +272,52 @@ class WANObjectCache implements LoggerAwareInterface {
                        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 );
+                                               $curTTL = min( $curTTL, $purge[self::FLD_TIME] - $now );
                                        }
                                }
                        }
-
                        $curTTLs[$key] = $curTTL;
                }
 
                return $result;
        }
 
+       /**
+        * @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
         *
@@ -296,7 +342,7 @@ class WANObjectCache implements LoggerAwareInterface {
         *     // Fetch the row from the DB
         *     $row = $dbr->selectRow( ... );
         *     $key = $cache->makeKey( 'building', $buildingId );
-        *     $cache->set( $key, $row, 86400, $setOpts );
+        *     $cache->set( $key, $row, $cache::TTL_DAY, $setOpts );
         * @endcode
         *
         * @param string $key Cache key
@@ -316,10 +362,9 @@ class WANObjectCache implements LoggerAwareInterface {
         *               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 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.
+        *   - 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
         */
@@ -328,29 +373,39 @@ class WANObjectCache implements LoggerAwareInterface {
                $age = isset( $opts['since'] ) ? max( 0, microtime( true ) - $opts['since'] ) : 0;
                $lag = isset( $opts['lag'] ) ? $opts['lag'] : 0;
 
+               // 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 ( $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;
-                       $this->logger->warning( "Lowered set() TTL for $key due to replication lag." );
-               }
-
-               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 + $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 );
-                       }
-                       $this->logger->warning( "Rejected set() for $key due to snapshot lag." );
+                               $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 > 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 ) )
@@ -406,23 +461,37 @@ class WANObjectCache implements LoggerAwareInterface {
         *     $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;
        }
 
        /**
@@ -442,17 +511,22 @@ class WANObjectCache implements LoggerAwareInterface {
         * 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;
@@ -486,15 +560,18 @@ class WANObjectCache implements LoggerAwareInterface {
         * @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;
        }
 
        /**
@@ -562,8 +639,8 @@ class WANObjectCache implements LoggerAwareInterface {
         *     $catInfo = $cache->getWithSetCallback(
         *         // Key to store the cached value under
         *         $cache->makeKey( 'cat-attributes', $catId ),
-        *         // Time-to-live (seconds)
-        *         60,
+        *         // Time-to-live (in seconds)
+        *         $cache::TTL_MINUTE,
         *         // Function that derives the new key value
         *         function ( $oldValue, &$ttl, array &$setOpts ) {
         *             $dbr = wfGetDB( DB_SLAVE );
@@ -580,8 +657,8 @@ class WANObjectCache implements LoggerAwareInterface {
         *     $catConfig = $cache->getWithSetCallback(
         *         // Key to store the cached value under
         *         $cache->makeKey( 'site-cat-config' ),
-        *         // Time-to-live (seconds)
-        *         86400,
+        *         // Time-to-live (in seconds)
+        *         $cache::TTL_DAY,
         *         // Function that derives the new key value
         *         function ( $oldValue, &$ttl, array &$setOpts ) {
         *             $dbr = wfGetDB( DB_SLAVE );
@@ -605,7 +682,7 @@ class WANObjectCache implements LoggerAwareInterface {
         *         // Key to store the cached value under
         *         $cache->makeKey( 'cat-state', $cat->getId() ),
         *         // Time-to-live (seconds)
-        *         900,
+        *         $cache::TTL_HOUR,
         *         // Function that derives the new key value
         *         function ( $oldValue, &$ttl, array &$setOpts ) {
         *             // Determine new value from the DB
@@ -632,7 +709,7 @@ class WANObjectCache implements LoggerAwareInterface {
         *     $lastCatActions = $cache->getWithSetCallback(
         *         // Key to store the cached value under
         *         $cache->makeKey( 'cat-last-actions', 100 ),
-        *         // Time-to-live (seconds)
+        *         // Time-to-live (in seconds)
         *         10,
         *         // Function that derives the new key value
         *         function ( $oldValue, &$ttl, array &$setOpts ) {
@@ -842,17 +919,18 @@ class WANObjectCache implements LoggerAwareInterface {
        /**
         * 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
                ) );
@@ -914,7 +992,7 @@ class WANObjectCache implements LoggerAwareInterface {
         *
         * @param mixed $value
         * @param integer $ttl [0=forever]
-        * @return string
+        * @return array
         */
        protected function wrap( $value, $ttl ) {
                return array(
@@ -934,10 +1012,10 @@ class WANObjectCache implements LoggerAwareInterface {
         */
        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 );
                }
 
@@ -948,7 +1026,12 @@ class WANObjectCache implements LoggerAwareInterface {
                        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 );
@@ -975,17 +1058,36 @@ class WANObjectCache implements LoggerAwareInterface {
        }
 
        /**
-        * @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 static function makePurgeValue( $timestamp, $holdoff ) {
+               return self::PURGE_VAL_PREFIX . (float)$timestamp . ':' . (int)$holdoff;
        }
 }