diff options
Diffstat (limited to 'includes/filebackend/lockmanager')
-rw-r--r-- | includes/filebackend/lockmanager/DBLockManager.php | 374 | ||||
-rw-r--r-- | includes/filebackend/lockmanager/FSLockManager.php | 255 | ||||
-rw-r--r-- | includes/filebackend/lockmanager/LSLockManager.php | 218 | ||||
-rw-r--r-- | includes/filebackend/lockmanager/LockManager.php | 425 | ||||
-rw-r--r-- | includes/filebackend/lockmanager/LockManagerGroup.php | 143 | ||||
-rw-r--r-- | includes/filebackend/lockmanager/MemcLockManager.php | 319 |
6 files changed, 1734 insertions, 0 deletions
diff --git a/includes/filebackend/lockmanager/DBLockManager.php b/includes/filebackend/lockmanager/DBLockManager.php new file mode 100644 index 00000000..a8fe258b --- /dev/null +++ b/includes/filebackend/lockmanager/DBLockManager.php @@ -0,0 +1,374 @@ +<?php +/** + * Version of LockManager based on using DB table locks. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * http://www.gnu.org/copyleft/gpl.html + * + * @file + * @ingroup LockManager + */ + +/** + * Version of LockManager based on using DB table locks. + * This is meant for multi-wiki systems that may share files. + * All locks are blocking, so it might be useful to set a small + * lock-wait timeout via server config to curtail deadlocks. + * + * All lock requests for a resource, identified by a hash string, will map + * to one bucket. Each bucket maps to one or several peer DBs, each on their + * own server, all having the filelocks.sql tables (with row-level locking). + * A majority of peer DBs must agree for a lock to be acquired. + * + * Caching is used to avoid hitting servers that are down. + * + * @ingroup LockManager + * @since 1.19 + */ +class DBLockManager extends QuorumLockManager { + /** @var Array Map of DB names to server config */ + protected $dbServers; // (DB name => server config array) + /** @var BagOStuff */ + protected $statusCache; + + protected $lockExpiry; // integer number of seconds + protected $safeDelay; // integer number of seconds + + protected $session = 0; // random integer + /** @var Array Map Database connections (DB name => Database) */ + protected $conns = array(); + + /** + * Construct a new instance from configuration. + * + * $config paramaters include: + * - dbServers : Associative array of DB names to server configuration. + * Configuration is an associative array that includes: + * - host : DB server name + * - dbname : DB name + * - type : DB type (mysql,postgres,...) + * - user : DB user + * - password : DB user password + * - tablePrefix : DB table prefix + * - flags : DB flags (see DatabaseBase) + * - dbsByBucket : Array of 1-16 consecutive integer keys, starting from 0, + * each having an odd-numbered list of DB names (peers) as values. + * Any DB named 'localDBMaster' will automatically use the DB master + * settings for this wiki (without the need for a dbServers entry). + * - lockExpiry : Lock timeout (seconds) for dropped connections. [optional] + * This tells the DB server how long to wait before assuming + * connection failure and releasing all the locks for a session. + * + * @param Array $config + */ + public function __construct( array $config ) { + parent::__construct( $config ); + + $this->dbServers = isset( $config['dbServers'] ) + ? $config['dbServers'] + : array(); // likely just using 'localDBMaster' + // Sanitize srvsByBucket config to prevent PHP errors + $this->srvsByBucket = array_filter( $config['dbsByBucket'], 'is_array' ); + $this->srvsByBucket = array_values( $this->srvsByBucket ); // consecutive + + if ( isset( $config['lockExpiry'] ) ) { + $this->lockExpiry = $config['lockExpiry']; + } else { + $met = ini_get( 'max_execution_time' ); + $this->lockExpiry = $met ? $met : 60; // use some sane amount if 0 + } + $this->safeDelay = ( $this->lockExpiry <= 0 ) + ? 60 // pick a safe-ish number to match DB timeout default + : $this->lockExpiry; // cover worst case + + foreach ( $this->srvsByBucket as $bucket ) { + if ( count( $bucket ) > 1 ) { // multiple peers + // Tracks peers that couldn't be queried recently to avoid lengthy + // connection timeouts. This is useless if each bucket has one peer. + try { + $this->statusCache = ObjectCache::newAccelerator( array() ); + } catch ( MWException $e ) { + trigger_error( __CLASS__ . + " using multiple DB peers without apc, xcache, or wincache." ); + } + break; + } + } + + $this->session = wfRandomString( 31 ); + } + + /** + * Get a connection to a lock DB and acquire locks on $paths. + * This does not use GET_LOCK() per http://bugs.mysql.com/bug.php?id=1118. + * + * @see QuorumLockManager::getLocksOnServer() + * @return Status + */ + protected function getLocksOnServer( $lockSrv, array $paths, $type ) { + $status = Status::newGood(); + + if ( $type == self::LOCK_EX ) { // writer locks + try { + $keys = array_unique( array_map( 'LockManager::sha1Base36', $paths ) ); + # Build up values for INSERT clause + $data = array(); + foreach ( $keys as $key ) { + $data[] = array( 'fle_key' => $key ); + } + # Wait on any existing writers and block new ones if we get in + $db = $this->getConnection( $lockSrv ); // checked in isServerUp() + $db->insert( 'filelocks_exclusive', $data, __METHOD__ ); + } catch ( DBError $e ) { + foreach ( $paths as $path ) { + $status->fatal( 'lockmanager-fail-acquirelock', $path ); + } + } + } + + return $status; + } + + /** + * @see QuorumLockManager::freeLocksOnServer() + * @return Status + */ + protected function freeLocksOnServer( $lockSrv, array $paths, $type ) { + return Status::newGood(); // not supported + } + + /** + * @see QuorumLockManager::releaseAllLocks() + * @return Status + */ + protected function releaseAllLocks() { + $status = Status::newGood(); + + foreach ( $this->conns as $lockDb => $db ) { + if ( $db->trxLevel() ) { // in transaction + try { + $db->rollback( __METHOD__ ); // finish transaction and kill any rows + } catch ( DBError $e ) { + $status->fatal( 'lockmanager-fail-db-release', $lockDb ); + } + } + } + + return $status; + } + + /** + * @see QuorumLockManager::isServerUp() + * @return bool + */ + protected function isServerUp( $lockSrv ) { + if ( !$this->cacheCheckFailures( $lockSrv ) ) { + return false; // recent failure to connect + } + try { + $this->getConnection( $lockSrv ); + } catch ( DBError $e ) { + $this->cacheRecordFailure( $lockSrv ); + return false; // failed to connect + } + return true; + } + + /** + * Get (or reuse) a connection to a lock DB + * + * @param $lockDb string + * @return DatabaseBase + * @throws DBError + */ + protected function getConnection( $lockDb ) { + if ( !isset( $this->conns[$lockDb] ) ) { + $db = null; + if ( $lockDb === 'localDBMaster' ) { + $lb = wfGetLBFactory()->newMainLB(); + $db = $lb->getConnection( DB_MASTER ); + } elseif ( isset( $this->dbServers[$lockDb] ) ) { + $config = $this->dbServers[$lockDb]; + $db = DatabaseBase::factory( $config['type'], $config ); + } + if ( !$db ) { + return null; // config error? + } + $this->conns[$lockDb] = $db; + $this->conns[$lockDb]->clearFlag( DBO_TRX ); + # If the connection drops, try to avoid letting the DB rollback + # and release the locks before the file operations are finished. + # This won't handle the case of DB server restarts however. + $options = array(); + if ( $this->lockExpiry > 0 ) { + $options['connTimeout'] = $this->lockExpiry; + } + $this->conns[$lockDb]->setSessionOptions( $options ); + $this->initConnection( $lockDb, $this->conns[$lockDb] ); + } + if ( !$this->conns[$lockDb]->trxLevel() ) { + $this->conns[$lockDb]->begin( __METHOD__ ); // start transaction + } + return $this->conns[$lockDb]; + } + + /** + * Do additional initialization for new lock DB connection + * + * @param $lockDb string + * @param $db DatabaseBase + * @return void + * @throws DBError + */ + protected function initConnection( $lockDb, DatabaseBase $db ) {} + + /** + * Checks if the DB has not recently had connection/query errors. + * This just avoids wasting time on doomed connection attempts. + * + * @param $lockDb string + * @return bool + */ + protected function cacheCheckFailures( $lockDb ) { + return ( $this->statusCache && $this->safeDelay > 0 ) + ? !$this->statusCache->get( $this->getMissKey( $lockDb ) ) + : true; + } + + /** + * Log a lock request failure to the cache + * + * @param $lockDb string + * @return bool Success + */ + protected function cacheRecordFailure( $lockDb ) { + return ( $this->statusCache && $this->safeDelay > 0 ) + ? $this->statusCache->set( $this->getMissKey( $lockDb ), 1, $this->safeDelay ) + : true; + } + + /** + * Get a cache key for recent query misses for a DB + * + * @param $lockDb string + * @return string + */ + protected function getMissKey( $lockDb ) { + $lockDb = ( $lockDb === 'localDBMaster' ) ? wfWikiID() : $lockDb; // non-relative + return 'dblockmanager:downservers:' . str_replace( ' ', '_', $lockDb ); + } + + /** + * Make sure remaining locks get cleared for sanity + */ + function __destruct() { + foreach ( $this->conns as $db ) { + if ( $db->trxLevel() ) { // in transaction + try { + $db->rollback( __METHOD__ ); // finish transaction and kill any rows + } catch ( DBError $e ) { + // oh well + } + } + $db->close(); + } + } +} + +/** + * MySQL version of DBLockManager that supports shared locks. + * All locks are non-blocking, which avoids deadlocks. + * + * @ingroup LockManager + */ +class MySqlLockManager extends DBLockManager { + /** @var Array Mapping of lock types to the type actually used */ + protected $lockTypeMap = array( + self::LOCK_SH => self::LOCK_SH, + self::LOCK_UW => self::LOCK_SH, + self::LOCK_EX => self::LOCK_EX + ); + + /** + * @param $lockDb string + * @param $db DatabaseBase + */ + protected function initConnection( $lockDb, DatabaseBase $db ) { + # Let this transaction see lock rows from other transactions + $db->query( "SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;" ); + } + + /** + * Get a connection to a lock DB and acquire locks on $paths. + * This does not use GET_LOCK() per http://bugs.mysql.com/bug.php?id=1118. + * + * @see DBLockManager::getLocksOnServer() + * @return Status + */ + protected function getLocksOnServer( $lockSrv, array $paths, $type ) { + $status = Status::newGood(); + + $db = $this->getConnection( $lockSrv ); // checked in isServerUp() + $keys = array_unique( array_map( 'LockManager::sha1Base36', $paths ) ); + # Build up values for INSERT clause + $data = array(); + foreach ( $keys as $key ) { + $data[] = array( 'fls_key' => $key, 'fls_session' => $this->session ); + } + # Block new writers... + $db->insert( 'filelocks_shared', $data, __METHOD__, array( 'IGNORE' ) ); + # Actually do the locking queries... + if ( $type == self::LOCK_SH ) { // reader locks + # Bail if there are any existing writers... + $blocked = $db->selectField( 'filelocks_exclusive', '1', + array( 'fle_key' => $keys ), + __METHOD__ + ); + # Prospective writers that haven't yet updated filelocks_exclusive + # will recheck filelocks_shared after doing so and bail due to our entry. + } else { // writer locks + $encSession = $db->addQuotes( $this->session ); + # Bail if there are any existing writers... + # The may detect readers, but the safe check for them is below. + # Note: if two writers come at the same time, both bail :) + $blocked = $db->selectField( 'filelocks_shared', '1', + array( 'fls_key' => $keys, "fls_session != $encSession" ), + __METHOD__ + ); + if ( !$blocked ) { + # Build up values for INSERT clause + $data = array(); + foreach ( $keys as $key ) { + $data[] = array( 'fle_key' => $key ); + } + # Block new readers/writers... + $db->insert( 'filelocks_exclusive', $data, __METHOD__ ); + # Bail if there are any existing readers... + $blocked = $db->selectField( 'filelocks_shared', '1', + array( 'fls_key' => $keys, "fls_session != $encSession" ), + __METHOD__ + ); + } + } + + if ( $blocked ) { + foreach ( $paths as $path ) { + $status->fatal( 'lockmanager-fail-acquirelock', $path ); + } + } + + return $status; + } +} diff --git a/includes/filebackend/lockmanager/FSLockManager.php b/includes/filebackend/lockmanager/FSLockManager.php new file mode 100644 index 00000000..9a6206fd --- /dev/null +++ b/includes/filebackend/lockmanager/FSLockManager.php @@ -0,0 +1,255 @@ +<?php +/** + * Simple version of LockManager based on using FS lock files. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * http://www.gnu.org/copyleft/gpl.html + * + * @file + * @ingroup LockManager + */ + +/** + * Simple version of LockManager based on using FS lock files. + * All locks are non-blocking, which avoids deadlocks. + * + * This should work fine for small sites running off one server. + * Do not use this with 'lockDirectory' set to an NFS mount unless the + * NFS client is at least version 2.6.12. Otherwise, the BSD flock() + * locks will be ignored; see http://nfs.sourceforge.net/#section_d. + * + * @ingroup LockManager + * @since 1.19 + */ +class FSLockManager extends LockManager { + /** @var Array Mapping of lock types to the type actually used */ + protected $lockTypeMap = array( + self::LOCK_SH => self::LOCK_SH, + self::LOCK_UW => self::LOCK_SH, + self::LOCK_EX => self::LOCK_EX + ); + + protected $lockDir; // global dir for all servers + + /** @var Array Map of (locked key => lock type => lock file handle) */ + protected $handles = array(); + + /** + * Construct a new instance from configuration. + * + * $config includes: + * - lockDirectory : Directory containing the lock files + * + * @param array $config + */ + function __construct( array $config ) { + parent::__construct( $config ); + + $this->lockDir = $config['lockDirectory']; + } + + /** + * @see LockManager::doLock() + * @param $paths array + * @param $type int + * @return Status + */ + protected function doLock( array $paths, $type ) { + $status = Status::newGood(); + + $lockedPaths = array(); // files locked in this attempt + foreach ( $paths as $path ) { + $status->merge( $this->doSingleLock( $path, $type ) ); + if ( $status->isOK() ) { + $lockedPaths[] = $path; + } else { + // Abort and unlock everything + $status->merge( $this->doUnlock( $lockedPaths, $type ) ); + return $status; + } + } + + return $status; + } + + /** + * @see LockManager::doUnlock() + * @param $paths array + * @param $type int + * @return Status + */ + protected function doUnlock( array $paths, $type ) { + $status = Status::newGood(); + + foreach ( $paths as $path ) { + $status->merge( $this->doSingleUnlock( $path, $type ) ); + } + + return $status; + } + + /** + * Lock a single resource key + * + * @param $path string + * @param $type integer + * @return Status + */ + protected function doSingleLock( $path, $type ) { + $status = Status::newGood(); + + if ( isset( $this->locksHeld[$path][$type] ) ) { + ++$this->locksHeld[$path][$type]; + } elseif ( isset( $this->locksHeld[$path][self::LOCK_EX] ) ) { + $this->locksHeld[$path][$type] = 1; + } else { + wfSuppressWarnings(); + $handle = fopen( $this->getLockPath( $path ), 'a+' ); + wfRestoreWarnings(); + if ( !$handle ) { // lock dir missing? + wfMkdirParents( $this->lockDir ); + $handle = fopen( $this->getLockPath( $path ), 'a+' ); // try again + } + if ( $handle ) { + // Either a shared or exclusive lock + $lock = ( $type == self::LOCK_SH ) ? LOCK_SH : LOCK_EX; + if ( flock( $handle, $lock | LOCK_NB ) ) { + // Record this lock as active + $this->locksHeld[$path][$type] = 1; + $this->handles[$path][$type] = $handle; + } else { + fclose( $handle ); + $status->fatal( 'lockmanager-fail-acquirelock', $path ); + } + } else { + $status->fatal( 'lockmanager-fail-openlock', $path ); + } + } + + return $status; + } + + /** + * Unlock a single resource key + * + * @param $path string + * @param $type integer + * @return Status + */ + protected function doSingleUnlock( $path, $type ) { + $status = Status::newGood(); + + if ( !isset( $this->locksHeld[$path] ) ) { + $status->warning( 'lockmanager-notlocked', $path ); + } elseif ( !isset( $this->locksHeld[$path][$type] ) ) { + $status->warning( 'lockmanager-notlocked', $path ); + } else { + $handlesToClose = array(); + --$this->locksHeld[$path][$type]; + if ( $this->locksHeld[$path][$type] <= 0 ) { + unset( $this->locksHeld[$path][$type] ); + // If a LOCK_SH comes in while we have a LOCK_EX, we don't + // actually add a handler, so check for handler existence. + if ( isset( $this->handles[$path][$type] ) ) { + if ( $type === self::LOCK_EX + && isset( $this->locksHeld[$path][self::LOCK_SH] ) + && !isset( $this->handles[$path][self::LOCK_SH] ) ) + { + // EX lock came first: move this handle to the SH one + $this->handles[$path][self::LOCK_SH] = $this->handles[$path][$type]; + } else { + // Mark this handle to be unlocked and closed + $handlesToClose[] = $this->handles[$path][$type]; + } + unset( $this->handles[$path][$type] ); + } + } + if ( !count( $this->locksHeld[$path] ) ) { + unset( $this->locksHeld[$path] ); // no locks on this path + } + // Unlock handles to release locks and delete + // any lock files that end up with no locks on them... + if ( wfIsWindows() ) { + // Windows: for any process, including this one, + // calling unlink() on a locked file will fail + $status->merge( $this->closeLockHandles( $path, $handlesToClose ) ); + $status->merge( $this->pruneKeyLockFiles( $path ) ); + } else { + // Unix: unlink() can be used on files currently open by this + // process and we must do so in order to avoid race conditions + $status->merge( $this->pruneKeyLockFiles( $path ) ); + $status->merge( $this->closeLockHandles( $path, $handlesToClose ) ); + } + } + + return $status; + } + + /** + * @param $path string + * @param $handlesToClose array + * @return Status + */ + private function closeLockHandles( $path, array $handlesToClose ) { + $status = Status::newGood(); + foreach ( $handlesToClose as $handle ) { + if ( !flock( $handle, LOCK_UN ) ) { + $status->fatal( 'lockmanager-fail-releaselock', $path ); + } + if ( !fclose( $handle ) ) { + $status->warning( 'lockmanager-fail-closelock', $path ); + } + } + return $status; + } + + /** + * @param $path string + * @return Status + */ + private function pruneKeyLockFiles( $path ) { + $status = Status::newGood(); + if ( !isset( $this->locksHeld[$path] ) ) { + # No locks are held for the lock file anymore + if ( !unlink( $this->getLockPath( $path ) ) ) { + $status->warning( 'lockmanager-fail-deletelock', $path ); + } + unset( $this->handles[$path] ); + } + return $status; + } + + /** + * Get the path to the lock file for a key + * @param $path string + * @return string + */ + protected function getLockPath( $path ) { + $hash = self::sha1Base36( $path ); + return "{$this->lockDir}/{$hash}.lock"; + } + + /** + * Make sure remaining locks get cleared for sanity + */ + function __destruct() { + while ( count( $this->locksHeld ) ) { + foreach ( $this->locksHeld as $path => $locks ) { + $this->doSingleUnlock( $path, self::LOCK_EX ); + $this->doSingleUnlock( $path, self::LOCK_SH ); + } + } + } +} diff --git a/includes/filebackend/lockmanager/LSLockManager.php b/includes/filebackend/lockmanager/LSLockManager.php new file mode 100644 index 00000000..89428182 --- /dev/null +++ b/includes/filebackend/lockmanager/LSLockManager.php @@ -0,0 +1,218 @@ +<?php +/** + * Version of LockManager based on using lock daemon servers. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * http://www.gnu.org/copyleft/gpl.html + * + * @file + * @ingroup LockManager + */ + +/** + * Manage locks using a lock daemon server. + * + * Version of LockManager based on using lock daemon servers. + * This is meant for multi-wiki systems that may share files. + * All locks are non-blocking, which avoids deadlocks. + * + * All lock requests for a resource, identified by a hash string, will map + * to one bucket. Each bucket maps to one or several peer servers, each + * running LockServerDaemon.php, listening on a designated TCP port. + * A majority of peers must agree for a lock to be acquired. + * + * @ingroup LockManager + * @since 1.19 + */ +class LSLockManager extends QuorumLockManager { + /** @var Array Mapping of lock types to the type actually used */ + protected $lockTypeMap = array( + self::LOCK_SH => self::LOCK_SH, + self::LOCK_UW => self::LOCK_SH, + self::LOCK_EX => self::LOCK_EX + ); + + /** @var Array Map of server names to server config */ + protected $lockServers; // (server name => server config array) + + /** @var Array Map Server connections (server name => resource) */ + protected $conns = array(); + + protected $connTimeout; // float number of seconds + protected $session = ''; // random SHA-1 string + + /** + * Construct a new instance from configuration. + * + * $config paramaters include: + * - lockServers : Associative array of server names to configuration. + * Configuration is an associative array that includes: + * - host : IP address/hostname + * - port : TCP port + * - authKey : Secret string the lock server uses + * - srvsByBucket : Array of 1-16 consecutive integer keys, starting from 0, + * each having an odd-numbered list of server names (peers) as values. + * - connTimeout : Lock server connection attempt timeout. [optional] + * + * @param Array $config + */ + public function __construct( array $config ) { + parent::__construct( $config ); + + $this->lockServers = $config['lockServers']; + // Sanitize srvsByBucket config to prevent PHP errors + $this->srvsByBucket = array_filter( $config['srvsByBucket'], 'is_array' ); + $this->srvsByBucket = array_values( $this->srvsByBucket ); // consecutive + + if ( isset( $config['connTimeout'] ) ) { + $this->connTimeout = $config['connTimeout']; + } else { + $this->connTimeout = 3; // use some sane amount + } + + $this->session = wfRandomString( 32 ); // 128 bits + } + + /** + * @see QuorumLockManager::getLocksOnServer() + * @return Status + */ + protected function getLocksOnServer( $lockSrv, array $paths, $type ) { + $status = Status::newGood(); + + // Send out the command and get the response... + $type = ( $type == self::LOCK_SH ) ? 'SH' : 'EX'; + $keys = array_unique( array_map( 'LockManager::sha1Base36', $paths ) ); + $response = $this->sendCommand( $lockSrv, 'ACQUIRE', $type, $keys ); + + if ( $response !== 'ACQUIRED' ) { + foreach ( $paths as $path ) { + $status->fatal( 'lockmanager-fail-acquirelock', $path ); + } + } + + return $status; + } + + /** + * @see QuorumLockManager::freeLocksOnServer() + * @return Status + */ + protected function freeLocksOnServer( $lockSrv, array $paths, $type ) { + $status = Status::newGood(); + + // Send out the command and get the response... + $type = ( $type == self::LOCK_SH ) ? 'SH' : 'EX'; + $keys = array_unique( array_map( 'LockManager::sha1Base36', $paths ) ); + $response = $this->sendCommand( $lockSrv, 'RELEASE', $type, $keys ); + + if ( $response !== 'RELEASED' ) { + foreach ( $paths as $path ) { + $status->fatal( 'lockmanager-fail-releaselock', $path ); + } + } + + return $status; + } + + /** + * @see QuorumLockManager::releaseAllLocks() + * @return Status + */ + protected function releaseAllLocks() { + $status = Status::newGood(); + + foreach ( $this->conns as $lockSrv => $conn ) { + $response = $this->sendCommand( $lockSrv, 'RELEASE_ALL', '', array() ); + if ( $response !== 'RELEASED_ALL' ) { + $status->fatal( 'lockmanager-fail-svr-release', $lockSrv ); + } + } + + return $status; + } + + /** + * @see QuorumLockManager::isServerUp() + * @return bool + */ + protected function isServerUp( $lockSrv ) { + return (bool)$this->getConnection( $lockSrv ); + } + + /** + * Send a command and get back the response + * + * @param $lockSrv string + * @param $action string + * @param $type string + * @param $values Array + * @return string|bool + */ + protected function sendCommand( $lockSrv, $action, $type, $values ) { + $conn = $this->getConnection( $lockSrv ); + if ( !$conn ) { + return false; // no connection + } + $authKey = $this->lockServers[$lockSrv]['authKey']; + // Build of the command as a flat string... + $values = implode( '|', $values ); + $key = sha1( $this->session . $action . $type . $values . $authKey ); + // Send out the command... + if ( fwrite( $conn, "{$this->session}:$key:$action:$type:$values\n" ) === false ) { + return false; + } + // Get the response... + $response = fgets( $conn ); + if ( $response === false ) { + return false; + } + return trim( $response ); + } + + /** + * Get (or reuse) a connection to a lock server + * + * @param $lockSrv string + * @return resource + */ + protected function getConnection( $lockSrv ) { + if ( !isset( $this->conns[$lockSrv] ) ) { + $cfg = $this->lockServers[$lockSrv]; + wfSuppressWarnings(); + $errno = $errstr = ''; + $conn = fsockopen( $cfg['host'], $cfg['port'], $errno, $errstr, $this->connTimeout ); + wfRestoreWarnings(); + if ( $conn === false ) { + return null; + } + $sec = floor( $this->connTimeout ); + $usec = floor( ( $this->connTimeout - floor( $this->connTimeout ) ) * 1e6 ); + stream_set_timeout( $conn, $sec, $usec ); + $this->conns[$lockSrv] = $conn; + } + return $this->conns[$lockSrv]; + } + + /** + * Make sure remaining locks get cleared for sanity + */ + function __destruct() { + $this->releaseAllLocks(); + foreach ( $this->conns as $conn ) { + fclose( $conn ); + } + } +} diff --git a/includes/filebackend/lockmanager/LockManager.php b/includes/filebackend/lockmanager/LockManager.php new file mode 100644 index 00000000..07853f87 --- /dev/null +++ b/includes/filebackend/lockmanager/LockManager.php @@ -0,0 +1,425 @@ +<?php +/** + * @defgroup LockManager Lock management + * @ingroup FileBackend + */ + +/** + * Resource locking handling. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * http://www.gnu.org/copyleft/gpl.html + * + * @file + * @ingroup LockManager + * @author Aaron Schulz + */ + +/** + * @brief Class for handling resource locking. + * + * Locks on resource keys can either be shared or exclusive. + * + * Implementations must keep track of what is locked by this proccess + * in-memory and support nested locking calls (using reference counting). + * At least LOCK_UW and LOCK_EX must be implemented. LOCK_SH can be a no-op. + * Locks should either be non-blocking or have low wait timeouts. + * + * Subclasses should avoid throwing exceptions at all costs. + * + * @ingroup LockManager + * @since 1.19 + */ +abstract class LockManager { + /** @var Array Mapping of lock types to the type actually used */ + protected $lockTypeMap = array( + self::LOCK_SH => self::LOCK_SH, + self::LOCK_UW => self::LOCK_EX, // subclasses may use self::LOCK_SH + self::LOCK_EX => self::LOCK_EX + ); + + /** @var Array Map of (resource path => lock type => count) */ + protected $locksHeld = array(); + + /* Lock types; stronger locks have higher values */ + const LOCK_SH = 1; // shared lock (for reads) + const LOCK_UW = 2; // shared lock (for reads used to write elsewhere) + const LOCK_EX = 3; // exclusive lock (for writes) + + /** + * Construct a new instance from configuration + * + * @param $config Array + */ + public function __construct( array $config ) {} + + /** + * Lock the resources at the given abstract paths + * + * @param $paths Array List of resource names + * @param $type integer LockManager::LOCK_* constant + * @return Status + */ + final public function lock( array $paths, $type = self::LOCK_EX ) { + wfProfileIn( __METHOD__ ); + $status = $this->doLock( array_unique( $paths ), $this->lockTypeMap[$type] ); + wfProfileOut( __METHOD__ ); + return $status; + } + + /** + * Unlock the resources at the given abstract paths + * + * @param $paths Array List of storage paths + * @param $type integer LockManager::LOCK_* constant + * @return Status + */ + final public function unlock( array $paths, $type = self::LOCK_EX ) { + wfProfileIn( __METHOD__ ); + $status = $this->doUnlock( array_unique( $paths ), $this->lockTypeMap[$type] ); + wfProfileOut( __METHOD__ ); + return $status; + } + + /** + * Get the base 36 SHA-1 of a string, padded to 31 digits + * + * @param $path string + * @return string + */ + final protected static function sha1Base36( $path ) { + return wfBaseConvert( sha1( $path ), 16, 36, 31 ); + } + + /** + * Lock resources with the given keys and lock type + * + * @param $paths Array List of storage paths + * @param $type integer LockManager::LOCK_* constant + * @return string + */ + abstract protected function doLock( array $paths, $type ); + + /** + * Unlock resources with the given keys and lock type + * + * @param $paths Array List of storage paths + * @param $type integer LockManager::LOCK_* constant + * @return string + */ + abstract protected function doUnlock( array $paths, $type ); +} + +/** + * Self-releasing locks + * + * LockManager helper class to handle scoped locks, which + * release when an object is destroyed or goes out of scope. + * + * @ingroup LockManager + * @since 1.19 + */ +class ScopedLock { + /** @var LockManager */ + protected $manager; + /** @var Status */ + protected $status; + /** @var Array List of resource paths*/ + protected $paths; + + protected $type; // integer lock type + + /** + * @param $manager LockManager + * @param $paths Array List of storage paths + * @param $type integer LockManager::LOCK_* constant + * @param $status Status + */ + protected function __construct( + LockManager $manager, array $paths, $type, Status $status + ) { + $this->manager = $manager; + $this->paths = $paths; + $this->status = $status; + $this->type = $type; + } + + /** + * Get a ScopedLock object representing a lock on resource paths. + * Any locks are released once this object goes out of scope. + * The status object is updated with any errors or warnings. + * + * @param $manager LockManager + * @param $paths Array List of storage paths + * @param $type integer LockManager::LOCK_* constant + * @param $status Status + * @return ScopedLock|null Returns null on failure + */ + public static function factory( + LockManager $manager, array $paths, $type, Status $status + ) { + $lockStatus = $manager->lock( $paths, $type ); + $status->merge( $lockStatus ); + if ( $lockStatus->isOK() ) { + return new self( $manager, $paths, $type, $status ); + } + return null; + } + + function __destruct() { + $wasOk = $this->status->isOK(); + $this->status->merge( $this->manager->unlock( $this->paths, $this->type ) ); + if ( $wasOk ) { + // Make sure status is OK, despite any unlockFiles() fatals + $this->status->setResult( true, $this->status->value ); + } + } +} + +/** + * Version of LockManager that uses a quorum from peer servers for locks. + * The resource space can also be sharded into separate peer groups. + * + * @ingroup LockManager + * @since 1.20 + */ +abstract class QuorumLockManager extends LockManager { + /** @var Array Map of bucket indexes to peer server lists */ + protected $srvsByBucket = array(); // (bucket index => (lsrv1, lsrv2, ...)) + + /** + * @see LockManager::doLock() + * @param $paths array + * @param $type int + * @return Status + */ + final protected function doLock( array $paths, $type ) { + $status = Status::newGood(); + + $pathsToLock = array(); // (bucket => paths) + // Get locks that need to be acquired (buckets => locks)... + foreach ( $paths as $path ) { + if ( isset( $this->locksHeld[$path][$type] ) ) { + ++$this->locksHeld[$path][$type]; + } elseif ( isset( $this->locksHeld[$path][self::LOCK_EX] ) ) { + $this->locksHeld[$path][$type] = 1; + } else { + $bucket = $this->getBucketFromKey( $path ); + $pathsToLock[$bucket][] = $path; + } + } + + $lockedPaths = array(); // files locked in this attempt + // Attempt to acquire these locks... + foreach ( $pathsToLock as $bucket => $paths ) { + // Try to acquire the locks for this bucket + $status->merge( $this->doLockingRequestBucket( $bucket, $paths, $type ) ); + if ( !$status->isOK() ) { + $status->merge( $this->doUnlock( $lockedPaths, $type ) ); + return $status; + } + // Record these locks as active + foreach ( $paths as $path ) { + $this->locksHeld[$path][$type] = 1; // locked + } + // Keep track of what locks were made in this attempt + $lockedPaths = array_merge( $lockedPaths, $paths ); + } + + return $status; + } + + /** + * @see LockManager::doUnlock() + * @param $paths array + * @param $type int + * @return Status + */ + final protected function doUnlock( array $paths, $type ) { + $status = Status::newGood(); + + $pathsToUnlock = array(); + foreach ( $paths as $path ) { + if ( !isset( $this->locksHeld[$path][$type] ) ) { + $status->warning( 'lockmanager-notlocked', $path ); + } else { + --$this->locksHeld[$path][$type]; + // Reference count the locks held and release locks when zero + if ( $this->locksHeld[$path][$type] <= 0 ) { + unset( $this->locksHeld[$path][$type] ); + $bucket = $this->getBucketFromKey( $path ); + $pathsToUnlock[$bucket][] = $path; + } + if ( !count( $this->locksHeld[$path] ) ) { + unset( $this->locksHeld[$path] ); // no SH or EX locks left for key + } + } + } + + // Remove these specific locks if possible, or at least release + // all locks once this process is currently not holding any locks. + foreach ( $pathsToUnlock as $bucket => $paths ) { + $status->merge( $this->doUnlockingRequestBucket( $bucket, $paths, $type ) ); + } + if ( !count( $this->locksHeld ) ) { + $status->merge( $this->releaseAllLocks() ); + } + + return $status; + } + + /** + * Attempt to acquire locks with the peers for a bucket. + * This is all or nothing; if any key is locked then this totally fails. + * + * @param $bucket integer + * @param $paths Array List of resource keys to lock + * @param $type integer LockManager::LOCK_EX or LockManager::LOCK_SH + * @return Status + */ + final protected function doLockingRequestBucket( $bucket, array $paths, $type ) { + $status = Status::newGood(); + + $yesVotes = 0; // locks made on trustable servers + $votesLeft = count( $this->srvsByBucket[$bucket] ); // remaining peers + $quorum = floor( $votesLeft/2 + 1 ); // simple majority + // Get votes for each peer, in order, until we have enough... + foreach ( $this->srvsByBucket[$bucket] as $lockSrv ) { + if ( !$this->isServerUp( $lockSrv ) ) { + --$votesLeft; + $status->warning( 'lockmanager-fail-svr-acquire', $lockSrv ); + continue; // server down? + } + // Attempt to acquire the lock on this peer + $status->merge( $this->getLocksOnServer( $lockSrv, $paths, $type ) ); + if ( !$status->isOK() ) { + return $status; // vetoed; resource locked + } + ++$yesVotes; // success for this peer + if ( $yesVotes >= $quorum ) { + return $status; // lock obtained + } + --$votesLeft; + $votesNeeded = $quorum - $yesVotes; + if ( $votesNeeded > $votesLeft ) { + break; // short-circuit + } + } + // At this point, we must not have met the quorum + $status->setResult( false ); + + return $status; + } + + /** + * Attempt to release locks with the peers for a bucket + * + * @param $bucket integer + * @param $paths Array List of resource keys to lock + * @param $type integer LockManager::LOCK_EX or LockManager::LOCK_SH + * @return Status + */ + final protected function doUnlockingRequestBucket( $bucket, array $paths, $type ) { + $status = Status::newGood(); + + foreach ( $this->srvsByBucket[$bucket] as $lockSrv ) { + if ( !$this->isServerUp( $lockSrv ) ) { + $status->fatal( 'lockmanager-fail-svr-release', $lockSrv ); + // Attempt to release the lock on this peer + } else { + $status->merge( $this->freeLocksOnServer( $lockSrv, $paths, $type ) ); + } + } + + return $status; + } + + /** + * Get the bucket for resource path. + * This should avoid throwing any exceptions. + * + * @param $path string + * @return integer + */ + protected function getBucketFromKey( $path ) { + $prefix = substr( sha1( $path ), 0, 2 ); // first 2 hex chars (8 bits) + return (int)base_convert( $prefix, 16, 10 ) % count( $this->srvsByBucket ); + } + + /** + * Check if a lock server is up + * + * @param $lockSrv string + * @return bool + */ + abstract protected function isServerUp( $lockSrv ); + + /** + * Get a connection to a lock server and acquire locks on $paths + * + * @param $lockSrv string + * @param $paths array + * @param $type integer + * @return Status + */ + abstract protected function getLocksOnServer( $lockSrv, array $paths, $type ); + + /** + * Get a connection to a lock server and release locks on $paths. + * + * Subclasses must effectively implement this or releaseAllLocks(). + * + * @param $lockSrv string + * @param $paths array + * @param $type integer + * @return Status + */ + abstract protected function freeLocksOnServer( $lockSrv, array $paths, $type ); + + /** + * Release all locks that this session is holding. + * + * Subclasses must effectively implement this or freeLocksOnServer(). + * + * @return Status + */ + abstract protected function releaseAllLocks(); +} + +/** + * Simple version of LockManager that does nothing + * @since 1.19 + */ +class NullLockManager extends LockManager { + /** + * @see LockManager::doLock() + * @param $paths array + * @param $type int + * @return Status + */ + protected function doLock( array $paths, $type ) { + return Status::newGood(); + } + + /** + * @see LockManager::doUnlock() + * @param $paths array + * @param $type int + * @return Status + */ + protected function doUnlock( array $paths, $type ) { + return Status::newGood(); + } +} diff --git a/includes/filebackend/lockmanager/LockManagerGroup.php b/includes/filebackend/lockmanager/LockManagerGroup.php new file mode 100644 index 00000000..8c8c940a --- /dev/null +++ b/includes/filebackend/lockmanager/LockManagerGroup.php @@ -0,0 +1,143 @@ +<?php +/** + * Lock manager registration handling. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * http://www.gnu.org/copyleft/gpl.html + * + * @file + * @ingroup LockManager + */ + +/** + * Class to handle file lock manager registration + * + * @ingroup LockManager + * @author Aaron Schulz + * @since 1.19 + */ +class LockManagerGroup { + /** + * @var LockManagerGroup + */ + protected static $instance = null; + + /** @var Array of (name => ('class' =>, 'config' =>, 'instance' =>)) */ + protected $managers = array(); + + protected function __construct() {} + + /** + * @return LockManagerGroup + */ + public static function singleton() { + if ( self::$instance == null ) { + self::$instance = new self(); + self::$instance->initFromGlobals(); + } + return self::$instance; + } + + /** + * Destroy the singleton instance, so that a new one will be created next + * time singleton() is called. + */ + public static function destroySingleton() { + self::$instance = null; + } + + /** + * Register lock managers from the global variables + * + * @return void + */ + protected function initFromGlobals() { + global $wgLockManagers; + + $this->register( $wgLockManagers ); + } + + /** + * Register an array of file lock manager configurations + * + * @param $configs Array + * @return void + * @throws MWException + */ + protected function register( array $configs ) { + foreach ( $configs as $config ) { + if ( !isset( $config['name'] ) ) { + throw new MWException( "Cannot register a lock manager with no name." ); + } + $name = $config['name']; + if ( !isset( $config['class'] ) ) { + throw new MWException( "Cannot register lock manager `{$name}` with no class." ); + } + $class = $config['class']; + unset( $config['class'] ); // lock manager won't need this + $this->managers[$name] = array( + 'class' => $class, + 'config' => $config, + 'instance' => null + ); + } + } + + /** + * Get the lock manager object with a given name + * + * @param $name string + * @return LockManager + * @throws MWException + */ + public function get( $name ) { + if ( !isset( $this->managers[$name] ) ) { + throw new MWException( "No lock manager defined with the name `$name`." ); + } + // Lazy-load the actual lock manager instance + if ( !isset( $this->managers[$name]['instance'] ) ) { + $class = $this->managers[$name]['class']; + $config = $this->managers[$name]['config']; + $this->managers[$name]['instance'] = new $class( $config ); + } + return $this->managers[$name]['instance']; + } + + /** + * Get the default lock manager configured for the site. + * Returns NullLockManager if no lock manager could be found. + * + * @return LockManager + */ + public function getDefault() { + return isset( $this->managers['default'] ) + ? $this->get( 'default' ) + : new NullLockManager( array() ); + } + + /** + * Get the default lock manager configured for the site + * or at least some other effective configured lock manager. + * Throws an exception if no lock manager could be found. + * + * @return LockManager + * @throws MWException + */ + public function getAny() { + return isset( $this->managers['default'] ) + ? $this->get( 'default' ) + : $this->get( 'fsLockManager' ); + } +} diff --git a/includes/filebackend/lockmanager/MemcLockManager.php b/includes/filebackend/lockmanager/MemcLockManager.php new file mode 100644 index 00000000..57c0463d --- /dev/null +++ b/includes/filebackend/lockmanager/MemcLockManager.php @@ -0,0 +1,319 @@ +<?php +/** + * Version of LockManager based on using memcached servers. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * http://www.gnu.org/copyleft/gpl.html + * + * @file + * @ingroup LockManager + */ + +/** + * Manage locks using memcached servers. + * + * Version of LockManager based on using memcached servers. + * This is meant for multi-wiki systems that may share files. + * All locks are non-blocking, which avoids deadlocks. + * + * All lock requests for a resource, identified by a hash string, will map + * to one bucket. Each bucket maps to one or several peer servers, each running memcached. + * A majority of peers must agree for a lock to be acquired. + * + * @ingroup LockManager + * @since 1.20 + */ +class MemcLockManager extends QuorumLockManager { + /** @var Array Mapping of lock types to the type actually used */ + protected $lockTypeMap = array( + self::LOCK_SH => self::LOCK_SH, + self::LOCK_UW => self::LOCK_SH, + self::LOCK_EX => self::LOCK_EX + ); + + /** @var Array Map server names to MemcachedBagOStuff objects */ + protected $bagOStuffs = array(); + /** @var Array */ + protected $serversUp = array(); // (server name => bool) + + protected $lockExpiry; // integer; maximum time locks can be held + protected $session = ''; // string; random SHA-1 UUID + protected $wikiId = ''; // string + + /** + * Construct a new instance from configuration. + * + * $config paramaters include: + * - lockServers : Associative array of server names to "<IP>:<port>" strings. + * - srvsByBucket : Array of 1-16 consecutive integer keys, starting from 0, + * each having an odd-numbered list of server names (peers) as values. + * - memcConfig : Configuration array for ObjectCache::newFromParams. [optional] + * If set, this must use one of the memcached classes. + * - wikiId : Wiki ID string that all resources are relative to. [optional] + * + * @param Array $config + */ + public function __construct( array $config ) { + parent::__construct( $config ); + + // Sanitize srvsByBucket config to prevent PHP errors + $this->srvsByBucket = array_filter( $config['srvsByBucket'], 'is_array' ); + $this->srvsByBucket = array_values( $this->srvsByBucket ); // consecutive + + $memcConfig = isset( $config['memcConfig'] ) + ? $config['memcConfig'] + : array( 'class' => 'MemcachedPhpBagOStuff' ); + + foreach ( $config['lockServers'] as $name => $address ) { + $params = array( 'servers' => array( $address ) ) + $memcConfig; + $cache = ObjectCache::newFromParams( $params ); + if ( $cache instanceof MemcachedBagOStuff ) { + $this->bagOStuffs[$name] = $cache; + } else { + throw new MWException( + 'Only MemcachedBagOStuff classes are supported by MemcLockManager.' ); + } + } + + $this->wikiId = isset( $config['wikiId'] ) ? $config['wikiId'] : wfWikiID(); + + $met = ini_get( 'max_execution_time' ); // this is 0 in CLI mode + $this->lockExpiry = $met ? 2*(int)$met : 2*3600; + + $this->session = wfRandomString( 32 ); + } + + /** + * @see QuorumLockManager::getLocksOnServer() + * @return Status + */ + protected function getLocksOnServer( $lockSrv, array $paths, $type ) { + $status = Status::newGood(); + + $memc = $this->getCache( $lockSrv ); + $keys = array_map( array( $this, 'recordKeyForPath' ), $paths ); // lock records + + // Lock all of the active lock record keys... + if ( !$this->acquireMutexes( $memc, $keys ) ) { + foreach ( $paths as $path ) { + $status->fatal( 'lockmanager-fail-acquirelock', $path ); + } + return; + } + + // Fetch all the existing lock records... + $lockRecords = $memc->getMulti( $keys ); + + $now = time(); + // Check if the requested locks conflict with existing ones... + foreach ( $paths as $path ) { + $locksKey = $this->recordKeyForPath( $path ); + $locksHeld = isset( $lockRecords[$locksKey] ) + ? $lockRecords[$locksKey] + : array( self::LOCK_SH => array(), self::LOCK_EX => array() ); // init + foreach ( $locksHeld[self::LOCK_EX] as $session => $expiry ) { + if ( $expiry < $now ) { // stale? + unset( $locksHeld[self::LOCK_EX][$session] ); + } elseif ( $session !== $this->session ) { + $status->fatal( 'lockmanager-fail-acquirelock', $path ); + } + } + if ( $type === self::LOCK_EX ) { + foreach ( $locksHeld[self::LOCK_SH] as $session => $expiry ) { + if ( $expiry < $now ) { // stale? + unset( $locksHeld[self::LOCK_SH][$session] ); + } elseif ( $session !== $this->session ) { + $status->fatal( 'lockmanager-fail-acquirelock', $path ); + } + } + } + if ( $status->isOK() ) { + // Register the session in the lock record array + $locksHeld[$type][$this->session] = $now + $this->lockExpiry; + // We will update this record if none of the other locks conflict + $lockRecords[$locksKey] = $locksHeld; + } + } + + // If there were no lock conflicts, update all the lock records... + if ( $status->isOK() ) { + foreach ( $lockRecords as $locksKey => $locksHeld ) { + $memc->set( $locksKey, $locksHeld ); + wfDebug( __METHOD__ . ": acquired lock on key $locksKey.\n" ); + } + } + + // Unlock all of the active lock record keys... + $this->releaseMutexes( $memc, $keys ); + + return $status; + } + + /** + * @see QuorumLockManager::freeLocksOnServer() + * @return Status + */ + protected function freeLocksOnServer( $lockSrv, array $paths, $type ) { + $status = Status::newGood(); + + $memc = $this->getCache( $lockSrv ); + $keys = array_map( array( $this, 'recordKeyForPath' ), $paths ); // lock records + + // Lock all of the active lock record keys... + if ( !$this->acquireMutexes( $memc, $keys ) ) { + foreach ( $paths as $path ) { + $status->fatal( 'lockmanager-fail-releaselock', $path ); + } + return; + } + + // Fetch all the existing lock records... + $lockRecords = $memc->getMulti( $keys ); + + // Remove the requested locks from all records... + foreach ( $paths as $path ) { + $locksKey = $this->recordKeyForPath( $path ); // lock record + if ( !isset( $lockRecords[$locksKey] ) ) { + continue; // nothing to do + } + $locksHeld = $lockRecords[$locksKey]; + if ( is_array( $locksHeld ) && isset( $locksHeld[$type] ) ) { + unset( $locksHeld[$type][$this->session] ); + $ok = $memc->set( $locksKey, $locksHeld ); + } else { + $ok = true; + } + if ( !$ok ) { + $status->fatal( 'lockmanager-fail-releaselock', $path ); + } + wfDebug( __METHOD__ . ": released lock on key $locksKey.\n" ); + } + + // Unlock all of the active lock record keys... + $this->releaseMutexes( $memc, $keys ); + + return $status; + } + + /** + * @see QuorumLockManager::releaseAllLocks() + * @return Status + */ + protected function releaseAllLocks() { + return Status::newGood(); // not supported + } + + /** + * @see QuorumLockManager::isServerUp() + * @return bool + */ + protected function isServerUp( $lockSrv ) { + return (bool)$this->getCache( $lockSrv ); + } + + /** + * Get the MemcachedBagOStuff object for a $lockSrv + * + * @param $lockSrv string Server name + * @return MemcachedBagOStuff|null + */ + protected function getCache( $lockSrv ) { + $memc = null; + if ( isset( $this->bagOStuffs[$lockSrv] ) ) { + $memc = $this->bagOStuffs[$lockSrv]; + if ( !isset( $this->serversUp[$lockSrv] ) ) { + $this->serversUp[$lockSrv] = $memc->set( 'MemcLockManager:ping', 1, 1 ); + if ( !$this->serversUp[$lockSrv] ) { + trigger_error( __METHOD__ . ": Could not contact $lockSrv.", E_USER_WARNING ); + } + } + if ( !$this->serversUp[$lockSrv] ) { + return null; // server appears to be down + } + } + return $memc; + } + + /** + * @param $path string + * @return string + */ + protected function recordKeyForPath( $path ) { + $hash = LockManager::sha1Base36( $path ); + list( $db, $prefix ) = wfSplitWikiID( $this->wikiId ); + return wfForeignMemcKey( $db, $prefix, __CLASS__, 'locks', $hash ); + } + + /** + * @param $memc MemcachedBagOStuff + * @param $keys Array List of keys to acquire + * @return bool + */ + protected function acquireMutexes( MemcachedBagOStuff $memc, array $keys ) { + $lockedKeys = array(); + + // Acquire the keys in lexicographical order, to avoid deadlock problems. + // If P1 is waiting to acquire a key P2 has, P2 can't also be waiting for a key P1 has. + sort( $keys ); + + // Try to quickly loop to acquire the keys, but back off after a few rounds. + // This reduces memcached spam, especially in the rare case where a server acquires + // some lock keys and dies without releasing them. Lock keys expire after a few minutes. + $rounds = 0; + $start = microtime( true ); + do { + if ( ( ++$rounds % 4 ) == 0 ) { + usleep( 1000*50 ); // 50 ms + } + foreach ( array_diff( $keys, $lockedKeys ) as $key ) { + if ( $memc->add( "$key:mutex", 1, 180 ) ) { // lock record + $lockedKeys[] = $key; + } else { + continue; // acquire in order + } + } + } while ( count( $lockedKeys ) < count( $keys ) && ( microtime( true ) - $start ) <= 6 ); + + if ( count( $lockedKeys ) != count( $keys ) ) { + $this->releaseMutexes( $lockedKeys ); // failed; release what was locked + return false; + } + + return true; + } + + /** + * @param $memc MemcachedBagOStuff + * @param $keys Array List of acquired keys + * @return void + */ + protected function releaseMutexes( MemcachedBagOStuff $memc, array $keys ) { + foreach ( $keys as $key ) { + $memc->delete( "$key:mutex" ); + } + } + + /** + * Make sure remaining locks get cleared for sanity + */ + function __destruct() { + while ( count( $this->locksHeld ) ) { + foreach ( $this->locksHeld as $path => $locks ) { + $this->doUnlock( array( $path ), self::LOCK_EX ); + $this->doUnlock( array( $path ), self::LOCK_SH ); + } + } + } +} |