objectcache: fix WRITE_ALLOW_SEGMENTS in BagOStuff cas() and add() methods
authorAaron Schulz <aschulz@wikimedia.org>
Thu, 15 Aug 2019 01:31:48 +0000 (21:31 -0400)
committerAaron Schulz <aschulz@wikimedia.org>
Tue, 20 Aug 2019 14:55:42 +0000 (10:55 -0400)
Add MediumSpecificBagOStuff::getValueOrSegmentList() helper method.

Also:
* Use $keysMissing variable correctly in CachedBagOStuff::getMulti()
  to avoid extra overhead.
* Optimize mergeViaCas() when the current value matches the new one.

Change-Id: I5c4bd74379bc459216ac0278150ce3aecff3b851

13 files changed:
includes/libs/objectcache/APCBagOStuff.php
includes/libs/objectcache/APCUBagOStuff.php
includes/libs/objectcache/CachedBagOStuff.php
includes/libs/objectcache/EmptyBagOStuff.php
includes/libs/objectcache/HashBagOStuff.php
includes/libs/objectcache/MediumSpecificBagOStuff.php
includes/libs/objectcache/MemcachedPeclBagOStuff.php
includes/libs/objectcache/MemcachedPhpBagOStuff.php
includes/libs/objectcache/RESTBagOStuff.php
includes/libs/objectcache/RedisBagOStuff.php
includes/libs/objectcache/WinCacheBagOStuff.php
includes/objectcache/SqlBagOStuff.php
tests/phpunit/includes/libs/objectcache/BagOStuffTest.php

index 0954ac8..aa83b1f 100644 (file)
@@ -73,7 +73,7 @@ class APCBagOStuff extends MediumSpecificBagOStuff {
                return true;
        }
 
-       public function add( $key, $value, $exptime = 0, $flags = 0 ) {
+       protected function doAdd( $key, $value, $exptime = 0, $flags = 0 ) {
                return apc_add(
                        $key . self::KEY_SUFFIX,
                        $this->nativeSerialize ? $value : $this->serialize( $value ),
index 021cdf7..80383d1 100644 (file)
@@ -71,7 +71,7 @@ class APCUBagOStuff extends MediumSpecificBagOStuff {
                );
        }
 
-       public function add( $key, $value, $exptime = 0, $flags = 0 ) {
+       protected function doAdd( $key, $value, $exptime = 0, $flags = 0 ) {
                return apcu_add(
                        $key . self::KEY_SUFFIX,
                        $this->nativeSerialize ? $value : $this->serialize( $value ),
index 0ab26c9..9fa9a89 100644 (file)
@@ -79,7 +79,7 @@ class CachedBagOStuff extends BagOStuff {
                        }
                }
 
-               $valuesByKeyFetched = $this->backend->getMulti( $keys, $flags );
+               $valuesByKeyFetched = $this->backend->getMulti( $keysMissing, $flags );
                $this->setMulti( $valuesByKeyFetched, self::TTL_INDEFINITE, self::WRITE_CACHE_ONLY );
 
                return $valuesByKeyCached + $valuesByKeyFetched;
index dab8ba1..b2613b2 100644 (file)
@@ -41,7 +41,7 @@ class EmptyBagOStuff extends MediumSpecificBagOStuff {
                return true;
        }
 
-       public function add( $key, $value, $exptime = 0, $flags = 0 ) {
+       protected function doAdd( $key, $value, $exptime = 0, $flags = 0 ) {
                return true;
        }
 
index 1cfa0c7..b4087be 100644 (file)
@@ -94,7 +94,7 @@ class HashBagOStuff extends MediumSpecificBagOStuff {
                return true;
        }
 
-       public function add( $key, $value, $exptime = 0, $flags = 0 ) {
+       protected function doAdd( $key, $value, $exptime = 0, $flags = 0 ) {
                if ( $this->hasKey( $key ) && !$this->expire( $key ) ) {
                        return false; // key already set
                }
index 62a8aec..329e600 100644 (file)
@@ -160,57 +160,9 @@ abstract class MediumSpecificBagOStuff extends BagOStuff {
         * @return bool Success
         */
        public function set( $key, $value, $exptime = 0, $flags = 0 ) {
-               if (
-                       is_int( $value ) || // avoid breaking incr()/decr()
-                       ( $flags & self::WRITE_ALLOW_SEGMENTS ) != self::WRITE_ALLOW_SEGMENTS ||
-                       is_infinite( $this->segmentationSize )
-               ) {
-                       return $this->doSet( $key, $value, $exptime, $flags );
-               }
-
-               $serialized = $this->serialize( $value );
-               $segmentSize = $this->getSegmentationSize();
-               $maxTotalSize = $this->getSegmentedValueMaxSize();
-
-               $size = strlen( $serialized );
-               if ( $size <= $segmentSize ) {
-                       // Since the work of serializing it was already done, just use it inline
-                       return $this->doSet(
-                               $key,
-                               SerializedValueContainer::newUnified( $serialized ),
-                               $exptime,
-                               $flags
-                       );
-               } elseif ( $size > $maxTotalSize ) {
-                       $this->setLastError( "Key $key exceeded $maxTotalSize bytes." );
-
-                       return false;
-               }
-
-               $chunksByKey = [];
-               $segmentHashes = [];
-               $count = intdiv( $size, $segmentSize ) + ( ( $size % $segmentSize ) ? 1 : 0 );
-               for ( $i = 0; $i < $count; ++$i ) {
-                       $segment = substr( $serialized, $i * $segmentSize, $segmentSize );
-                       $hash = sha1( $segment );
-                       $chunkKey = $this->makeGlobalKey( self::SEGMENT_COMPONENT, $key, $hash );
-                       $chunksByKey[$chunkKey] = $segment;
-                       $segmentHashes[] = $hash;
-               }
-
-               $flags &= ~self::WRITE_ALLOW_SEGMENTS; // sanity
-               $ok = $this->setMulti( $chunksByKey, $exptime, $flags );
-               if ( $ok ) {
-                       // Only when all segments are stored should the main key be changed
-                       $ok = $this->doSet(
-                               $key,
-                               SerializedValueContainer::newSegmented( $segmentHashes ),
-                               $exptime,
-                               $flags
-                       );
-               }
-
-               return $ok;
+               list( $entry, $usable ) = $this->makeValueOrSegmentList( $key, $value, $exptime, $flags );
+               // Only when all segments (if any) are stored should the main key be changed
+               return $usable ? $this->doSet( $key, $entry, $exptime, $flags ) : false;
        }
 
        /**
@@ -268,6 +220,23 @@ abstract class MediumSpecificBagOStuff extends BagOStuff {
         */
        abstract protected function doDelete( $key, $flags = 0 );
 
+       public function add( $key, $value, $exptime = 0, $flags = 0 ) {
+               list( $entry, $usable ) = $this->makeValueOrSegmentList( $key, $value, $exptime, $flags );
+               // Only when all segments (if any) are stored should the main key be changed
+               return $usable ? $this->doAdd( $key, $entry, $exptime, $flags ) : false;
+       }
+
+       /**
+        * Insert an item if it does not already exist
+        *
+        * @param string $key
+        * @param mixed $value
+        * @param int $exptime
+        * @param int $flags Bitfield of BagOStuff::WRITE_* constants (since 1.33)
+        * @return bool Success
+        */
+       abstract protected function doAdd( $key, $value, $exptime = 0, $flags = 0 );
+
        /**
         * Merge changes into the existing cache value (possibly creating a new one)
         *
@@ -283,7 +252,6 @@ abstract class MediumSpecificBagOStuff extends BagOStuff {
         * @param int $attempts The amount of times to attempt a merge in case of failure
         * @param int $flags Bitfield of BagOStuff::WRITE_* constants
         * @return bool Success
-        * @throws InvalidArgumentException
         */
        public function merge( $key, callable $callback, $exptime = 0, $attempts = 10, $flags = 0 ) {
                return $this->mergeViaCas( $key, $callback, $exptime, $attempts, $flags );
@@ -297,9 +265,9 @@ abstract class MediumSpecificBagOStuff extends BagOStuff {
         * @param int $flags Bitfield of BagOStuff::WRITE_* constants
         * @return bool Success
         * @see BagOStuff::merge()
-        *
         */
        final protected function mergeViaCas( $key, callable $callback, $exptime, $attempts, $flags ) {
+               $attemptsLeft = $attempts;
                do {
                        $casToken = null; // passed by reference
                        // Get the old value and CAS token from cache
@@ -309,23 +277,27 @@ abstract class MediumSpecificBagOStuff extends BagOStuff {
                                $this->doGet( $key, self::READ_LATEST, $casToken )
                        );
                        if ( $this->getLastError() ) {
+                               // Don't spam slow retries due to network problems (retry only on races)
                                $this->logger->warning(
-                                       __METHOD__ . ' failed due to I/O error on get() for {key}.',
+                                       __METHOD__ . ' failed due to read I/O error on get() for {key}.',
                                        [ 'key' => $key ]
                                );
-
-                               return false; // don't spam retries (retry only on races)
+                               $success = false;
+                               break;
                        }
 
                        // Derive the new value from the old value
                        $value = call_user_func( $callback, $this, $key, $currentValue, $exptime );
-                       $hadNoCurrentValue = ( $currentValue === false );
+                       $keyWasNonexistant = ( $currentValue === false );
+                       $valueMatchesOldValue = ( $value === $currentValue );
                        unset( $currentValue ); // free RAM in case the value is large
 
                        $this->clearLastError();
                        if ( $value === false ) {
                                $success = true; // do nothing
-                       } elseif ( $hadNoCurrentValue ) {
+                       } elseif ( $valueMatchesOldValue && $attemptsLeft !== $attempts ) {
+                               $success = true; // recently set by another thread to the same value
+                       } elseif ( $keyWasNonexistant ) {
                                // Try to create the key, failing if it gets created in the meantime
                                $success = $this->add( $key, $value, $exptime, $flags );
                        } else {
@@ -333,15 +305,16 @@ abstract class MediumSpecificBagOStuff extends BagOStuff {
                                $success = $this->cas( $casToken, $key, $value, $exptime, $flags );
                        }
                        if ( $this->getLastError() ) {
+                               // Don't spam slow retries due to network problems (retry only on races)
                                $this->logger->warning(
-                                       __METHOD__ . ' failed due to I/O error for {key}.',
+                                       __METHOD__ . ' failed due to write I/O error for {key}.',
                                        [ 'key' => $key ]
                                );
-
-                               return false; // IO error; don't spam retries
+                               $success = false;
+                               break;
                        }
 
-               } while ( !$success && --$attempts );
+               } while ( !$success && --$attemptsLeft );
 
                return $success;
        }
@@ -357,21 +330,58 @@ abstract class MediumSpecificBagOStuff extends BagOStuff {
         * @return bool Success
         */
        protected function cas( $casToken, $key, $value, $exptime = 0, $flags = 0 ) {
+               if ( $casToken === null ) {
+                       $this->logger->warning(
+                               __METHOD__ . ' got empty CAS token for {key}.',
+                               [ 'key' => $key ]
+                       );
+
+                       return false; // caller may have meant to use add()?
+               }
+
+               list( $entry, $usable ) = $this->makeValueOrSegmentList( $key, $value, $exptime, $flags );
+               // Only when all segments (if any) are stored should the main key be changed
+               return $usable ? $this->doCas( $casToken, $key, $entry, $exptime, $flags ) : false;
+       }
+
+       /**
+        * Check and set an item
+        *
+        * @param mixed $casToken
+        * @param string $key
+        * @param mixed $value
+        * @param int $exptime Either an interval in seconds or a unix timestamp for expiry
+        * @param int $flags Bitfield of BagOStuff::WRITE_* constants
+        * @return bool Success
+        */
+       protected function doCas( $casToken, $key, $value, $exptime = 0, $flags = 0 ) {
+               // @TODO: the lock() call assumes that all other relavent sets() use one
                if ( !$this->lock( $key, 0 ) ) {
                        return false; // non-blocking
                }
 
                $curCasToken = null; // passed by reference
+               $this->clearLastError();
                $this->doGet( $key, self::READ_LATEST, $curCasToken );
-               if ( $casToken === $curCasToken ) {
-                       $success = $this->set( $key, $value, $exptime, $flags );
+               if ( is_object( $curCasToken ) ) {
+                       // Using === does not work with objects since it checks for instance identity
+                       throw new UnexpectedValueException( "CAS token cannot be an object" );
+               }
+               if ( $this->getLastError() ) {
+                       // Fail if the old CAS token could not be read
+                       $success = false;
+                       $this->logger->warning(
+                               __METHOD__ . ' failed due to write I/O error for {key}.',
+                               [ 'key' => $key ]
+                       );
+               } elseif ( $casToken === $curCasToken ) {
+                       $success = $this->doSet( $key, $value, $exptime, $flags );
                } else {
+                       $success = false; // mismatched or failed
                        $this->logger->info(
                                __METHOD__ . ' failed due to race condition for {key}.',
                                [ 'key' => $key ]
                        );
-
-                       $success = false; // mismatched or failed
                }
 
                $this->unlock( $key );
@@ -782,6 +792,59 @@ abstract class MediumSpecificBagOStuff extends BagOStuff {
                $this->busyCallbacks[] = $workCallback;
        }
 
+       /**
+        * Determine the entry (inline or segment list) to store under a key to save the value
+        *
+        * @param string $key
+        * @param mixed $value
+        * @param int $exptime
+        * @param int $flags
+        * @return array (inline value or segment list, whether the entry is usable)
+        * @since 1.34
+        */
+       final protected function makeValueOrSegmentList( $key, $value, $exptime, $flags ) {
+               $entry = $value;
+               $usable = true;
+
+               if (
+                       ( $flags & self::WRITE_ALLOW_SEGMENTS ) === self::WRITE_ALLOW_SEGMENTS &&
+                       !is_int( $value ) && // avoid breaking incr()/decr()
+                       is_finite( $this->segmentationSize )
+               ) {
+                       $segmentSize = $this->segmentationSize;
+                       $maxTotalSize = $this->segmentedValueMaxSize;
+
+                       $serialized = $this->serialize( $value );
+                       $size = strlen( $serialized );
+                       if ( $size > $maxTotalSize ) {
+                               $this->logger->warning(
+                                       "Value for {key} exceeds $maxTotalSize bytes; cannot segment.",
+                                       [ 'key' => $key ]
+                               );
+                       } elseif ( $size <= $segmentSize ) {
+                               // The serialized value was already computed, so just use it inline
+                               $entry = SerializedValueContainer::newUnified( $serialized );
+                       } else {
+                               // Split the serialized value into chunks and store them at different keys
+                               $chunksByKey = [];
+                               $segmentHashes = [];
+                               $count = intdiv( $size, $segmentSize ) + ( ( $size % $segmentSize ) ? 1 : 0 );
+                               for ( $i = 0; $i < $count; ++$i ) {
+                                       $segment = substr( $serialized, $i * $segmentSize, $segmentSize );
+                                       $hash = sha1( $segment );
+                                       $chunkKey = $this->makeGlobalKey( self::SEGMENT_COMPONENT, $key, $hash );
+                                       $chunksByKey[$chunkKey] = $segment;
+                                       $segmentHashes[] = $hash;
+                               }
+                               $flags &= ~self::WRITE_ALLOW_SEGMENTS; // sanity
+                               $usable = $this->setMulti( $chunksByKey, $exptime, $flags );
+                               $entry = SerializedValueContainer::newSegmented( $segmentHashes );
+                       }
+               }
+
+               return [ $entry, $usable ];
+       }
+
        /**
         * @param int|float $exptime
         * @return bool Whether the expiry is non-infinite, and, negative or not a UNIX timestamp
index cc7ee2a..3df483d 100644 (file)
@@ -214,7 +214,7 @@ class MemcachedPeclBagOStuff extends MemcachedBagOStuff {
                        : $this->checkResult( $key, $result );
        }
 
-       protected function cas( $casToken, $key, $value, $exptime = 0, $flags = 0 ) {
+       protected function doCas( $casToken, $key, $value, $exptime = 0, $flags = 0 ) {
                $this->debug( "cas($key)" );
 
                $result = $this->acquireSyncClient()->cas(
@@ -238,7 +238,7 @@ class MemcachedPeclBagOStuff extends MemcachedBagOStuff {
                        : $this->checkResult( $key, $result );
        }
 
-       public function add( $key, $value, $exptime = 0, $flags = 0 ) {
+       protected function doAdd( $key, $value, $exptime = 0, $flags = 0 ) {
                $this->debug( "add($key)" );
 
                $result = $this->acquireSyncClient()->add(
index b1d5d29..8144231 100644 (file)
@@ -76,7 +76,7 @@ class MemcachedPhpBagOStuff extends MemcachedBagOStuff {
                return $this->client->delete( $this->validateKeyEncoding( $key ) );
        }
 
-       public function add( $key, $value, $exptime = 0, $flags = 0 ) {
+       protected function doAdd( $key, $value, $exptime = 0, $flags = 0 ) {
                return $this->client->add(
                        $this->validateKeyEncoding( $key ),
                        $value,
@@ -84,7 +84,7 @@ class MemcachedPhpBagOStuff extends MemcachedBagOStuff {
                );
        }
 
-       protected function cas( $casToken, $key, $value, $exptime = 0, $flags = 0 ) {
+       protected function doCas( $casToken, $key, $value, $exptime = 0, $flags = 0 ) {
                return $this->client->cas(
                        $casToken,
                        $this->validateKeyEncoding( $key ),
index aa4a9b3..b8ce38b 100644 (file)
@@ -164,7 +164,7 @@ class RESTBagOStuff extends MediumSpecificBagOStuff {
                return $this->handleError( "Failed to store $key", $rcode, $rerr, $rhdrs, $rbody );
        }
 
-       public function add( $key, $value, $exptime = 0, $flags = 0 ) {
+       protected function doAdd( $key, $value, $exptime = 0, $flags = 0 ) {
                // @TODO: make this atomic
                if ( $this->get( $key ) === false ) {
                        return $this->set( $key, $value, $exptime, $flags );
index f75d3a1..252b1fa 100644 (file)
@@ -341,7 +341,7 @@ class RedisBagOStuff extends MediumSpecificBagOStuff {
                return $result;
        }
 
-       public function add( $key, $value, $expiry = 0, $flags = 0 ) {
+       protected function doAdd( $key, $value, $expiry = 0, $flags = 0 ) {
                $conn = $this->getConnection( $key );
                if ( !$conn ) {
                        return false;
index 0e4e3fb..3c4efbb 100644 (file)
  * @ingroup Cache
  */
 class WinCacheBagOStuff extends MediumSpecificBagOStuff {
+       public function __construct( array $params = [] ) {
+               $params['segmentationSize'] = $params['segmentationSize'] ?? INF;
+               parent::__construct( $params );
+       }
+
        protected function doGet( $key, $flags = 0, &$casToken = null ) {
                $casToken = null;
 
@@ -44,7 +49,7 @@ class WinCacheBagOStuff extends MediumSpecificBagOStuff {
                return $value;
        }
 
-       protected function cas( $casToken, $key, $value, $exptime = 0, $flags = 0 ) {
+       protected function doCas( $casToken, $key, $value, $exptime = 0, $flags = 0 ) {
                if ( !wincache_lock( $key ) ) { // optimize with FIFO lock
                        return false;
                }
@@ -76,7 +81,7 @@ class WinCacheBagOStuff extends MediumSpecificBagOStuff {
                return ( $result === [] || $result === true );
        }
 
-       public function add( $key, $value, $exptime = 0, $flags = 0 ) {
+       protected function doAdd( $key, $value, $exptime = 0, $flags = 0 ) {
                if ( wincache_ucache_exists( $key ) ) {
                        return false; // avoid warnings
                }
index e97dc41..d9fe319 100644 (file)
@@ -467,11 +467,11 @@ class SqlBagOStuff extends MediumSpecificBagOStuff {
                return $this->modifyMulti( [ $key => $value ], $exptime, $flags, self::$OP_SET );
        }
 
-       public function add( $key, $value, $exptime = 0, $flags = 0 ) {
+       protected function doAdd( $key, $value, $exptime = 0, $flags = 0 ) {
                return $this->modifyMulti( [ $key => $value ], $exptime, $flags, self::$OP_ADD );
        }
 
-       protected function cas( $casToken, $key, $value, $exptime = 0, $flags = 0 ) {
+       protected function doCas( $casToken, $key, $value, $exptime = 0, $flags = 0 ) {
                list( $serverIndex, $tableName ) = $this->getTableByKey( $key );
                $exptime = $this->getExpirationAsTimestamp( $exptime );
 
index 9ec86bc..7d3e82c 100644 (file)
@@ -380,24 +380,39 @@ class BagOStuffTest extends MediaWikiTestCase {
                        return $oldValue . '!';
                };
 
-               foreach ( [ $tiny, $small, $big ] as $value ) {
+               $cases = [ 'tiny' => $tiny, 'small' => $small, 'big' => $big ];
+               foreach ( $cases as $case => $value ) {
                        $this->cache->set( $key, $value, 10, BagOStuff::WRITE_ALLOW_SEGMENTS );
-                       $this->assertEquals( $value, $this->cache->get( $key ) );
-                       $this->assertEquals( $value, $this->cache->getMulti( [ $key ] )[$key] );
-
-                       $this->assertTrue( $this->cache->merge( $key, $callback, 5 ) );
-                       $this->assertEquals( "$value!", $this->cache->get( $key ) );
-                       $this->assertEquals( "$value!", $this->cache->getMulti( [ $key ] )[$key] );
-
-                       $this->assertTrue( $this->cache->deleteMulti( [ $key ] ) );
-                       $this->assertFalse( $this->cache->get( $key ) );
-                       $this->assertEquals( [], $this->cache->getMulti( [ $key ] ) );
+                       $this->assertEquals( $value, $this->cache->get( $key ), "get $case" );
+                       $this->assertEquals( $value, $this->cache->getMulti( [ $key ] )[$key], "get $case" );
+
+                       $this->assertTrue(
+                               $this->cache->merge( $key, $callback, 5, 1, BagOStuff::WRITE_ALLOW_SEGMENTS ),
+                               "merge $case"
+                       );
+                       $this->assertEquals(
+                               "$value!",
+                               $this->cache->get( $key ),
+                               "merged $case"
+                       );
+                       $this->assertEquals(
+                               "$value!",
+                               $this->cache->getMulti( [ $key ] )[$key],
+                               "merged $case"
+                       );
+
+                       $this->assertTrue( $this->cache->deleteMulti( [ $key ] ), "delete $case" );
+                       $this->assertFalse( $this->cache->get( $key ), "deleted $case" );
+                       $this->assertEquals( [], $this->cache->getMulti( [ $key ] ), "deletd $case" );
 
                        $this->cache->set( $key, "@$value", 10, BagOStuff::WRITE_ALLOW_SEGMENTS );
-                       $this->assertEquals( "@$value", $this->cache->get( $key ) );
-                       $this->assertTrue( $this->cache->delete( $key, BagOStuff::WRITE_PRUNE_SEGMENTS ) );
-                       $this->assertFalse( $this->cache->get( $key ) );
-                       $this->assertEquals( [], $this->cache->getMulti( [ $key ] ) );
+                       $this->assertEquals( "@$value", $this->cache->get( $key ), "get $case" );
+                       $this->assertTrue(
+                               $this->cache->delete( $key, BagOStuff::WRITE_PRUNE_SEGMENTS ),
+                               "prune $case"
+                       );
+                       $this->assertFalse( $this->cache->get( $key ), "pruned $case" );
+                       $this->assertEquals( [], $this->cache->getMulti( [ $key ] ), "pruned $case" );
                }
 
                $this->cache->set( $key, 666, 10, BagOStuff::WRITE_ALLOW_SEGMENTS );