With a user-friendly error message which uses both technical and non-technical language, without the irrelevant detail of filebackend URLs. Bug: T283045 Change-Id: I8704f405d38acdffae7bd4ea5b91f3f591fc2ebc
337 lines
12 KiB
PHP
337 lines
12 KiB
PHP
<?php
|
|
|
|
use Wikimedia\TestingAccessWrapper;
|
|
|
|
class MySqlLockManagerTest extends MediaWikiUnitTestCase {
|
|
// 31 characters long
|
|
private const FAKE_SESSION = '0123456789abcdef0123456789abcde';
|
|
|
|
/**
|
|
* @covers MysqlLockManager::__construct
|
|
* @covers MySqlLockManager::doGetLocksOnServer
|
|
* @covers MySqlLockManager::initConnection
|
|
*
|
|
* @dataProvider provideGetLocksOnServer
|
|
* @param array $params Keys:
|
|
* - lockArgs: Array of arguments to pass to lock()
|
|
* - sharedInsertKeys: Array of keys that are expected to be inserted in filelocks_shared
|
|
* table (the session is assumed to be self::FAKE_SESSION)
|
|
* - exclusiveInsertKeys: Same but for filelocks_exclusive
|
|
* - exclusiveSelectFieldKeys: Array of keys expected to be checked for in filelocks_exclusive
|
|
* - exclusiveSelectFieldResult: '1' if the keys will be found there, false if not
|
|
* - sharedSelectFieldKeys: Array of keys expected to be checked for in filelocks_shared
|
|
* - sharedSelectFieldResult1: Result expected the first time, before the exclusive lock
|
|
* insertion
|
|
* - sharedSelectFieldResult2: Result expected after an exclusive lock is obtained
|
|
* - expectedOK: Boolean, true for success and false for failure
|
|
* - lockedPaths: Array of paths that the LockManager should be told it holds exclusive locks
|
|
* on already (locksHeld member)
|
|
*/
|
|
public function testGetLocksOnServer( array $params ) {
|
|
$mockDb = $this->createMock( IDatabase::class );
|
|
|
|
$mockDb->expects( $this->never() )->method( $this->anythingBut( 'query', 'startAtomic',
|
|
'selectField', 'insert', 'setSessionOptions', 'trxLevel', 'rollback', 'close',
|
|
'addQuotes' ) );
|
|
|
|
$isolationSet = false;
|
|
$trxStarted = false;
|
|
|
|
$mockDb->expects( $this->once() )->method( 'query' )
|
|
->with( 'SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;' )
|
|
->will( $this->returnCallback( static function () use ( &$isolationSet ) {
|
|
$isolationSet = true;
|
|
} ) );
|
|
|
|
$mockDb->expects( $this->once() )->method( 'startAtomic' )
|
|
->will( $this->returnCallback( static function () use ( &$trxStarted ) {
|
|
$trxStarted = true;
|
|
} ) );
|
|
|
|
// Because of the way PHPUnit works, we don't test the order of inserts relative to
|
|
// selectField. It shouldn't matter if the correct results are obtained in all cases.
|
|
$expectedInserts = [];
|
|
if ( isset( $params['sharedInsertKeys'] ) ) {
|
|
$rows = [];
|
|
foreach ( $params['sharedInsertKeys'] as $key ) {
|
|
$rows[] = [ 'fls_key' => $key, 'fls_session' => self::FAKE_SESSION ];
|
|
}
|
|
$expectedInserts[] = [
|
|
'filelocks_shared',
|
|
$rows,
|
|
self::isType( 'string' ),
|
|
[ 'IGNORE' ],
|
|
];
|
|
}
|
|
if ( isset( $params['exclusiveInsertKeys'] ) ) {
|
|
$rows = [];
|
|
foreach ( $params['exclusiveInsertKeys'] as $key ) {
|
|
$rows[] = [ 'fle_key' => $key ];
|
|
}
|
|
$expectedInserts[] = [
|
|
'filelocks_exclusive',
|
|
$rows,
|
|
self::isType( 'string' ),
|
|
[],
|
|
];
|
|
}
|
|
|
|
$mockDb->expects( $this->exactly( count( $expectedInserts ) ) )->method( 'insert' )
|
|
->withConsecutive( ...$expectedInserts )
|
|
->will( $this->returnCallback( function () use ( &$isolationSet, &$trxStarted ) {
|
|
$this->assertTrue( $isolationSet, 'Read uncommitted must be set before queries' );
|
|
$this->assertTrue( $trxStarted, 'Transaction must be started before writes' );
|
|
} ) );
|
|
|
|
$expectedSelectFieldArgs = [];
|
|
$selectFieldReturns = [];
|
|
if ( isset( $params['exclusiveSelectFieldKeys'] ) ) {
|
|
$expectedSelectFieldArgs[] = [
|
|
'filelocks_exclusive', '1', [ 'fle_key' => $params['exclusiveSelectFieldKeys'] ]
|
|
];
|
|
$selectFieldReturns[] = $params['exclusiveSelectFieldResult'];
|
|
}
|
|
if ( isset( $params['sharedSelectFieldKeys'] ) ) {
|
|
$row = [
|
|
'filelocks_shared',
|
|
'1',
|
|
[
|
|
'fls_key' => $params['sharedSelectFieldKeys'],
|
|
"fls_session != '" . self::FAKE_SESSION . "'"
|
|
]
|
|
];
|
|
$expectedSelectFieldArgs[] = $row;
|
|
$selectFieldReturns[] = $params['sharedSelectFieldResult1'];
|
|
if ( isset( $params['sharedSelectFieldResult2'] ) ) {
|
|
// Query will be the same, result may be different
|
|
$expectedSelectFieldArgs[] = $row;
|
|
$selectFieldReturns[] = $params['sharedSelectFieldResult2'];
|
|
}
|
|
}
|
|
|
|
$mockDb->expects( $this->exactly( count( $expectedSelectFieldArgs ) ) )
|
|
->method( 'selectField' )
|
|
->withConsecutive( ...$expectedSelectFieldArgs )
|
|
->willReturnOnConsecutiveCalls( ...$selectFieldReturns );
|
|
|
|
$mockDb->method( 'addQuotes' )->will( $this->returnCallback( static function ( $s ) {
|
|
return "'$s'";
|
|
} ) );
|
|
|
|
$lm = new MySqlLockManager( [
|
|
'dbServers' => [ 'main' => $mockDb ],
|
|
'dbsByBucket' => [ [ 'main' ] ],
|
|
] );
|
|
|
|
// We need a predictable session here
|
|
$wrapper = TestingAccessWrapper::newFromObject( $lm );
|
|
// This tests the constructor's truncation of the session to 31 chars from 32
|
|
$this->assertRegExp( '/^[0-9a-z]{31}$/', $wrapper->session );
|
|
$wrapper->session = self::FAKE_SESSION;
|
|
|
|
$locksHeld = [];
|
|
foreach ( $params['lockedPaths'] ?? [] as $path ) {
|
|
$locksHeld[$path][LockManager::LOCK_EX] = true;
|
|
}
|
|
$wrapper->locksHeld = $locksHeld;
|
|
|
|
$status = $lm->lock( ...$params['lockArgs'] );
|
|
|
|
$expectedErrors = [];
|
|
if ( !( $params['expectedOK'] ?? true ) ) {
|
|
$expectedErrors[] = [
|
|
'type' => 'error',
|
|
'message' => 'lockmanager-fail-conflict',
|
|
'params' => [],
|
|
];
|
|
}
|
|
$this->assertSame( $expectedErrors, $status->getErrors() );
|
|
$this->assertSame( !$expectedErrors, $status->isOK() );
|
|
}
|
|
|
|
/**
|
|
* Copied from LockManager::sha1Base36Absolute
|
|
*
|
|
* @param string $path
|
|
* @param string|null $domain
|
|
* @return string
|
|
*/
|
|
private static function sha1Base36Absolute( $path, $domain = null ) {
|
|
$domain = $domain ?? 'global';
|
|
return Wikimedia\base_convert( sha1( "$domain:$path" ), 16, 36, 31 );
|
|
}
|
|
|
|
public static function provideGetLocksOnServer() {
|
|
$key = self::sha1Base36Absolute( 'MyFile.png' );
|
|
$key2 = self::sha1Base36Absolute( 'MyFile.jpg' );
|
|
|
|
return [
|
|
'Simple exclusive lock' => [ [
|
|
'lockArgs' => [ [ 'MyFile.png' ] ],
|
|
'sharedInsertKeys' => [ $key ],
|
|
'sharedSelectFieldKeys' => [ $key ],
|
|
'sharedSelectFieldResult1' => false,
|
|
'exclusiveInsertKeys' => [ $key ],
|
|
'sharedSelectFieldResult2' => false,
|
|
] ],
|
|
'Simple shared lock' => [ [
|
|
'lockArgs' => [ [ 'MyFile.png' ], LockManager::LOCK_SH ],
|
|
'sharedInsertKeys' => [ $key ],
|
|
'exclusiveSelectFieldKeys' => [ $key ],
|
|
'exclusiveSelectFieldResult' => false,
|
|
] ],
|
|
'Exclusive lock blocked by shared lock' => [ [
|
|
'lockArgs' => [ [ 'MyFile.png' ] ],
|
|
'sharedInsertKeys' => [ $key ],
|
|
'sharedSelectFieldKeys' => [ $key ],
|
|
'sharedSelectFieldResult1' => '1',
|
|
'expectedOK' => false,
|
|
] ],
|
|
'Exclusive lock blocked by shared lock on second try' => [ [
|
|
'lockArgs' => [ [ 'MyFile.png' ] ],
|
|
'sharedInsertKeys' => [ $key ],
|
|
'sharedSelectFieldKeys' => [ $key ],
|
|
'sharedSelectFieldResult1' => false,
|
|
'exclusiveInsertKeys' => [ $key ],
|
|
'sharedSelectFieldResult2' => '1',
|
|
'expectedOK' => false,
|
|
] ],
|
|
'Shared lock blocked by exclusive lock' => [ [
|
|
'lockArgs' => [ [ 'MyFile.png' ], LockManager::LOCK_SH ],
|
|
'sharedInsertKeys' => [ $key ],
|
|
'exclusiveSelectFieldKeys' => [ $key ],
|
|
'exclusiveSelectFieldResult' => '1',
|
|
'expectedOK' => false,
|
|
] ],
|
|
'Shared lock where we already hold an exclusive lock on the path' => [ [
|
|
'lockArgs' => [ [ 'MyFile.png' ], LockManager::LOCK_SH ],
|
|
'sharedInsertKeys' => [ $key ],
|
|
'lockedPaths' => [ 'MyFile.png' ],
|
|
] ],
|
|
|
|
// Two keys at once
|
|
'Exclusive lock on two keys' => [ [
|
|
'lockArgs' => [ [ 'MyFile.png', 'MyFile.jpg' ] ],
|
|
'sharedInsertKeys' => [ $key, $key2 ],
|
|
'sharedSelectFieldKeys' => [ $key, $key2 ],
|
|
'sharedSelectFieldResult1' => false,
|
|
'exclusiveInsertKeys' => [ $key, $key2 ],
|
|
'sharedSelectFieldResult2' => false,
|
|
] ],
|
|
'Shared lock on two keys' => [ [
|
|
'lockArgs' => [ [ 'MyFile.png', 'MyFile.jpg' ], LockManager::LOCK_SH ],
|
|
'sharedInsertKeys' => [ $key, $key2 ],
|
|
'exclusiveSelectFieldKeys' => [ $key, $key2 ],
|
|
'exclusiveSelectFieldResult' => false,
|
|
] ],
|
|
'Exclusive lock on two keys blocked by shared lock' => [ [
|
|
'lockArgs' => [ [ 'MyFile.png', 'MyFile.jpg' ] ],
|
|
'sharedInsertKeys' => [ $key, $key2 ],
|
|
'sharedSelectFieldKeys' => [ $key, $key2 ],
|
|
'sharedSelectFieldResult1' => '1',
|
|
'expectedOK' => false,
|
|
] ],
|
|
'Exclusive lock on two keys blocked by shared lock on second try' => [ [
|
|
'lockArgs' => [ [ 'MyFile.png', 'MyFile.jpg' ] ],
|
|
'sharedInsertKeys' => [ $key, $key2 ],
|
|
'sharedSelectFieldKeys' => [ $key, $key2 ],
|
|
'sharedSelectFieldResult1' => false,
|
|
'exclusiveInsertKeys' => [ $key, $key2 ],
|
|
'sharedSelectFieldResult2' => '1',
|
|
'expectedOK' => false,
|
|
] ],
|
|
'Shared lock on two keys blocked by exclusive lock' => [ [
|
|
'lockArgs' => [ [ 'MyFile.png', 'MyFile.jpg' ], LockManager::LOCK_SH ],
|
|
'sharedInsertKeys' => [ $key, $key2 ],
|
|
'exclusiveSelectFieldKeys' => [ $key, $key2 ],
|
|
'exclusiveSelectFieldResult' => '1',
|
|
'expectedOK' => false,
|
|
] ],
|
|
'Shared lock on two keys where we already hold an exclusive lock on one' => [ [
|
|
'lockArgs' => [ [ 'MyFile.png', 'MyFile.jpg' ], LockManager::LOCK_SH ],
|
|
'sharedInsertKeys' => [ $key, $key2 ],
|
|
'exclusiveSelectFieldKeys' => [ $key2 ],
|
|
'exclusiveSelectFieldResult' => false,
|
|
'lockedPaths' => [ 'MyFile.png' ],
|
|
] ],
|
|
'Shared lock on two keys, one already held and one blocked' => [ [
|
|
'lockArgs' => [ [ 'MyFile.png', 'MyFile.jpg' ], LockManager::LOCK_SH ],
|
|
'sharedInsertKeys' => [ $key, $key2 ],
|
|
'exclusiveSelectFieldKeys' => [ $key2 ],
|
|
'exclusiveSelectFieldResult' => '1',
|
|
'expectedOK' => false,
|
|
'lockedPaths' => [ 'MyFile.png' ],
|
|
] ],
|
|
'Shared lock on two keys, both already held' => [ [
|
|
'lockArgs' => [ [ 'MyFile.png', 'MyFile.jpg' ], LockManager::LOCK_SH ],
|
|
'sharedInsertKeys' => [ $key, $key2 ],
|
|
'lockedPaths' => [ 'MyFile.png', 'MyFile.jpg' ],
|
|
] ],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @covers MySqlLockManager::releaseAllLocks
|
|
* @dataProvider provideReleaseAllLocks
|
|
* @param int $trxLevel What the mock DB should return from trxLevel()
|
|
* @param int $expectedRollbacks How many times we expect rollback() to be called
|
|
*/
|
|
public function testReleaseAllLocks( $trxLevel, $expectedRollbacks ) {
|
|
$mockDb = $this->createMock( IDatabase::class );
|
|
$mockDb->expects( $this->once() )->method( 'trxLevel' )->willReturn( $trxLevel );
|
|
$mockDb->expects( $this->exactly( $expectedRollbacks ) )->method( 'rollback' );
|
|
$mockDb->expects( $this->never() )->method( $this->anythingBut( 'query', 'startAtomic',
|
|
'selectField', 'insert', 'setSessionOptions', 'trxLevel', 'rollback', 'close',
|
|
'addQuotes' ) );
|
|
|
|
$lm = new MySqlLockManager( [
|
|
'dbServers' => [ 'main' => $mockDb ],
|
|
'dbsByBucket' => [ [ 'main' ] ],
|
|
] );
|
|
|
|
// Initialize the connection
|
|
$lm->lock( [ 'a' ] );
|
|
|
|
// __destruct should call releaseAllLocks
|
|
unset( $lm );
|
|
}
|
|
|
|
public static function provideReleaseAllLocks() {
|
|
return [
|
|
[ 0, 0 ],
|
|
[ 1, 1 ],
|
|
[ 734, 1 ],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @covers MySqlLockManager::releaseAllLocks
|
|
*/
|
|
public function testReleaseAllLocks_exception() {
|
|
$mockDb = $this->createMock( IDatabase::class );
|
|
// These methods will be called once by unlock() and again by destructor
|
|
$mockDb->expects( $this->exactly( 2 ) )->method( 'trxLevel' )->willReturn( 1 );
|
|
$mockDb->expects( $this->exactly( 2 ) )->method( 'rollback' )
|
|
->will( $this->throwException( new DBError( $mockDb, '' ) ) );
|
|
$mockDb->expects( $this->never() )->method( $this->anythingBut( 'query', 'startAtomic',
|
|
'selectField', 'insert', 'setSessionOptions', 'trxLevel', 'rollback', 'close',
|
|
'addQuotes' ) );
|
|
|
|
$lm = new MySqlLockManager( [
|
|
'dbServers' => [ 'main' => $mockDb ],
|
|
'dbsByBucket' => [ [ 'main' ] ],
|
|
] );
|
|
|
|
// Initialize the connection
|
|
$lm->lock( [ 'a' ] );
|
|
|
|
// Unlocking everything will call releaseAllLocks
|
|
$status = $lm->unlock( [ 'a' ] );
|
|
$this->assertSame( [ [
|
|
'type' => 'error',
|
|
'message' => 'lockmanager-fail-db-release',
|
|
'params' => [ 'main' ],
|
|
] ], $status->getErrors() );
|
|
$this->assertFalse( $status->isOK() );
|
|
}
|
|
}
|