objectcache: Support key versioning in WANObjectCache
authorAaron Schulz <aschulz@wikimedia.org>
Thu, 12 May 2016 04:07:23 +0000 (21:07 -0700)
committerKrinkle <krinklemail@gmail.com>
Wed, 25 May 2016 18:03:21 +0000 (18:03 +0000)
* getWithSetCallback() takes a 'version' parameter.
* If the value at a key has a different version, then
  getWithSetCallback() will automatically use a separate
  key. Which value "wins" the main key does not matter.
* Purges are handled by using the main key as a sort of
  check key (with no hold-off). Note that this key is always
  purged on delete().
* Changed stash keys to track the same info as other keys
  both for consistency and because this change needs the
  generation timestamp. Renamed the stash prefix to avoid
  corrupt results with Het Deploy.
* This is useful for things like the User class that use
  versioning and have cross-wiki key access and purges.
  Currently, bumps to version must be deployed to all wikis
  at once, which this aims to avoid.

Change-Id: I26ae62f116e32b48bcf06bc13f8b9e79ae976745

includes/libs/objectcache/WANObjectCache.php
tests/phpunit/includes/libs/objectcache/WANObjectCacheTest.php

index 470a38c..ad7bc08 100644 (file)
@@ -127,11 +127,14 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface {
        const ERR_RELAY = 4; // relay broadcast failed
 
        const VALUE_KEY_PREFIX = 'WANCache:v:';
-       const STASH_KEY_PREFIX = 'WANCache:s:';
+       const INTERIM_KEY_PREFIX = 'WANCache:i:';
        const TIME_KEY_PREFIX = 'WANCache:t:';
 
        const PURGE_VAL_PREFIX = 'PURGED:';
 
+       const VFLD_DATA = 'WOC:d'; // key to the value of versioned data
+       const VFLD_VERSION = 'WOC:v'; // key to the version of the value present
+
        const MAX_PC_KEYS = 1000; // max keys to keep in process cache
 
        const DEFAULT_PURGE_CHANNEL = 'wancache-purge';
@@ -207,14 +210,17 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface {
         * That method has cache slam avoiding features for hot/expensive keys.
         *
         * @param string $key Cache key
-        * @param mixed $curTTL Approximate TTL left on the key if present [returned]
+        * @param mixed $curTTL Approximate TTL left on the key if present/tombstoned [returned]
         * @param array $checkKeys List of "check" keys
+        * @param float &$asOf UNIX timestamp of cached value; null on failure [returned]
         * @return mixed Value of cache key or false on failure
         */
-       final public function get( $key, &$curTTL = null, array $checkKeys = [] ) {
+       final public function get( $key, &$curTTL = null, array $checkKeys = [], &$asOf = null ) {
                $curTTLs = [];
-               $values = $this->getMulti( [ $key ], $curTTLs, $checkKeys );
+               $asOfs = [];
+               $values = $this->getMulti( [ $key ], $curTTLs, $checkKeys, $asOfs );
                $curTTL = isset( $curTTLs[$key] ) ? $curTTLs[$key] : null;
+               $asOf = isset( $asOfs[$key] ) ? $asOfs[$key] : null;
 
                return isset( $values[$key] ) ? $values[$key] : false;
        }
@@ -228,13 +234,15 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface {
         * @param array $curTTLs Map of (key => approximate TTL left) for existing keys [returned]
         * @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.
+        * @param float[] &$asOfs Map of (key =>  UNIX timestamp of cached value; null on failure)
         * @return array Map of (key => value) for keys that exist
         */
        final public function getMulti(
-               array $keys, &$curTTLs = [], array $checkKeys = []
+               array $keys, &$curTTLs = [], array $checkKeys = [], array &$asOfs = []
        ) {
                $result = [];
                $curTTLs = [];
+               $asOfs = [];
 
                $vPrefixLen = strlen( self::VALUE_KEY_PREFIX );
                $valueKeys = self::prefixCacheKeys( $keys, self::VALUE_KEY_PREFIX );
@@ -297,6 +305,7 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface {
                                }
                        }
                        $curTTLs[$key] = $curTTL;
+                       $asOfs[$key] = ( $value !== false ) ? $wrappedValues[$vKey][self::FLD_TIME] : null;
                }
 
                return $result;
@@ -761,13 +770,18 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface {
         *      higher this is set, the higher the worst-case staleness can be.
         *      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
+        *   - 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
+        *   - version: Integer version number. This allows for callers to make breaking changes to
+        *      how values are stored while maintaining compatability and correct cache purges. New
+        *      versions are stored alongside older versions concurrently. Avoid storing class objects
+        *      however, as this reduces compatibility (due to serialization).
+        *      Default: null.
+        * @return mixed Value found or written to the key
         */
        final public function getWithSetCallback( $key, $ttl, $callback, array $opts = [] ) {
                $pcTTL = isset( $opts['pcTTL'] ) ? $opts['pcTTL'] : self::TTL_UNCACHEABLE;
@@ -776,8 +790,43 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface {
                $value = ( $pcTTL >= 0 ) ? $this->procCache->get( $key ) : false;
 
                if ( $value === false ) {
+                       unset( $opts['minTime'] ); // not a public feature
+
                        // Fetch the value over the network
-                       $value = $this->doGetWithSetCallback( $key, $ttl, $callback, $opts );
+                       if ( isset( $opts['version'] ) ) {
+                               $version = $opts['version'];
+                               $asOf = null;
+                               $cur = $this->doGetWithSetCallback(
+                                       $key,
+                                       $ttl,
+                                       function ( $oldValue, &$ttl, &$setOpts ) use ( $callback, $version ) {
+                                               $oldData = $oldValue ? $oldValue[self::VFLD_DATA] : false;
+                                               return [
+                                                       self::VFLD_DATA => $callback( $oldData, $ttl, $setOpts ),
+                                                       self::VFLD_VERSION => $version
+                                               ];
+                                       },
+                                       $opts,
+                                       $asOf
+                               );
+                               if ( $cur[self::VFLD_VERSION] === $version ) {
+                                       // Value created or existed before with version; use it
+                                       $value = $cur[self::VFLD_DATA];
+                               } else {
+                                       // Value existed before with a different version; use variant key.
+                                       // Reflect purges to $key by requiring that this key value be newer.
+                                       $value = $this->doGetWithSetCallback(
+                                               'cache-variant:' . md5( $key ) . ":$version",
+                                               $ttl,
+                                               $callback,
+                                               // Regenerate value if not newer than $key
+                                               [ 'version' => null, 'minTime' => $asOf ] + $opts
+                                       );
+                               }
+                       } else {
+                               $value = $this->doGetWithSetCallback( $key, $ttl, $callback, $opts );
+                       }
+
                        // Update the process cache if enabled
                        if ( $pcTTL >= 0 && $value !== false ) {
                                $this->procCache->set( $key, $value, $pcTTL );
@@ -795,21 +844,29 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface {
         * @param string $key
         * @param integer $ttl
         * @param callback $callback
-        * @param array $opts
+        * @param array $opts Options map for getWithSetCallback() which also includes:
+        *   - minTime: Treat values older than this UNIX timestamp as not existing. Default: null.
+        * @param float &$asOf Cache generation timestamp of returned value [returned]
         * @return mixed
         */
-       protected function doGetWithSetCallback( $key, $ttl, $callback, array $opts ) {
+       protected function doGetWithSetCallback( $key, $ttl, $callback, array $opts, &$asOf = null ) {
                $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'] : [];
+               $minTime = isset( $opts['minTime'] ) ? $opts['minTime'] : 0.0;
+               $versioned = isset( $opts['version'] );
 
                // Get the current key value
                $curTTL = null;
-               $cValue = $this->get( $key, $curTTL, $checkKeys ); // current value
+               $cValue = $this->get( $key, $curTTL, $checkKeys, $asOf ); // current value
                $value = $cValue; // return value
 
                // Determine if a regeneration is desired
-               if ( $value !== false && $curTTL > 0 && !$this->worthRefresh( $curTTL, $lowTTL ) ) {
+               if ( $value !== false
+                       && $curTTL > 0
+                       && $this->isValid( $value, $versioned, $asOf, $minTime )
+                       && !$this->worthRefresh( $curTTL, $lowTTL )
+               ) {
                        return $value;
                }
 
@@ -829,15 +886,18 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface {
                        if ( $this->cache->lock( $key, 0, self::LOCK_TTL ) ) {
                                // Lock acquired; this thread should update the key
                                $lockAcquired = true;
-                       } elseif ( $value !== false ) {
+                       } elseif ( $value !== false && $this->isValid( $value, $versioned, $asOf, $minTime ) ) {
                                // If it cannot be acquired; then the stale value can be used
                                return $value;
                        } else {
-                               // Use the stash value for tombstoned keys to reduce regeneration load.
+                               // Use the INTERIM value for tombstoned keys to reduce regeneration load.
                                // For hot keys, either another thread has the lock or the lock failed;
-                               // use the stash value from the last thread that regenerated it.
-                               $value = $this->cache->get( self::STASH_KEY_PREFIX . $key );
-                               if ( $value !== false ) {
+                               // use the INTERIM value from the last thread that regenerated it.
+                               $wrapped = $this->cache->get( self::INTERIM_KEY_PREFIX . $key );
+                               $value = $this->unwrap( $wrapped, microtime( true ) );
+                               if ( $value !== false && $this->isValid( $value, $versioned, $asOf, $minTime ) ) {
+                                       $asOf = $wrapped[self::FLD_TIME];
+
                                        return $value;
                                }
                        }
@@ -850,11 +910,13 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface {
                // Generate the new value from the callback...
                $setOpts = [];
                $value = call_user_func_array( $callback, [ $cValue, &$ttl, &$setOpts ] );
+               $asOf = microtime( true );
                // When delete() is called, writes are write-holed by the tombstone,
-               // so use a special stash key to pass the new value around threads.
+               // so use a special INTERIM key to pass the new value around threads.
                if ( $useMutex && $value !== false && $ttl >= 0 ) {
                        $tempTTL = max( 1, (int)$lockTSE ); // set() expects seconds
-                       $this->cache->set( self::STASH_KEY_PREFIX . $key, $value, $tempTTL );
+                       $wrapped = $this->wrap( $value, $tempTTL, $asOf );
+                       $this->cache->set( self::INTERIM_KEY_PREFIX . $key, $wrapped, $tempTTL );
                }
 
                if ( $lockAcquired ) {
@@ -1005,6 +1067,25 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface {
                return mt_rand( 1, 1e9 ) <= 1e9 * $chance;
        }
 
+       /**
+        * Check whether $value is appropriately versioned and not older than $minTime (if set)
+        *
+        * @param array $value
+        * @param bool $versioned
+        * @param float $asOf The time $value was generated
+        * @param float $minTime The last time the main value was generated (0.0 if unknown)
+        * @return bool
+        */
+       protected function isValid( $value, $versioned, $asOf, $minTime ) {
+               if ( $versioned && !isset( $value[self::VFLD_VERSION] ) ) {
+                       return false;
+               } elseif ( $minTime > 0 && $asOf < $minTime ) {
+                       return false;
+               }
+
+               return true;
+       }
+
        /**
         * Do not use this method outside WANObjectCache
         *
index 266e025..3a4aab4 100644 (file)
@@ -33,17 +33,26 @@ class WANObjectCacheTest extends MediaWikiTestCase {
         * @param integer $ttl
         */
        public function testSetAndGet( $value, $ttl ) {
+               $curTTL = null;
+               $asOf = null;
                $key = wfRandomString();
+
+               $this->cache->get( $key, $curTTL, [], $asOf );
+               $this->assertNull( $curTTL, "Current TTL is null" );
+               $this->assertNull( $asOf, "Current as-of-time is infinite" );
+
+               $t = microtime( true );
                $this->cache->set( $key, $value, $ttl );
 
-               $curTTL = null;
-               $this->assertEquals( $value, $this->cache->get( $key, $curTTL ) );
+               $this->assertEquals( $value, $this->cache->get( $key, $curTTL, [], $asOf ) );
                if ( is_infinite( $ttl ) || $ttl == 0 ) {
                        $this->assertTrue( is_infinite( $curTTL ), "Current TTL is infinite" );
                } else {
                        $this->assertGreaterThan( 0, $curTTL, "Current TTL > 0" );
                        $this->assertLessThanOrEqual( $ttl, $curTTL, "Current TTL < nominal TTL" );
                }
+               $this->assertGreaterThanOrEqual( $t - 1, $asOf, "As-of-time in range of set() time" );
+               $this->assertLessThanOrEqual( $t + 1, $asOf, "As-of-time in range of set() time" );
        }
 
        public static function provideSetAndGet() {
@@ -97,10 +106,13 @@ class WANObjectCacheTest extends MediaWikiTestCase {
        }
 
        /**
+        * @dataProvider getWithSetCallback_provider
         * @covers WANObjectCache::getWithSetCallback()
         * @covers WANObjectCache::doGetWithSetCallback()
+        * @param array $extOpts
+        * @param bool $versioned
         */
-       public function testGetWithSetCallback() {
+       public function testGetWithSetCallback( array $extOpts, $versioned ) {
                $cache = $this->cache;
 
                $key = wfRandomString();
@@ -116,7 +128,7 @@ class WANObjectCacheTest extends MediaWikiTestCase {
                };
 
                $wasSet = 0;
-               $v = $cache->getWithSetCallback( $key, 30, $func, [ 'lockTSE' => 5 ] );
+               $v = $cache->getWithSetCallback( $key, 30, $func, [ 'lockTSE' => 5 ] + $extOpts );
                $this->assertEquals( $value, $v, "Value returned" );
                $this->assertEquals( 1, $wasSet, "Value regenerated" );
 
@@ -129,15 +141,16 @@ class WANObjectCacheTest extends MediaWikiTestCase {
                $v = $cache->getWithSetCallback( $key, 30, $func, [
                        'lowTTL' => 0,
                        'lockTSE' => 5,
-               ] );
+               ] + $extOpts );
                $this->assertEquals( $value, $v, "Value returned" );
                $this->assertEquals( 0, $wasSet, "Value not regenerated" );
 
                $priorTime = microtime( true );
                usleep( 1 );
                $wasSet = 0;
-               $v = $cache->getWithSetCallback( $key, 30, $func,
-                       [ 'checkKeys' => [ $cKey1, $cKey2 ] ] );
+               $v = $cache->getWithSetCallback(
+                       $key, 30, $func, [ 'checkKeys' => [ $cKey1, $cKey2 ] ] + $extOpts
+               );
                $this->assertEquals( $value, $v, "Value returned" );
                $this->assertEquals( 1, $wasSet, "Value regenerated due to check keys" );
                $t1 = $cache->getCheckKeyTime( $cKey1 );
@@ -147,8 +160,9 @@ class WANObjectCacheTest extends MediaWikiTestCase {
 
                $priorTime = microtime( true );
                $wasSet = 0;
-               $v = $cache->getWithSetCallback( $key, 30, $func,
-                       [ 'checkKeys' => [ $cKey1, $cKey2 ] ] );
+               $v = $cache->getWithSetCallback(
+                       $key, 30, $func, [ 'checkKeys' => [ $cKey1, $cKey2 ] ] + $extOpts
+               );
                $this->assertEquals( $value, $v, "Value returned" );
                $this->assertEquals( 1, $wasSet, "Value regenerated due to still-recent check keys" );
                $t1 = $cache->getCheckKeyTime( $cKey1 );
@@ -158,19 +172,30 @@ class WANObjectCacheTest extends MediaWikiTestCase {
 
                $curTTL = null;
                $v = $cache->get( $key, $curTTL, [ $cKey1, $cKey2 ] );
-               $this->assertEquals( $value, $v, "Value returned" );
+               if ( $versioned ) {
+                       $this->assertEquals( $value, $v[$cache::VFLD_DATA], "Value returned" );
+               } else {
+                       $this->assertEquals( $value, $v, "Value returned" );
+               }
                $this->assertLessThanOrEqual( 0, $curTTL, "Value has current TTL < 0 due to check keys" );
 
                $wasSet = 0;
                $key = wfRandomString();
-               $v = $cache->getWithSetCallback( $key, 30, $func, [ 'pcTTL' => 5 ] );
+               $v = $cache->getWithSetCallback( $key, 30, $func, [ 'pcTTL' => 5 ] + $extOpts );
                $this->assertEquals( $value, $v, "Value returned" );
                $cache->delete( $key );
-               $v = $cache->getWithSetCallback( $key, 30, $func, [ 'pcTTL' => 5 ] );
+               $v = $cache->getWithSetCallback( $key, 30, $func, [ 'pcTTL' => 5 ] + $extOpts );
                $this->assertEquals( $value, $v, "Value still returned after deleted" );
                $this->assertEquals( 1, $wasSet, "Value process cached while deleted" );
        }
 
+       public static function getWithSetCallback_provider() {
+               return [
+                       [ [], false ],
+                       [ [ 'version' => 1 ], true ]
+               ];
+       }
+
        /**
         * @covers WANObjectCache::getWithSetCallback()
         * @covers WANObjectCache::doGetWithSetCallback()
@@ -434,6 +459,71 @@ class WANObjectCacheTest extends MediaWikiTestCase {
                $this->assertGreaterThan( 0, $curTTL, "Existing key has current TTL > 0" );
        }
 
+       /**
+        * @dataProvider getWithSetCallback_versions_provider
+        * @param array $extOpts
+        * @param $versioned
+        */
+       public function testGetWithSetCallback_versions( array $extOpts, $versioned ) {
+               $cache = $this->cache;
+
+               $key = wfRandomString();
+               $value = wfRandomString();
+
+               $wasSet = 0;
+               $func = function( $old, &$ttl ) use ( &$wasSet, $value ) {
+                       ++$wasSet;
+                       return $value;
+               };
+
+               // Set the main key (version N if versioned)
+               $wasSet = 0;
+               $v = $cache->getWithSetCallback( $key, 30, $func, $extOpts );
+               $this->assertEquals( $value, $v, "Value returned" );
+               $this->assertEquals( 1, $wasSet, "Value regenerated" );
+               $cache->getWithSetCallback( $key, 30, $func, $extOpts );
+               $this->assertEquals( 1, $wasSet, "Value not regenerated" );
+               // Set the key for version N+1 (if versioned)
+               if ( $versioned ) {
+                       $verOpts = [ 'version' => $extOpts['version'] + 1 ];
+
+                       $wasSet = 0;
+                       $v = $cache->getWithSetCallback( $key, 30, $func, $verOpts + $extOpts );
+                       $this->assertEquals( $value, $v, "Value returned" );
+                       $this->assertEquals( 1, $wasSet, "Value regenerated" );
+
+                       $wasSet = 0;
+                       $v = $cache->getWithSetCallback( $key, 30, $func, $verOpts + $extOpts );
+                       $this->assertEquals( $value, $v, "Value returned" );
+                       $this->assertEquals( 0, $wasSet, "Value not regenerated" );
+               }
+
+               $wasSet = 0;
+               $cache->getWithSetCallback( $key, 30, $func, $extOpts );
+               $this->assertEquals( 0, $wasSet, "Value not regenerated" );
+
+               $wasSet = 0;
+               $cache->delete( $key );
+               $v = $cache->getWithSetCallback( $key, 30, $func, $extOpts );
+               $this->assertEquals( $value, $v, "Value returned" );
+               $this->assertEquals( 1, $wasSet, "Value regenerated" );
+
+               if ( $versioned ) {
+                       $wasSet = 0;
+                       $verOpts = [ 'version' => $extOpts['version'] + 1 ];
+                       $v = $cache->getWithSetCallback( $key, 30, $func, $verOpts + $extOpts );
+                       $this->assertEquals( $value, $v, "Value returned" );
+                       $this->assertEquals( 1, $wasSet, "Value regenerated" );
+               }
+       }
+
+       public static function getWithSetCallback_versions_provider() {
+               return [
+                       [ [], false ],
+                       [ [ 'version' => 1 ], true ]
+               ];
+       }
+
        /**
         * @covers WANObjectCache::touchCheckKey()
         * @covers WANObjectCache::resetCheckKey()