Avoid self-deadlocks in MessageCache::replace()
[lhc/web/wiklou.git] / includes / cache / MessageCache.php
index f63e0fb..9aac37a 100644 (file)
@@ -25,7 +25,7 @@
  * MediaWiki message cache structure version.
  * Bump this whenever the message cache format has changed.
  */
-define( 'MSG_CACHE_VERSION', 1 );
+define( 'MSG_CACHE_VERSION', 2 );
 
 /**
  * Memcached timeout when loading a key.
@@ -33,17 +33,6 @@ define( 'MSG_CACHE_VERSION', 1 );
  */
 define( 'MSG_LOAD_TIMEOUT', 60 );
 
-/**
- * Memcached timeout when locking a key for a writing operation.
- * See MessageCache::lock()
- */
-define( 'MSG_LOCK_TIMEOUT', 30 );
-/**
- * Number of times we will try to acquire a lock from Memcached.
- * This comes in addition to MSG_LOCK_TIMEOUT.
- */
-define( 'MSG_WAIT_TIMEOUT', 30 );
-
 /**
  * Message cache
  * Performs various MediaWiki namespace-related functions
@@ -52,12 +41,18 @@ define( 'MSG_WAIT_TIMEOUT', 30 );
 class MessageCache {
        const FOR_UPDATE = 1; // force message reload
 
+       /** How long memcached locks last */
+       const WAIT_SEC = 30;
+       /** How long to wait for memcached locks */
+       const LOCK_TTL = 30;
+
        /**
         * Process local cache of loaded messages that are defined in
         * MediaWiki namespace. First array level is a language code,
         * second level is message key and the values are either message
         * content prefixed with space, or !NONEXISTENT for negative
         * caching.
+        * @var array $mCache
         */
        protected $mCache;
 
@@ -137,6 +132,7 @@ class MessageCache {
         * @return string Normalized message key
         */
        public static function normalizeKey( $key ) {
+               global $wgContLang;
                $lckey = strtr( $key, ' ', '_' );
                if ( ord( $lckey ) < 128 ) {
                        $lckey[0] = strtolower( $lckey[0] );
@@ -153,6 +149,8 @@ class MessageCache {
         * @param int $expiry Lifetime for cache. @see $mExpiry.
         */
        function __construct( $memCached, $useDB, $expiry ) {
+               global $wgUseLocalMessageCache;
+
                if ( !$memCached ) {
                        $memCached = wfGetCache( CACHE_NONE );
                }
@@ -161,6 +159,12 @@ class MessageCache {
                $this->mDisable = !$useDB;
                $this->mExpiry = $expiry;
 
+               if ( $wgUseLocalMessageCache ) {
+                       $this->localCache = ObjectCache::newAccelerator( CACHE_NONE );
+               } else {
+                       $this->localCache = wfGetCache( CACHE_NONE );
+               }
+
                $this->wanCache = ObjectCache::getMainWANInstance();
        }
 
@@ -179,70 +183,25 @@ class MessageCache {
        }
 
        /**
-        * Try to load the cache from a local file.
+        * Try to load the cache from APC.
         *
-        * @param string $hash The hash of contents, to check validity.
         * @param string $code Optional language code, see documenation of load().
-        * @return array The cache array
+        * @return array|bool The cache array, or false if not in cache.
         */
-       function getLocalCache( $hash, $code ) {
-               global $wgCacheDirectory;
-
-               $filename = "$wgCacheDirectory/messages-" . wfWikiID() . "-$code";
-
-               # Check file existence
-               MediaWiki\suppressWarnings();
-               $file = fopen( $filename, 'r' );
-               MediaWiki\restoreWarnings();
-               if ( !$file ) {
-                       return false; // No cache file
-               }
-
-               // Check to see if the file has the hash specified
-               $localHash = fread( $file, 32 );
-               if ( $hash === $localHash ) {
-                       // All good, get the rest of it
-                       $serialized = '';
-                       while ( !feof( $file ) ) {
-                               $serialized .= fread( $file, 100000 );
-                       }
-                       fclose( $file );
-
-                       return unserialize( $serialized );
-               } else {
-                       fclose( $file );
-
-                       return false; // Wrong hash
-               }
+       protected function getLocalCache( $code ) {
+               $cacheKey = wfMemcKey( __CLASS__, $code );
+               return $this->localCache->get( $cacheKey );
        }
 
        /**
-        * Save the cache to a local file.
-        * @param string $serialized
-        * @param string $hash
+        * Save the cache to APC.
+        *
         * @param string $code
+        * @param array $cache The cache array
         */
-       function saveToLocal( $serialized, $hash, $code ) {
-               global $wgCacheDirectory;
-
-               $filename = "$wgCacheDirectory/messages-" . wfWikiID() . "-$code";
-               wfMkdirParents( $wgCacheDirectory, null, __METHOD__ ); // might fail
-
-               MediaWiki\suppressWarnings();
-               $file = fopen( $filename, 'w' );
-               MediaWiki\restoreWarnings();
-
-               if ( !$file ) {
-                       wfDebug( "Unable to open local cache file for writing\n" );
-
-                       return;
-               }
-
-               fwrite( $file, $hash . $serialized );
-               fclose( $file );
-               MediaWiki\suppressWarnings();
-               chmod( $filename, 0666 );
-               MediaWiki\restoreWarnings();
+       protected function saveToLocalCache( $code, $cache ) {
+               $cacheKey = wfMemcKey( __CLASS__, $code );
+               $this->localCache->set( $cacheKey, $cache );
        }
 
        /**
@@ -295,29 +254,30 @@ class MessageCache {
                # Loading code starts
                $success = false; # Keep track of success
                $staleCache = false; # a cache array with expired data, or false if none has been loaded
-               $hashExpired = false; # whether the cluster-local validation hash is stale
                $where = array(); # Debug info, delayed to avoid spamming debug log too much
 
-               # Local cache
-               # Hash of the contents is stored in memcache, to detect if local cache goes
-               # out of date (e.g. due to replace() on some other server)
-               if ( $wgUseLocalMessageCache ) {
-                       list( $hash, $hashExpired ) = $this->getValidationHash( $code );
-                       if ( $hash ) {
-                               $cache = $this->getLocalCache( $hash, $code );
-                               if ( !$cache ) {
-                                       $where[] = 'local cache is empty or has the wrong hash';
-                               } elseif ( $this->isCacheExpired( $cache ) ) {
-                                       $where[] = 'local cache is expired';
-                                       $staleCache = $cache;
-                               } elseif ( $hashExpired ) {
-                                       $where[] = 'local cache validation key is expired';
-                                       $staleCache = $cache;
-                               } else {
-                                       $where[] = 'got from local cache';
-                                       $success = true;
-                                       $this->mCache[$code] = $cache;
-                               }
+               # Hash of the contents is stored in memcache, to detect if data-center cache
+               # or local cache goes out of date (e.g. due to replace() on some other server)
+               list( $hash, $hashVolatile ) = $this->getValidationHash( $code );
+
+               if ( $wgUseLocalMessageCache && $hash ) {
+                       # Try the local cache and check against the cluster hash key...
+                       $cache = $this->getLocalCache( $code );
+                       if ( !$cache ) {
+                               $where[] = 'local cache is empty';
+                       } elseif ( !isset( $cache['HASH'] ) || $cache['HASH'] !== $hash ) {
+                               $where[] = 'local cache has the wrong hash';
+                               $staleCache = $cache;
+                       } elseif ( $this->isCacheExpired( $cache ) ) {
+                               $where[] = 'local cache is expired';
+                               $staleCache = $cache;
+                       } elseif ( $hashVolatile ) {
+                               $where[] = 'local cache validation key is expired/volatile';
+                               $staleCache = $cache;
+                       } else {
+                               $where[] = 'got from local cache';
+                               $success = true;
+                               $this->mCache[$code] = $cache;
                        }
                }
 
@@ -327,7 +287,7 @@ class MessageCache {
                        # the lock can't be acquired, wait for the other thread to finish
                        # and then try the global cache a second time.
                        for ( $failedAttempts = 0; $failedAttempts < 2; $failedAttempts++ ) {
-                               if ( $hashExpired && $staleCache ) {
+                               if ( $hashVolatile && $staleCache ) {
                                        # Do not bother fetching the whole cache blob to avoid I/O.
                                        # Instead, just try to get the non-blocking $statusKey lock
                                        # below, and use the local stale value if it was not acquired.
@@ -339,6 +299,12 @@ class MessageCache {
                                        } elseif ( $this->isCacheExpired( $cache ) ) {
                                                $where[] = 'global cache is expired';
                                                $staleCache = $cache;
+                                       } elseif ( $hashVolatile ) {
+                                               # DB results are slave lag prone until the holdoff TTL passes.
+                                               # By then, updates should be reflected in loadFromDBWithLock().
+                                               # One thread renerates the cache while others use old values.
+                                               $where[] = 'global cache is expired/volatile';
+                                               $staleCache = $cache;
                                        } else {
                                                $where[] = 'got from global cache';
                                                $this->mCache[$code] = $cache;
@@ -354,8 +320,8 @@ class MessageCache {
 
                                # We need to call loadFromDB. Limit the concurrency to one process.
                                # This prevents the site from going down when the cache expires.
+                               # Note that the slam-protection lock here is non-blocking.
                                if ( $this->loadFromDBWithLock( $code, $where ) ) {
-                                       # Load from DB complete, no need to retry
                                        $success = true;
                                        break;
                                } elseif ( $staleCache ) {
@@ -377,10 +343,10 @@ class MessageCache {
                                                # Disable cache
                                                break;
                                        } else {
-                                               # Wait for the other thread to finish, then retry
+                                               # Wait for the other thread to finish, then retry. Normally,
+                                               # the memcached get() will then yeild the other thread's result.
                                                $where[] = 'waited for other thread to complete';
-                                               $this->lock( $cacheKey );
-                                               $this->unlock( $cacheKey );
+                                               $this->getReentrantScopedLock( $cacheKey );
                                        }
                                }
                        }
@@ -413,6 +379,9 @@ class MessageCache {
 
                $memCache = $this->mMemc;
 
+               # Get the non-blocking status key lock. This lets the caller quickly know
+               # to use any stale cache lying around. Otherwise, it may do a blocking
+               # lock to try to obtain the messages.
                $statusKey = wfMemcKey( 'messages', $code, 'status' );
                if ( !$memCache->add( $statusKey, 'loading', MSG_LOAD_TIMEOUT ) ) {
                        return false; // could not acquire lock
@@ -431,13 +400,8 @@ class MessageCache {
                # If this lock fails, it doesn't really matter, it just means the
                # write is potentially non-atomic, e.g. the results of a replace()
                # may be discarded.
-               if ( $this->lock( $cacheKey ) ) {
-                       $that = $this;
-                       $mainUnlocker = new ScopedCallback( function () use ( $that, $cacheKey ) {
-                               $that->unlock( $cacheKey );
-                       } );
-               } else {
-                       $mainUnlocker = null;
+               $mainUnlocker = $this->getReentrantScopedLock( $cacheKey );
+               if ( !$mainUnlocker ) {
                        $where[] = 'could not acquire main lock';
                }
 
@@ -558,6 +522,8 @@ class MessageCache {
                }
 
                $cache['VERSION'] = MSG_CACHE_VERSION;
+               ksort( $cache );
+               $cache['HASH'] = md5( serialize( $cache ) );
                $cache['EXPIRY'] = wfTimestamp( TS_MW, time() + $this->mExpiry );
 
                return $cache;
@@ -582,8 +548,13 @@ class MessageCache {
                        return;
                }
 
+               # Note that if the cache is volatile, load() may trigger a DB fetch.
+               # In that case we reenter/reuse the existing cache key lock to avoid
+               # a self-deadlock. This is safe as no reads happen *directly* in this
+               # method between getReentrantScopedLock() and load() below. There is
+               # no risk of data "changing under our feet" for replace().
                $cacheKey = wfMemcKey( 'messages', $code );
-               $this->lock( $cacheKey );
+               $scopedLock = $this->getReentrantScopedLock( $cacheKey );
                $this->load( $code, self::FOR_UPDATE );
 
                $titleKey = wfMemcKey( 'messages', 'individual', $title );
@@ -603,7 +574,7 @@ class MessageCache {
 
                # Update caches
                $this->saveToCaches( $this->mCache[$code], 'all', $code );
-               $this->unlock( $cacheKey );
+               ScopedCallback::consume( $scopedLock );
                $this->wanCache->touchCheckKey( wfMemcKey( 'messages', $code ) );
 
                // Also delete cached sidebar... just in case it is affected
@@ -665,12 +636,11 @@ class MessageCache {
                        $success = true;
                }
 
+               $this->setValidationHash( $code, $cache['HASH'] );
+
                # Save to local cache
                if ( $wgUseLocalMessageCache ) {
-                       $serialized = serialize( $cache );
-                       $hash = md5( $serialized );
-                       $this->setValidationHash( $code, $hash );
-                       $this->saveToLocal( $serialized, $hash, $code );
+                       $this->saveToLocalCache( $code, $cache );
                }
 
                return $success;
@@ -680,7 +650,7 @@ class MessageCache {
         * Get the md5 used to validate the local disk cache
         *
         * @param string $code
-        * @return array (hash or false, bool expiry status)
+        * @return array (hash or false, bool expiry/volatility status)
         */
        protected function getValidationHash( $code ) {
                $curTTL = null;
@@ -709,40 +679,11 @@ class MessageCache {
        }
 
        /**
-        * Represents a write lock on the messages key.
-        *
-        * Will retry MessageCache::MSG_WAIT_TIMEOUT times, each operations having
-        * a timeout of MessageCache::MSG_LOCK_TIMEOUT.
-        *
-        * @param string $key
-        * @return bool Success
+        * @param string $key A language message cache key that stores blobs
+        * @return null|ScopedCallback
         */
-       function lock( $key ) {
-               $lockKey = $key . ':lock';
-               $acquired = false;
-               $testDone = false;
-               for ( $i = 0; $i < MSG_WAIT_TIMEOUT && !$acquired; $i++ ) {
-                       $acquired = $this->mMemc->add( $lockKey, 1, MSG_LOCK_TIMEOUT );
-                       if ( $acquired ) {
-                               break;
-                       }
-
-                       # Fail fast if memcached is totally down
-                       if ( !$testDone ) {
-                               $testDone = true;
-                               if ( !$this->mMemc->set( wfMemcKey( 'test' ), 'test', 1 ) ) {
-                                       break;
-                               }
-                       }
-                       sleep( 1 );
-               }
-
-               return $acquired;
-       }
-
-       function unlock( $key ) {
-               $lockKey = $key . ':lock';
-               $this->mMemc->delete( $lockKey );
+       protected function getReentrantScopedLock( $key ) {
+               return $this->mMemc->getScopedLock( $key, self::WAIT_SEC, self::LOCK_TTL, __METHOD__ );
        }
 
        /**