diff options
Diffstat (limited to 'includes/filebackend/lockmanager')
-rw-r--r-- | includes/filebackend/lockmanager/DBLockManager.php | 214 | ||||
-rw-r--r-- | includes/filebackend/lockmanager/FSLockManager.php | 42 | ||||
-rw-r--r-- | includes/filebackend/lockmanager/LSLockManager.php | 8 | ||||
-rw-r--r-- | includes/filebackend/lockmanager/LockManager.php | 385 | ||||
-rw-r--r-- | includes/filebackend/lockmanager/LockManagerGroup.php | 58 | ||||
-rw-r--r-- | includes/filebackend/lockmanager/MemcLockManager.php | 131 | ||||
-rw-r--r-- | includes/filebackend/lockmanager/QuorumLockManager.php | 246 | ||||
-rw-r--r-- | includes/filebackend/lockmanager/RedisLockManager.php | 288 | ||||
-rw-r--r-- | includes/filebackend/lockmanager/ScopedLock.php | 104 |
9 files changed, 1039 insertions, 437 deletions
diff --git a/includes/filebackend/lockmanager/DBLockManager.php b/includes/filebackend/lockmanager/DBLockManager.php index a8fe258b..3e934ba5 100644 --- a/includes/filebackend/lockmanager/DBLockManager.php +++ b/includes/filebackend/lockmanager/DBLockManager.php @@ -22,10 +22,9 @@ */ /** - * Version of LockManager based on using DB table locks. + * Version of LockManager based on using named/row DB 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 @@ -37,7 +36,7 @@ * @ingroup LockManager * @since 1.19 */ -class DBLockManager extends QuorumLockManager { +abstract class DBLockManager extends QuorumLockManager { /** @var Array Map of DB names to server config */ protected $dbServers; // (DB name => server config array) /** @var BagOStuff */ @@ -67,11 +66,12 @@ class DBLockManager extends QuorumLockManager { * 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). + * Only use 'localDBMaster' if the domain is a valid wiki ID. * - 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 + * @param array $config */ public function __construct( array $config ) { parent::__construct( $config ); @@ -110,63 +110,17 @@ class DBLockManager extends QuorumLockManager { $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 ) { + // @TODO: change this code to work in one batch + protected function getLocksOnServer( $lockSrv, array $pathsByType ) { $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 ); - } - } + foreach ( $pathsByType as $type => $paths ) { + $status->merge( $this->doGetLocksOnServer( $lockSrv, $paths, $type ) ); } - 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; + protected function freeLocksOnServer( $lockSrv, array $pathsByType ) { + return Status::newGood(); } /** @@ -197,8 +151,8 @@ class DBLockManager extends QuorumLockManager { if ( !isset( $this->conns[$lockDb] ) ) { $db = null; if ( $lockDb === 'localDBMaster' ) { - $lb = wfGetLBFactory()->newMainLB(); - $db = $lb->getConnection( DB_MASTER ); + $lb = wfGetLBFactory()->getMainLB( $this->domain ); + $db = $lb->getConnection( DB_MASTER, array(), $this->domain ); } elseif ( isset( $this->dbServers[$lockDb] ) ) { $config = $this->dbServers[$lockDb]; $db = DatabaseBase::factory( $config['type'], $config ); @@ -274,14 +228,8 @@ class DBLockManager extends QuorumLockManager { * Make sure remaining locks get cleared for sanity */ function __destruct() { + $this->releaseAllLocks(); 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(); } } @@ -317,31 +265,42 @@ class MySqlLockManager extends DBLockManager { * @see DBLockManager::getLocksOnServer() * @return Status */ - protected function getLocksOnServer( $lockSrv, array $paths, $type ) { + protected function doGetLocksOnServer( $lockSrv, array $paths, $type ) { $status = Status::newGood(); $db = $this->getConnection( $lockSrv ); // checked in isServerUp() - $keys = array_unique( array_map( 'LockManager::sha1Base36', $paths ) ); + + $keys = array(); // list of hash keys for the paths + $data = array(); // list of rows to insert + $checkEXKeys = array(); // list of hash keys that this has no EX lock on # Build up values for INSERT clause - $data = array(); - foreach ( $keys as $key ) { + foreach ( $paths as $path ) { + $key = $this->sha1Base36Absolute( $path ); + $keys[] = $key; $data[] = array( 'fls_key' => $key, 'fls_session' => $this->session ); + if ( !isset( $this->locksHeld[$path][self::LOCK_EX] ) ) { + $checkEXKeys[] = $key; + } } - # Block new writers... + + # Block new writers (both EX and SH locks leave entries here)... $db->insert( 'filelocks_shared', $data, __METHOD__, array( 'IGNORE' ) ); # Actually do the locking queries... if ( $type == self::LOCK_SH ) { // reader locks + $blocked = false; # 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. + if ( count( $checkEXKeys ) ) { + $blocked = $db->selectField( 'filelocks_exclusive', '1', + array( 'fle_key' => $checkEXKeys ), + __METHOD__ + ); + } + # Other prospective writers that haven't yet updated filelocks_exclusive + # will recheck filelocks_shared after doing so and bail due to this 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. + # This 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" ), @@ -371,4 +330,103 @@ class MySqlLockManager extends DBLockManager { return $status; } + + /** + * @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; + } +} + +/** + * PostgreSQL version of DBLockManager that supports shared locks. + * All locks are non-blocking, which avoids deadlocks. + * + * @ingroup LockManager + */ +class PostgreSqlLockManager 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 + ); + + protected function doGetLocksOnServer( $lockSrv, array $paths, $type ) { + $status = Status::newGood(); + if ( !count( $paths ) ) { + return $status; // nothing to lock + } + + $db = $this->getConnection( $lockSrv ); // checked in isServerUp() + $bigints = array_unique( array_map( + function( $key ) { + return wfBaseConvert( substr( $key, 0, 15 ), 16, 10 ); + }, + array_map( array( $this, 'sha1Base16Absolute' ), $paths ) + ) ); + + // Try to acquire all the locks... + $fields = array(); + foreach ( $bigints as $bigint ) { + $fields[] = ( $type == self::LOCK_SH ) + ? "pg_try_advisory_lock_shared({$db->addQuotes( $bigint )}) AS K$bigint" + : "pg_try_advisory_lock({$db->addQuotes( $bigint )}) AS K$bigint"; + } + $res = $db->query( 'SELECT ' . implode( ', ', $fields ), __METHOD__ ); + $row = (array)$res->fetchObject(); + + if ( in_array( 'f', $row ) ) { + // Release any acquired locks if some could not be acquired... + $fields = array(); + foreach ( $row as $kbigint => $ok ) { + if ( $ok === 't' ) { // locked + $bigint = substr( $kbigint, 1 ); // strip off the "K" + $fields[] = ( $type == self::LOCK_SH ) + ? "pg_advisory_unlock_shared({$db->addQuotes( $bigint )})" + : "pg_advisory_unlock({$db->addQuotes( $bigint )})"; + } + } + if ( count( $fields ) ) { + $db->query( 'SELECT ' . implode( ', ', $fields ), __METHOD__ ); + } + foreach ( $paths as $path ) { + $status->fatal( 'lockmanager-fail-acquirelock', $path ); + } + } + + return $status; + } + + /** + * @see QuorumLockManager::releaseAllLocks() + * @return Status + */ + protected function releaseAllLocks() { + $status = Status::newGood(); + + foreach ( $this->conns as $lockDb => $db ) { + try { + $db->query( "SELECT pg_advisory_unlock_all()", __METHOD__ ); + } catch ( DBError $e ) { + $status->fatal( 'lockmanager-fail-db-release', $lockDb ); + } + } + + return $status; + } } diff --git a/includes/filebackend/lockmanager/FSLockManager.php b/includes/filebackend/lockmanager/FSLockManager.php index 9a6206fd..eacba704 100644 --- a/includes/filebackend/lockmanager/FSLockManager.php +++ b/includes/filebackend/lockmanager/FSLockManager.php @@ -43,7 +43,7 @@ class FSLockManager extends LockManager { protected $lockDir; // global dir for all servers - /** @var Array Map of (locked key => lock type => lock file handle) */ + /** @var Array Map of (locked key => lock file handle) */ protected $handles = array(); /** @@ -115,12 +115,16 @@ class FSLockManager extends LockManager { } 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 ( isset( $this->handles[$path] ) ) { + $handle = $this->handles[$path]; + } 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 @@ -128,7 +132,7 @@ class FSLockManager extends LockManager { if ( flock( $handle, $lock | LOCK_NB ) ) { // Record this lock as active $this->locksHeld[$path][$type] = 1; - $this->handles[$path][$type] = $handle; + $this->handles[$path] = $handle; } else { fclose( $handle ); $status->fatal( 'lockmanager-fail-acquirelock', $path ); @@ -160,24 +164,13 @@ class FSLockManager extends LockManager { --$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 + if ( isset( $this->handles[$path] ) ) { + $handlesToClose[] = $this->handles[$path]; + unset( $this->handles[$path] ); + } } // Unlock handles to release locks and delete // any lock files that end up with no locks on them... @@ -237,8 +230,7 @@ class FSLockManager extends LockManager { * @return string */ protected function getLockPath( $path ) { - $hash = self::sha1Base36( $path ); - return "{$this->lockDir}/{$hash}.lock"; + return "{$this->lockDir}/{$this->sha1Base36Absolute( $path )}.lock"; } /** diff --git a/includes/filebackend/lockmanager/LSLockManager.php b/includes/filebackend/lockmanager/LSLockManager.php index 89428182..97de8dca 100644 --- a/includes/filebackend/lockmanager/LSLockManager.php +++ b/includes/filebackend/lockmanager/LSLockManager.php @@ -66,7 +66,7 @@ class LSLockManager extends QuorumLockManager { * each having an odd-numbered list of server names (peers) as values. * - connTimeout : Lock server connection attempt timeout. [optional] * - * @param Array $config + * @param array $config */ public function __construct( array $config ) { parent::__construct( $config ); @@ -94,7 +94,7 @@ class LSLockManager extends QuorumLockManager { // Send out the command and get the response... $type = ( $type == self::LOCK_SH ) ? 'SH' : 'EX'; - $keys = array_unique( array_map( 'LockManager::sha1Base36', $paths ) ); + $keys = array_unique( array_map( array( $this, 'sha1Base36Absolute' ), $paths ) ); $response = $this->sendCommand( $lockSrv, 'ACQUIRE', $type, $keys ); if ( $response !== 'ACQUIRED' ) { @@ -115,7 +115,7 @@ class LSLockManager extends QuorumLockManager { // Send out the command and get the response... $type = ( $type == self::LOCK_SH ) ? 'SH' : 'EX'; - $keys = array_unique( array_map( 'LockManager::sha1Base36', $paths ) ); + $keys = array_unique( array_map( array( $this, 'sha1Base36Absolute' ), $paths ) ); $response = $this->sendCommand( $lockSrv, 'RELEASE', $type, $keys ); if ( $response !== 'RELEASED' ) { @@ -169,7 +169,7 @@ class LSLockManager extends QuorumLockManager { $authKey = $this->lockServers[$lockSrv]['authKey']; // Build of the command as a flat string... $values = implode( '|', $values ); - $key = sha1( $this->session . $action . $type . $values . $authKey ); + $key = hash_hmac( 'sha1', "{$this->session}\n{$action}\n{$type}\n{$values}", $authKey ); // Send out the command... if ( fwrite( $conn, "{$this->session}:$key:$action:$type:$values\n" ) === false ) { return false; diff --git a/includes/filebackend/lockmanager/LockManager.php b/includes/filebackend/lockmanager/LockManager.php index 07853f87..dad8a624 100644 --- a/includes/filebackend/lockmanager/LockManager.php +++ b/includes/filebackend/lockmanager/LockManager.php @@ -53,7 +53,10 @@ abstract class LockManager { /** @var Array Map of (resource path => lock type => count) */ protected $locksHeld = array(); - /* Lock types; stronger locks have higher values */ + protected $domain; // string; domain (usually wiki ID) + protected $lockTTL; // integer; maximum time locks can be held + + /** 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) @@ -61,341 +64,185 @@ abstract class LockManager { /** * Construct a new instance from configuration * + * $config paramaters include: + * - domain : Domain (usually wiki ID) that all resources are relative to [optional] + * - lockTTL : Age (in seconds) at which resource locks should expire. + * This only applies if locks are not tied to a connection/process. + * * @param $config Array */ - public function __construct( array $config ) {} + public function __construct( array $config ) { + $this->domain = isset( $config['domain'] ) ? $config['domain'] : wfWikiID(); + if ( isset( $config['lockTTL'] ) ) { + $this->lockTTL = max( 1, $config['lockTTL'] ); + } elseif ( PHP_SAPI === 'cli' ) { + $this->lockTTL = 2 * 3600; + } else { + $met = ini_get( 'max_execution_time' ); // this is 0 in CLI mode + $this->lockTTL = max( 5 * 60, 2 * (int)$met ); + } + } /** * Lock the resources at the given abstract paths * - * @param $paths Array List of resource names + * @param array $paths List of resource names * @param $type integer LockManager::LOCK_* constant + * @param integer $timeout Timeout in seconds (0 means non-blocking) (since 1.21) * @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; + final public function lock( array $paths, $type = self::LOCK_EX, $timeout = 0 ) { + return $this->lockByType( array( $type => $paths ), $timeout ); } /** - * Unlock the resources at the given abstract paths + * Lock the resources at the given abstract paths * - * @param $paths Array List of storage paths - * @param $type integer LockManager::LOCK_* constant + * @param array $pathsByType Map of LockManager::LOCK_* constants to lists of paths + * @param integer $timeout Timeout in seconds (0 means non-blocking) (since 1.21) * @return Status + * @since 1.22 */ - final public function unlock( array $paths, $type = self::LOCK_EX ) { + final public function lockByType( array $pathsByType, $timeout = 0 ) { wfProfileIn( __METHOD__ ); - $status = $this->doUnlock( array_unique( $paths ), $this->lockTypeMap[$type] ); + $status = Status::newGood(); + $pathsByType = $this->normalizePathsByType( $pathsByType ); + $msleep = array( 0, 50, 100, 300, 500 ); // retry backoff times + $start = microtime( true ); + do { + $status = $this->doLockByType( $pathsByType ); + $elapsed = microtime( true ) - $start; + if ( $status->isOK() || $elapsed >= $timeout || $elapsed < 0 ) { + break; // success, timeout, or clock set back + } + usleep( 1e3 * ( next( $msleep ) ?: 1000 ) ); // use 1 sec after enough times + $elapsed = microtime( true ) - $start; + } while ( $elapsed < $timeout && $elapsed >= 0 ); wfProfileOut( __METHOD__ ); return $status; } /** - * Get the base 36 SHA-1 of a string, padded to 31 digits + * Unlock the resources at the given abstract paths * - * @param $path string - * @return string + * @param array $paths List of paths + * @param $type integer LockManager::LOCK_* constant + * @return Status */ - final protected static function sha1Base36( $path ) { - return wfBaseConvert( sha1( $path ), 16, 36, 31 ); + final public function unlock( array $paths, $type = self::LOCK_EX ) { + return $this->unlockByType( array( $type => $paths ) ); } /** - * Lock resources with the given keys and lock type + * Unlock the resources at the given abstract paths * - * @param $paths Array List of storage paths - * @param $type integer LockManager::LOCK_* constant - * @return string + * @param array $pathsByType Map of LockManager::LOCK_* constants to lists of paths + * @return Status + * @since 1.22 */ - abstract protected function doLock( array $paths, $type ); + final public function unlockByType( array $pathsByType ) { + wfProfileIn( __METHOD__ ); + $pathsByType = $this->normalizePathsByType( $pathsByType ); + $status = $this->doUnlockByType( $pathsByType ); + wfProfileOut( __METHOD__ ); + return $status; + } /** - * Unlock resources with the given keys and lock type + * Get the base 36 SHA-1 of a string, padded to 31 digits. + * Before hashing, the path will be prefixed with the domain ID. + * This should be used interally for lock key or file names. * - * @param $paths Array List of storage paths - * @param $type integer LockManager::LOCK_* constant + * @param $path string * @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; + final protected function sha1Base36Absolute( $path ) { + return wfBaseConvert( sha1( "{$this->domain}:{$path}" ), 16, 36, 31 ); } /** - * 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. + * Get the base 16 SHA-1 of a string, padded to 31 digits. + * Before hashing, the path will be prefixed with the domain ID. + * This should be used interally for lock key or file names. * - * @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 + * @param $path string + * @return string */ - 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 ); - } + final protected function sha1Base16Absolute( $path ) { + return sha1( "{$this->domain}:{$path}" ); } -} - -/** - * 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 + * Normalize the $paths array by converting LOCK_UW locks into the + * appropriate type and removing any duplicated paths for each lock type. + * + * @param array $paths Map of LockManager::LOCK_* constants to lists of paths + * @return Array + * @since 1.22 */ - 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 ); + final protected function normalizePathsByType( array $pathsByType ) { + $res = array(); + foreach ( $pathsByType as $type => $paths ) { + $res[$this->lockTypeMap[$type]] = array_unique( $paths ); } - - return $status; + return $res; } /** - * @see LockManager::doUnlock() - * @param $paths array - * @param $type int + * @see LockManager::lockByType() + * @param array $paths Map of LockManager::LOCK_* constants to lists of paths * @return Status + * @since 1.22 */ - final protected function doUnlock( array $paths, $type ) { + protected function doLockByType( array $pathsByType ) { $status = Status::newGood(); - - $pathsToUnlock = array(); - foreach ( $paths as $path ) { - if ( !isset( $this->locksHeld[$path][$type] ) ) { - $status->warning( 'lockmanager-notlocked', $path ); + $lockedByType = array(); // map of (type => paths) + foreach ( $pathsByType as $type => $paths ) { + $status->merge( $this->doLock( $paths, $type ) ); + if ( $status->isOK() ) { + $lockedByType[$type] = $paths; } 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 + // Release the subset of locks that were acquired + foreach ( $lockedByType as $type => $paths ) { + $status->merge( $this->doUnlock( $paths, $type ) ); } + break; } } - - // 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. + * Lock resources with the given keys and lock type * - * @param $bucket integer - * @param $paths Array List of resource keys to lock - * @param $type integer LockManager::LOCK_EX or LockManager::LOCK_SH + * @param array $paths List of paths + * @param $type integer LockManager::LOCK_* constant * @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; - } + abstract protected function doLock( array $paths, $type ); /** - * 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 + * @see LockManager::unlockByType() + * @param array $paths Map of LockManager::LOCK_* constants to lists of paths * @return Status + * @since 1.22 */ - final protected function doUnlockingRequestBucket( $bucket, array $paths, $type ) { + protected function doUnlockByType( array $pathsByType ) { $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 ) ); - } + foreach ( $pathsByType as $type => $paths ) { + $status->merge( $this->doUnlock( $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(). + * Unlock resources with the given keys and lock type * + * @param array $paths List of paths + * @param $type integer LockManager::LOCK_* constant * @return Status */ - abstract protected function releaseAllLocks(); + abstract protected function doUnlock( array $paths, $type ); } /** @@ -403,22 +250,10 @@ abstract class QuorumLockManager extends LockManager { * @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 index 8c8c940a..9aff2415 100644 --- a/includes/filebackend/lockmanager/LockManagerGroup.php +++ b/includes/filebackend/lockmanager/LockManagerGroup.php @@ -29,33 +29,41 @@ * @since 1.19 */ class LockManagerGroup { - /** - * @var LockManagerGroup - */ - protected static $instance = null; + /** @var Array (domain => LockManager) */ + protected static $instances = array(); + + protected $domain; // string; domain (usually wiki ID) - /** @var Array of (name => ('class' =>, 'config' =>, 'instance' =>)) */ + /** @var Array of (name => ('class' => ..., 'config' => ..., 'instance' => ...)) */ protected $managers = array(); - protected function __construct() {} + /** + * @param string $domain Domain (usually wiki ID) + */ + protected function __construct( $domain ) { + $this->domain = $domain; + } /** + * @param string $domain Domain (usually wiki ID) * @return LockManagerGroup */ - public static function singleton() { - if ( self::$instance == null ) { - self::$instance = new self(); - self::$instance->initFromGlobals(); + public static function singleton( $domain = false ) { + $domain = ( $domain === false ) ? wfWikiID() : $domain; + if ( !isset( self::$instances[$domain] ) ) { + self::$instances[$domain] = new self( $domain ); + self::$instances[$domain]->initFromGlobals(); } - return self::$instance; + return self::$instances[$domain]; } /** - * Destroy the singleton instance, so that a new one will be created next - * time singleton() is called. + * Destroy the singleton instances + * + * @return void */ - public static function destroySingleton() { - self::$instance = null; + public static function destroySingletons() { + self::$instances = array(); } /** @@ -78,6 +86,7 @@ class LockManagerGroup { */ protected function register( array $configs ) { foreach ( $configs as $config ) { + $config['domain'] = $this->domain; if ( !isset( $config['name'] ) ) { throw new MWException( "Cannot register a lock manager with no name." ); } @@ -88,8 +97,8 @@ class LockManagerGroup { $class = $config['class']; unset( $config['class'] ); // lock manager won't need this $this->managers[$name] = array( - 'class' => $class, - 'config' => $config, + 'class' => $class, + 'config' => $config, 'instance' => null ); } @@ -116,6 +125,21 @@ class LockManagerGroup { } /** + * Get the config array for a lock manager object with a given name + * + * @param $name string + * @return Array + * @throws MWException + */ + public function config( $name ) { + if ( !isset( $this->managers[$name] ) ) { + throw new MWException( "No lock manager defined with the name `$name`." ); + } + $class = $this->managers[$name]['class']; + return array( 'class' => $class ) + $this->managers[$name]['config']; + } + + /** * Get the default lock manager configured for the site. * Returns NullLockManager if no lock manager could be found. * diff --git a/includes/filebackend/lockmanager/MemcLockManager.php b/includes/filebackend/lockmanager/MemcLockManager.php index 57c0463d..5eab03ee 100644 --- a/includes/filebackend/lockmanager/MemcLockManager.php +++ b/includes/filebackend/lockmanager/MemcLockManager.php @@ -28,8 +28,8 @@ * 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. + * 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 @@ -48,9 +48,7 @@ class MemcLockManager extends QuorumLockManager { /** @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 + protected $session = ''; // string; random UUID /** * Construct a new instance from configuration. @@ -61,9 +59,9 @@ class MemcLockManager extends QuorumLockManager { * 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 + * @param array $config + * @throws MWException */ public function __construct( array $config ) { parent::__construct( $config ); @@ -87,19 +85,47 @@ class MemcLockManager extends QuorumLockManager { } } - $this->wikiId = isset( $config['wikiId'] ) ? $config['wikiId'] : wfWikiID(); + $this->session = wfRandomString( 32 ); + } - $met = ini_get( 'max_execution_time' ); // this is 0 in CLI mode - $this->lockExpiry = $met ? 2*(int)$met : 2*3600; + // @TODO: change this code to work in one batch + protected function getLocksOnServer( $lockSrv, array $pathsByType ) { + $status = Status::newGood(); - $this->session = wfRandomString( 32 ); + $lockedPaths = array(); + foreach ( $pathsByType as $type => $paths ) { + $status->merge( $this->doGetLocksOnServer( $lockSrv, $paths, $type ) ); + if ( $status->isOK() ) { + $lockedPaths[$type] = isset( $lockedPaths[$type] ) + ? array_merge( $lockedPaths[$type], $paths ) + : $paths; + } else { + foreach ( $lockedPaths as $type => $paths ) { + $status->merge( $this->doFreeLocksOnServer( $lockSrv, $paths, $type ) ); + } + break; + } + } + + return $status; + } + + // @TODO: change this code to work in one batch + protected function freeLocksOnServer( $lockSrv, array $pathsByType ) { + $status = Status::newGood(); + + foreach ( $pathsByType as $type => $paths ) { + $status->merge( $this->doFreeLocksOnServer( $lockSrv, $paths, $type ) ); + } + + return $status; } /** * @see QuorumLockManager::getLocksOnServer() * @return Status */ - protected function getLocksOnServer( $lockSrv, array $paths, $type ) { + protected function doGetLocksOnServer( $lockSrv, array $paths, $type ) { $status = Status::newGood(); $memc = $this->getCache( $lockSrv ); @@ -110,7 +136,7 @@ class MemcLockManager extends QuorumLockManager { foreach ( $paths as $path ) { $status->fatal( 'lockmanager-fail-acquirelock', $path ); } - return; + return $status; } // Fetch all the existing lock records... @@ -121,8 +147,8 @@ class MemcLockManager extends QuorumLockManager { foreach ( $paths as $path ) { $locksKey = $this->recordKeyForPath( $path ); $locksHeld = isset( $lockRecords[$locksKey] ) - ? $lockRecords[$locksKey] - : array( self::LOCK_SH => array(), self::LOCK_EX => array() ); // init + ? self::sanitizeLockArray( $lockRecords[$locksKey] ) + : self::newLockArray(); // init foreach ( $locksHeld[self::LOCK_EX] as $session => $expiry ) { if ( $expiry < $now ) { // stale? unset( $locksHeld[self::LOCK_EX][$session] ); @@ -141,7 +167,7 @@ class MemcLockManager extends QuorumLockManager { } if ( $status->isOK() ) { // Register the session in the lock record array - $locksHeld[$type][$this->session] = $now + $this->lockExpiry; + $locksHeld[$type][$this->session] = $now + $this->lockTTL; // We will update this record if none of the other locks conflict $lockRecords[$locksKey] = $locksHeld; } @@ -149,9 +175,15 @@ class MemcLockManager extends QuorumLockManager { // 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" ); + foreach ( $paths as $path ) { + $locksKey = $this->recordKeyForPath( $path ); + $locksHeld = $lockRecords[$locksKey]; + $ok = $memc->set( $locksKey, $locksHeld, 7 * 86400 ); + if ( !$ok ) { + $status->fatal( 'lockmanager-fail-acquirelock', $path ); + } else { + wfDebug( __METHOD__ . ": acquired lock on key $locksKey.\n" ); + } } } @@ -165,7 +197,7 @@ class MemcLockManager extends QuorumLockManager { * @see QuorumLockManager::freeLocksOnServer() * @return Status */ - protected function freeLocksOnServer( $lockSrv, array $paths, $type ) { + protected function doFreeLocksOnServer( $lockSrv, array $paths, $type ) { $status = Status::newGood(); $memc = $this->getCache( $lockSrv ); @@ -186,17 +218,22 @@ class MemcLockManager extends QuorumLockManager { foreach ( $paths as $path ) { $locksKey = $this->recordKeyForPath( $path ); // lock record if ( !isset( $lockRecords[$locksKey] ) ) { + $status->warning( 'lockmanager-fail-releaselock', $path ); continue; // nothing to do } - $locksHeld = $lockRecords[$locksKey]; - if ( is_array( $locksHeld ) && isset( $locksHeld[$type] ) ) { - unset( $locksHeld[$type][$this->session] ); - $ok = $memc->set( $locksKey, $locksHeld ); + $locksHeld = self::sanitizeLockArray( $lockRecords[$locksKey] ); + if ( isset( $locksHeld[$type][$this->session] ) ) { + unset( $locksHeld[$type][$this->session] ); // unregister this session + if ( $locksHeld === self::newLockArray() ) { + $ok = $memc->delete( $locksKey ); + } else { + $ok = $memc->set( $locksKey, $locksHeld ); + } + if ( !$ok ) { + $status->fatal( 'lockmanager-fail-releaselock', $path ); + } } else { - $ok = true; - } - if ( !$ok ) { - $status->fatal( 'lockmanager-fail-releaselock', $path ); + $status->warning( 'lockmanager-fail-releaselock', $path ); } wfDebug( __METHOD__ . ": released lock on key $locksKey.\n" ); } @@ -226,7 +263,7 @@ class MemcLockManager extends QuorumLockManager { /** * Get the MemcachedBagOStuff object for a $lockSrv * - * @param $lockSrv string Server name + * @param string $lockSrv Server name * @return MemcachedBagOStuff|null */ protected function getCache( $lockSrv ) { @@ -234,7 +271,7 @@ class MemcLockManager extends QuorumLockManager { if ( isset( $this->bagOStuffs[$lockSrv] ) ) { $memc = $this->bagOStuffs[$lockSrv]; if ( !isset( $this->serversUp[$lockSrv] ) ) { - $this->serversUp[$lockSrv] = $memc->set( 'MemcLockManager:ping', 1, 1 ); + $this->serversUp[$lockSrv] = $memc->set( __CLASS__ . ':ping', 1, 1 ); if ( !$this->serversUp[$lockSrv] ) { trigger_error( __METHOD__ . ": Could not contact $lockSrv.", E_USER_WARNING ); } @@ -251,14 +288,32 @@ class MemcLockManager extends QuorumLockManager { * @return string */ protected function recordKeyForPath( $path ) { - $hash = LockManager::sha1Base36( $path ); - list( $db, $prefix ) = wfSplitWikiID( $this->wikiId ); - return wfForeignMemcKey( $db, $prefix, __CLASS__, 'locks', $hash ); + return implode( ':', array( __CLASS__, 'locks', $this->sha1Base36Absolute( $path ) ) ); + } + + /** + * @return Array An empty lock structure for a key + */ + protected static function newLockArray() { + return array( self::LOCK_SH => array(), self::LOCK_EX => array() ); + } + + /** + * @param $a array + * @return Array An empty lock structure for a key + */ + protected static function sanitizeLockArray( $a ) { + if ( is_array( $a ) && isset( $a[self::LOCK_EX] ) && isset( $a[self::LOCK_SH] ) ) { + return $a; + } else { + trigger_error( __METHOD__ . ": reset invalid lock array.", E_USER_WARNING ); + return self::newLockArray(); + } } /** * @param $memc MemcachedBagOStuff - * @param $keys Array List of keys to acquire + * @param array $keys List of keys to acquire * @return bool */ protected function acquireMutexes( MemcachedBagOStuff $memc, array $keys ) { @@ -275,7 +330,7 @@ class MemcLockManager extends QuorumLockManager { $start = microtime( true ); do { if ( ( ++$rounds % 4 ) == 0 ) { - usleep( 1000*50 ); // 50 ms + usleep( 1000 * 50 ); // 50 ms } foreach ( array_diff( $keys, $lockedKeys ) as $key ) { if ( $memc->add( "$key:mutex", 1, 180 ) ) { // lock record @@ -284,10 +339,10 @@ class MemcLockManager extends QuorumLockManager { continue; // acquire in order } } - } while ( count( $lockedKeys ) < count( $keys ) && ( microtime( true ) - $start ) <= 6 ); + } while ( count( $lockedKeys ) < count( $keys ) && ( microtime( true ) - $start ) <= 3 ); if ( count( $lockedKeys ) != count( $keys ) ) { - $this->releaseMutexes( $lockedKeys ); // failed; release what was locked + $this->releaseMutexes( $memc, $lockedKeys ); // failed; release what was locked return false; } @@ -296,7 +351,7 @@ class MemcLockManager extends QuorumLockManager { /** * @param $memc MemcachedBagOStuff - * @param $keys Array List of acquired keys + * @param array $keys List of acquired keys * @return void */ protected function releaseMutexes( MemcachedBagOStuff $memc, array $keys ) { diff --git a/includes/filebackend/lockmanager/QuorumLockManager.php b/includes/filebackend/lockmanager/QuorumLockManager.php new file mode 100644 index 00000000..8356d32a --- /dev/null +++ b/includes/filebackend/lockmanager/QuorumLockManager.php @@ -0,0 +1,246 @@ +<?php +/** + * Version of LockManager that uses a quorum from peer servers for 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 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, ...)) + /** @var Array Map of degraded buckets */ + protected $degradedBuckets = array(); // (buckey index => UNIX timestamp) + + final protected function doLock( array $paths, $type ) { + return $this->doLockByType( array( $type => $paths ) ); + } + + final protected function doUnlock( array $paths, $type ) { + return $this->doUnlockByType( array( $type => $paths ) ); + } + + protected function doLockByType( array $pathsByType ) { + $status = Status::newGood(); + + $pathsToLock = array(); // (bucket => type => paths) + // Get locks that need to be acquired (buckets => locks)... + foreach ( $pathsByType as $type => $paths ) { + foreach ( $paths as $path ) { + if ( isset( $this->locksHeld[$path][$type] ) ) { + ++$this->locksHeld[$path][$type]; + } else { + $bucket = $this->getBucketFromPath( $path ); + $pathsToLock[$bucket][$type][] = $path; + } + } + } + + $lockedPaths = array(); // files locked in this attempt (type => paths) + // Attempt to acquire these locks... + foreach ( $pathsToLock as $bucket => $pathsToLockByType ) { + // Try to acquire the locks for this bucket + $status->merge( $this->doLockingRequestBucket( $bucket, $pathsToLockByType ) ); + if ( !$status->isOK() ) { + $status->merge( $this->doUnlockByType( $lockedPaths ) ); + return $status; + } + // Record these locks as active + foreach ( $pathsToLockByType as $type => $paths ) { + foreach ( $paths as $path ) { + $this->locksHeld[$path][$type] = 1; // locked + // Keep track of what locks were made in this attempt + $lockedPaths[$type][] = $path; + } + } + } + + return $status; + } + + protected function doUnlockByType( array $pathsByType ) { + $status = Status::newGood(); + + $pathsToUnlock = array(); // (bucket => type => paths) + foreach ( $pathsByType as $type => $paths ) { + 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->getBucketFromPath( $path ); + $pathsToUnlock[$bucket][$type][] = $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 => $pathsToUnlockByType ) { + $status->merge( $this->doUnlockingRequestBucket( $bucket, $pathsToUnlockByType ) ); + } + if ( !count( $this->locksHeld ) ) { + $status->merge( $this->releaseAllLocks() ); + $this->degradedBuckets = array(); // safe to retry the normal quorum + } + + 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 array $pathsByType Map of LockManager::LOCK_* constants to lists of paths + * @return Status + */ + final protected function doLockingRequestBucket( $bucket, array $pathsByType ) { + $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 ); + $this->degradedBuckets[$bucket] = time(); + continue; // server down? + } + // Attempt to acquire the lock on this peer + $status->merge( $this->getLocksOnServer( $lockSrv, $pathsByType ) ); + 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 array $pathsByType Map of LockManager::LOCK_* constants to lists of paths + * @return Status + */ + final protected function doUnlockingRequestBucket( $bucket, array $pathsByType ) { + $status = Status::newGood(); + + $yesVotes = 0; // locks freed on trustable servers + $votesLeft = count( $this->srvsByBucket[$bucket] ); // remaining peers + $quorum = floor( $votesLeft / 2 + 1 ); // simple majority + $isDegraded = isset( $this->degradedBuckets[$bucket] ); // not the normal quorum? + foreach ( $this->srvsByBucket[$bucket] as $lockSrv ) { + if ( !$this->isServerUp( $lockSrv ) ) { + $status->warning( 'lockmanager-fail-svr-release', $lockSrv ); + // Attempt to release the lock on this peer + } else { + $status->merge( $this->freeLocksOnServer( $lockSrv, $pathsByType ) ); + ++$yesVotes; // success for this peer + // Normally the first peers form the quorum, and the others are ignored. + // Ignore them in this case, but not when an alternative quorum was used. + if ( $yesVotes >= $quorum && !$isDegraded ) { + break; // lock released + } + } + } + // Set a bad status if the quorum was not met. + // Assumes the same "up" servers as during the acquire step. + $status->setResult( $yesVotes >= $quorum ); + + return $status; + } + + /** + * Get the bucket for resource path. + * This should avoid throwing any exceptions. + * + * @param $path string + * @return integer + */ + protected function getBucketFromPath( $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. + * This should process cache results to reduce RTT. + * + * @param $lockSrv string + * @return bool + */ + abstract protected function isServerUp( $lockSrv ); + + /** + * Get a connection to a lock server and acquire locks + * + * @param $lockSrv string + * @param array $pathsByType Map of LockManager::LOCK_* constants to lists of paths + * @return Status + */ + abstract protected function getLocksOnServer( $lockSrv, array $pathsByType ); + + /** + * Get a connection to a lock server and release locks on $paths. + * + * Subclasses must effectively implement this or releaseAllLocks(). + * + * @param $lockSrv string + * @param array $pathsByType Map of LockManager::LOCK_* constants to lists of paths + * @return Status + */ + abstract protected function freeLocksOnServer( $lockSrv, array $pathsByType ); + + /** + * Release all locks that this session is holding. + * + * Subclasses must effectively implement this or freeLocksOnServer(). + * + * @return Status + */ + abstract protected function releaseAllLocks(); +} diff --git a/includes/filebackend/lockmanager/RedisLockManager.php b/includes/filebackend/lockmanager/RedisLockManager.php new file mode 100644 index 00000000..43b0198a --- /dev/null +++ b/includes/filebackend/lockmanager/RedisLockManager.php @@ -0,0 +1,288 @@ +<?php +/** + * Version of LockManager based on using redis 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 redis servers. + * + * Version of LockManager based on using redis 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 redis. + * A majority of peers must agree for a lock to be acquired. + * + * This class requires Redis 2.6 as it makes use Lua scripts for fast atomic operations. + * + * @ingroup LockManager + * @since 1.22 + */ +class RedisLockManager 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 RedisConnectionPool */ + protected $redisPool; + /** @var Array Map server names to hostname/IP and port numbers */ + protected $lockServers = array(); + + protected $session = ''; // string; random UUID + + /** + * 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. + * - redisConfig : Configuration for RedisConnectionPool::__construct(). + * + * @param Array $config + * @throws MWException + */ + 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 + + $config['redisConfig']['serializer'] = 'none'; + $this->redisPool = RedisConnectionPool::singleton( $config['redisConfig'] ); + + $this->session = wfRandomString( 32 ); + } + + // @TODO: change this code to work in one batch + protected function getLocksOnServer( $lockSrv, array $pathsByType ) { + $status = Status::newGood(); + + $lockedPaths = array(); + foreach ( $pathsByType as $type => $paths ) { + $status->merge( $this->doGetLocksOnServer( $lockSrv, $paths, $type ) ); + if ( $status->isOK() ) { + $lockedPaths[$type] = isset( $lockedPaths[$type] ) + ? array_merge( $lockedPaths[$type], $paths ) + : $paths; + } else { + foreach ( $lockedPaths as $type => $paths ) { + $status->merge( $this->doFreeLocksOnServer( $lockSrv, $paths, $type ) ); + } + break; + } + } + + return $status; + } + + // @TODO: change this code to work in one batch + protected function freeLocksOnServer( $lockSrv, array $pathsByType ) { + $status = Status::newGood(); + + foreach ( $pathsByType as $type => $paths ) { + $status->merge( $this->doFreeLocksOnServer( $lockSrv, $paths, $type ) ); + } + + return $status; + } + + protected function doGetLocksOnServer( $lockSrv, array $paths, $type ) { + $status = Status::newGood(); + + $server = $this->lockServers[$lockSrv]; + $conn = $this->redisPool->getConnection( $server ); + if ( !$conn ) { + foreach ( $paths as $path ) { + $status->fatal( 'lockmanager-fail-acquirelock', $path ); + } + return $status; + } + + $keys = array_map( array( $this, 'recordKeyForPath' ), $paths ); // lock records + + try { + static $script = +<<<LUA + if ARGV[1] ~= 'EX' and ARGV[1] ~= 'SH' then + return redis.error_reply('Unrecognized lock type given (must be EX or SH)') + end + local failed = {} + -- Check that all the locks can be acquired + for i,resourceKey in ipairs(KEYS) do + local keyIsFree = true + local currentLocks = redis.call('hKeys',resourceKey) + for i,lockKey in ipairs(currentLocks) do + local _, _, type, session = string.find(lockKey,"(%w+):(%w+)") + -- Check any locks that are not owned by this session + if session ~= ARGV[2] then + local lockTimestamp = redis.call('hGet',resourceKey,lockKey) + if 1*lockTimestamp < ( ARGV[4] - ARGV[3] ) then + -- Lock is stale, so just prune it out + redis.call('hDel',resourceKey,lockKey) + elseif ARGV[1] == 'EX' or type == 'EX' then + keyIsFree = false + break + end + end + end + if not keyIsFree then + failed[#failed+1] = resourceKey + end + end + -- If all locks could be acquired, then do so + if #failed == 0 then + for i,resourceKey in ipairs(KEYS) do + redis.call('hSet',resourceKey,ARGV[1] .. ':' .. ARGV[2],ARGV[4]) + -- In addition to invalidation logic, be sure to garbage collect + redis.call('expire',resourceKey,ARGV[3]) + end + end + return failed +LUA; + $res = $conn->luaEval( $script, + array_merge( + $keys, // KEYS[0], KEYS[1],...KEYS[N] + array( + $type === self::LOCK_SH ? 'SH' : 'EX', // ARGV[1] + $this->session, // ARGV[2] + $this->lockTTL, // ARGV[3] + time() // ARGV[4] + ) + ), + count( $keys ) # number of first argument(s) that are keys + ); + } catch ( RedisException $e ) { + $res = false; + $this->redisPool->handleException( $server, $conn, $e ); + } + + if ( $res === false ) { + foreach ( $paths as $path ) { + $status->fatal( 'lockmanager-fail-acquirelock', $path ); + } + } else { + $pathsByKey = array_combine( $keys, $paths ); + foreach ( $res as $key ) { + $status->fatal( 'lockmanager-fail-acquirelock', $pathsByKey[$key] ); + } + } + + return $status; + } + + protected function doFreeLocksOnServer( $lockSrv, array $paths, $type ) { + $status = Status::newGood(); + + $server = $this->lockServers[$lockSrv]; + $conn = $this->redisPool->getConnection( $server ); + if ( !$conn ) { + foreach ( $paths as $path ) { + $status->fatal( 'lockmanager-fail-releaselock', $path ); + } + return $status; + } + + $keys = array_map( array( $this, 'recordKeyForPath' ), $paths ); // lock records + + try { + static $script = +<<<LUA + if ARGV[1] ~= 'EX' and ARGV[1] ~= 'SH' then + return redis.error_reply('Unrecognized lock type given (must be EX or SH)') + end + local failed = {} + for i,resourceKey in ipairs(KEYS) do + local released = redis.call('hDel',resourceKey,ARGV[1] .. ':' .. ARGV[2]) + if released > 0 then + -- Remove the whole structure if it is now empty + if redis.call('hLen',resourceKey) == 0 then + redis.call('del',resourceKey) + end + else + failed[#failed+1] = resourceKey + end + end + return failed +LUA; + $res = $conn->luaEval( $script, + array_merge( + $keys, // KEYS[0], KEYS[1],...KEYS[N] + array( + $type === self::LOCK_SH ? 'SH' : 'EX', // ARGV[1] + $this->session // ARGV[2] + ) + ), + count( $keys ) # number of first argument(s) that are keys + ); + } catch ( RedisException $e ) { + $res = false; + $this->redisPool->handleException( $server, $conn, $e ); + } + + if ( $res === false ) { + foreach ( $paths as $path ) { + $status->fatal( 'lockmanager-fail-releaselock', $path ); + } + } else { + $pathsByKey = array_combine( $keys, $paths ); + foreach ( $res as $key ) { + $status->fatal( 'lockmanager-fail-releaselock', $pathsByKey[$key] ); + } + } + + return $status; + } + + protected function releaseAllLocks() { + return Status::newGood(); // not supported + } + + protected function isServerUp( $lockSrv ) { + return (bool)$this->redisPool->getConnection( $this->lockServers[$lockSrv] ); + } + + /** + * @param $path string + * @return string + */ + protected function recordKeyForPath( $path ) { + return implode( ':', array( __CLASS__, 'locks', $this->sha1Base36Absolute( $path ) ) ); + } + + /** + * 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 ); + } + } + } +} diff --git a/includes/filebackend/lockmanager/ScopedLock.php b/includes/filebackend/lockmanager/ScopedLock.php new file mode 100644 index 00000000..5faad4a6 --- /dev/null +++ b/includes/filebackend/lockmanager/ScopedLock.php @@ -0,0 +1,104 @@ +<?php +/** + * 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 + */ + +/** + * 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 Map of lock types to resource paths */ + protected $pathsByType; + + /** + * @param LockManager $manager + * @param array $pathsByType Map of lock types to path lists + * @param Status $status + */ + protected function __construct( LockManager $manager, array $pathsByType, Status $status ) { + $this->manager = $manager; + $this->pathsByType = $pathsByType; + $this->status = $status; + } + + /** + * 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. + * + * $type can be "mixed" and $paths can be a map of types to paths (since 1.22). + * Otherwise $type should be an integer and $paths should be a list of paths. + * + * @param LockManager $manager + * @param array $paths List of storage paths or map of lock types to path lists + * @param integer|string $type LockManager::LOCK_* constant or "mixed" + * @param Status $status + * @param integer $timeout Timeout in seconds (0 means non-blocking) (since 1.22) + * @return ScopedLock|null Returns null on failure + */ + public static function factory( + LockManager $manager, array $paths, $type, Status $status, $timeout = 0 + ) { + $pathsByType = is_integer( $type ) ? array( $type => $paths ) : $paths; + $lockStatus = $manager->lockByType( $pathsByType, $timeout ); + $status->merge( $lockStatus ); + if ( $lockStatus->isOK() ) { + return new self( $manager, $pathsByType, $status ); + } + return null; + } + + /** + * Release a scoped lock and set any errors in the attatched Status object. + * This is useful for early release of locks before function scope is destroyed. + * This is the same as setting the lock object to null. + * + * @param ScopedLock $lock + * @return void + * @since 1.21 + */ + public static function release( ScopedLock &$lock = null ) { + $lock = null; + } + + /** + * Release the locks when this goes out of scope + */ + function __destruct() { + $wasOk = $this->status->isOK(); + $this->status->merge( $this->manager->unlockByType( $this->pathsByType ) ); + if ( $wasOk ) { + // Make sure status is OK, despite any unlockFiles() fatals + $this->status->setResult( true, $this->status->value ); + } + } +} |