LocalFile::lock() held an explicit DB transaction open while a potentially slow file backend operation was in progress, causing production errors. Instead, protect write operations with non-blocking locks. If a file is locked, report the failure to the user. Like an edit conflict, informing the user is appropriate in most cases. For example, if two admins try to delete the same file at the same time, only one should succeed, and the second does not need to wait for the first. In upgradeRow(), locks were used to protect operations which read from a file and write the result back to the database. The lock was supposed to prevent overwriting of fresh data with stale data. Use a timestamp check instead. Bug: T283045 Change-Id: Iaf9c9eef617044656b5b0d91a9632eacd84a5a78
288 lines
8.9 KiB
PHP
288 lines
8.9 KiB
PHP
<?php
|
|
/**
|
|
* @defgroup LockManager Lock management
|
|
* @ingroup FileBackend
|
|
*/
|
|
use Psr\Log\LoggerInterface;
|
|
use Psr\Log\NullLogger;
|
|
use Wikimedia\RequestTimeout\RequestTimeout;
|
|
use Wikimedia\WaitConditionLoop;
|
|
|
|
/**
|
|
* 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
|
|
*/
|
|
|
|
/**
|
|
* @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 process
|
|
* 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.
|
|
*
|
|
* @stable to extend
|
|
* @ingroup LockManager
|
|
* @since 1.19
|
|
*/
|
|
abstract class LockManager {
|
|
/** @var LoggerInterface */
|
|
protected $logger;
|
|
|
|
/** @var array Mapping of lock types to the type actually used */
|
|
protected $lockTypeMap = [
|
|
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 = [];
|
|
|
|
protected $domain; // string; domain (usually wiki ID)
|
|
protected $lockTTL; // integer; maximum time locks can be held
|
|
|
|
/** @var string Random 32-char hex number */
|
|
protected $session;
|
|
|
|
/** Lock types; stronger locks have higher values */
|
|
public const LOCK_SH = 1; // shared lock (for reads)
|
|
public const LOCK_UW = 2; // shared lock (for reads used to write elsewhere)
|
|
public const LOCK_EX = 3; // exclusive lock (for writes)
|
|
|
|
/** Max expected lock expiry in any context */
|
|
protected const MAX_LOCK_TTL = 2 * 3600; // 2 hours
|
|
|
|
/** Default lock TTL in CLI mode */
|
|
protected const CLI_LOCK_TTL = 3600; // 1 hour
|
|
|
|
/** Minimum lock TTL. The configured lockTTL is ignored if it is less than this value. */
|
|
protected const MIN_LOCK_TTL = 5; // seconds
|
|
|
|
/** The minimum lock TTL if it is guessed from max_execution_time rather than configured. */
|
|
protected const MIN_GUESSED_LOCK_TTL = 5 * 60; // 5 minutes
|
|
|
|
/**
|
|
* Construct a new instance from configuration
|
|
* @stable to call
|
|
*
|
|
* @param array $config Parameters 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.
|
|
*/
|
|
public function __construct( array $config ) {
|
|
$this->domain = $config['domain'] ?? 'global';
|
|
if ( isset( $config['lockTTL'] ) ) {
|
|
$this->lockTTL = max( self::MIN_LOCK_TTL, $config['lockTTL'] );
|
|
} elseif ( PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg' ) {
|
|
$this->lockTTL = self::CLI_LOCK_TTL;
|
|
} else {
|
|
$ttl = 2 * ceil( RequestTimeout::singleton()->getWallTimeLimit() );
|
|
$this->lockTTL = ( $ttl === INF || $ttl < self::MIN_GUESSED_LOCK_TTL )
|
|
? self::MIN_GUESSED_LOCK_TTL : $ttl;
|
|
}
|
|
|
|
// Upper bound on how long to keep lock structures around. This is useful when setting
|
|
// TTLs, as the "lockTTL" value may vary based on CLI mode and app server group. This is
|
|
// a "safe" value that can be used to avoid clobbering other locks that use high TTLs.
|
|
$this->lockTTL = min( $this->lockTTL, self::MAX_LOCK_TTL );
|
|
|
|
$random = [];
|
|
for ( $i = 1; $i <= 5; ++$i ) {
|
|
$random[] = mt_rand( 0, 0xFFFFFFF );
|
|
}
|
|
$this->session = md5( implode( '-', $random ) );
|
|
|
|
$this->logger = $config['logger'] ?? new NullLogger();
|
|
}
|
|
|
|
/**
|
|
* Lock the resources at the given abstract paths
|
|
*
|
|
* @param array $paths List of resource names
|
|
* @param int $type LockManager::LOCK_* constant
|
|
* @param int $timeout Timeout in seconds (0 means non-blocking) (since 1.21)
|
|
* @return StatusValue
|
|
*/
|
|
final public function lock( array $paths, $type = self::LOCK_EX, $timeout = 0 ) {
|
|
return $this->lockByType( [ $type => $paths ], $timeout );
|
|
}
|
|
|
|
/**
|
|
* Lock the resources at the given abstract paths
|
|
*
|
|
* @param array $pathsByType Map of LockManager::LOCK_* constants to lists of paths
|
|
* @param int $timeout Timeout in seconds (0 means non-blocking) (since 1.21)
|
|
* @return StatusValue
|
|
* @since 1.22
|
|
*/
|
|
final public function lockByType( array $pathsByType, $timeout = 0 ) {
|
|
$pathsByType = $this->normalizePathsByType( $pathsByType );
|
|
|
|
$status = null;
|
|
$loop = new WaitConditionLoop(
|
|
function () use ( &$status, $pathsByType ) {
|
|
$status = $this->doLockByType( $pathsByType );
|
|
|
|
return $status->isOK() ?: WaitConditionLoop::CONDITION_CONTINUE;
|
|
},
|
|
$timeout
|
|
);
|
|
$loop->invoke();
|
|
|
|
return $status;
|
|
}
|
|
|
|
/**
|
|
* Unlock the resources at the given abstract paths
|
|
*
|
|
* @param array $paths List of paths
|
|
* @param int $type LockManager::LOCK_* constant
|
|
* @return StatusValue
|
|
*/
|
|
final public function unlock( array $paths, $type = self::LOCK_EX ) {
|
|
return $this->unlockByType( [ $type => $paths ] );
|
|
}
|
|
|
|
/**
|
|
* Unlock the resources at the given abstract paths
|
|
*
|
|
* @param array $pathsByType Map of LockManager::LOCK_* constants to lists of paths
|
|
* @return StatusValue
|
|
* @since 1.22
|
|
*/
|
|
final public function unlockByType( array $pathsByType ) {
|
|
$pathsByType = $this->normalizePathsByType( $pathsByType );
|
|
$status = $this->doUnlockByType( $pathsByType );
|
|
|
|
return $status;
|
|
}
|
|
|
|
/**
|
|
* 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 internally for lock key or file names.
|
|
*
|
|
* @param string $path
|
|
* @return string
|
|
*/
|
|
final protected function sha1Base36Absolute( $path ) {
|
|
return Wikimedia\base_convert( sha1( "{$this->domain}:{$path}" ), 16, 36, 31 );
|
|
}
|
|
|
|
/**
|
|
* 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 internally for lock key or file names.
|
|
*
|
|
* @param string $path
|
|
* @return string
|
|
*/
|
|
final protected function sha1Base16Absolute( $path ) {
|
|
return sha1( "{$this->domain}:{$path}" );
|
|
}
|
|
|
|
/**
|
|
* Normalize the $paths array by converting LOCK_UW locks into the
|
|
* appropriate type and removing any duplicated paths for each lock type.
|
|
*
|
|
* @param array $pathsByType Map of LockManager::LOCK_* constants to lists of paths
|
|
* @return array
|
|
* @since 1.22
|
|
*/
|
|
final protected function normalizePathsByType( array $pathsByType ) {
|
|
$res = [];
|
|
foreach ( $pathsByType as $type => $paths ) {
|
|
foreach ( $paths as $path ) {
|
|
if ( (string)$path === '' ) {
|
|
throw new InvalidArgumentException( __METHOD__ . ": got empty path." );
|
|
}
|
|
}
|
|
$res[$this->lockTypeMap[$type]] = array_unique( $paths );
|
|
}
|
|
|
|
return $res;
|
|
}
|
|
|
|
/**
|
|
* @see LockManager::lockByType()
|
|
* @stable to override
|
|
* @param array $pathsByType Map of LockManager::LOCK_* constants to lists of paths
|
|
* @return StatusValue
|
|
* @since 1.22
|
|
*/
|
|
protected function doLockByType( array $pathsByType ) {
|
|
$status = StatusValue::newGood();
|
|
$lockedByType = []; // map of (type => paths)
|
|
foreach ( $pathsByType as $type => $paths ) {
|
|
$status->merge( $this->doLock( $paths, $type ) );
|
|
if ( $status->isOK() ) {
|
|
$lockedByType[$type] = $paths;
|
|
} else {
|
|
// Release the subset of locks that were acquired
|
|
foreach ( $lockedByType as $lType => $lPaths ) {
|
|
$status->merge( $this->doUnlock( $lPaths, $lType ) );
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
return $status;
|
|
}
|
|
|
|
/**
|
|
* Lock resources with the given keys and lock type
|
|
*
|
|
* @param array $paths List of paths
|
|
* @param int $type LockManager::LOCK_* constant
|
|
* @return StatusValue
|
|
*/
|
|
abstract protected function doLock( array $paths, $type );
|
|
|
|
/**
|
|
* @see LockManager::unlockByType()
|
|
* @stable to override
|
|
* @param array $pathsByType Map of LockManager::LOCK_* constants to lists of paths
|
|
* @return StatusValue
|
|
* @since 1.22
|
|
*/
|
|
protected function doUnlockByType( array $pathsByType ) {
|
|
$status = StatusValue::newGood();
|
|
foreach ( $pathsByType as $type => $paths ) {
|
|
$status->merge( $this->doUnlock( $paths, $type ) );
|
|
}
|
|
|
|
return $status;
|
|
}
|
|
|
|
/**
|
|
* Unlock resources with the given keys and lock type
|
|
*
|
|
* @param array $paths List of paths
|
|
* @param int $type LockManager::LOCK_* constant
|
|
* @return StatusValue
|
|
*/
|
|
abstract protected function doUnlock( array $paths, $type );
|
|
}
|