use BagOStuff;
use EmptyBagOStuff;
use HashBagOStuff;
+use MediaWiki\MediaWikiServices;
use MediaWiki\Storage\NameTableAccessException;
use MediaWiki\Storage\NameTableStore;
use MediaWikiTestCase;
+use PHPUnit\Framework\MockObject\MockObject;
use Psr\Log\NullLogger;
+use RuntimeException;
use WANObjectCache;
use Wikimedia\Rdbms\IDatabase;
use Wikimedia\Rdbms\LoadBalancer;
+use Wikimedia\Rdbms\MaintainableDBConnRef;
use Wikimedia\TestingAccessWrapper;
/**
->disableOriginalConstructor()
->getMock();
$mock->expects( $this->any() )
- ->method( 'getConnection' )
- ->willReturn( $db );
+ ->method( 'getConnectionRef' )
+ ->willReturnCallback( function ( $i ) use ( $mock, $db ) {
+ return new MaintainableDBConnRef( $mock, $db, $i );
+ } );
return $mock;
}
- private function getCallCheckingDb( $insertCalls, $selectCalls ) {
+ /**
+ * @param null $insertCalls
+ * @param null $selectCalls
+ *
+ * @return MockObject|IDatabase
+ */
+ private function getProxyDb( $insertCalls = null, $selectCalls = null ) {
$proxiedMethods = [
'select' => $selectCalls,
'insert' => $insertCalls,
'insertId' => null,
'getSessionLagStatus' => null,
'writesPending' => null,
- 'onTransactionPreCommitOrIdle' => null
+ 'onTransactionPreCommitOrIdle' => null,
+ 'onAtomicSectionCancel' => null,
+ 'doAtomicSection' => null,
+ 'begin' => null,
+ 'rollback' => null,
+ 'commit' => null,
];
$mock = $this->getMockBuilder( IDatabase::class )
->disableOriginalConstructor()
$insertCallback = null
) {
return new NameTableStore(
- $this->getMockLoadBalancer( $this->getCallCheckingDb( $insertCalls, $selectCalls ) ),
+ $this->getMockLoadBalancer( $this->getProxyDb( $insertCalls, $selectCalls ) ),
$this->getHashWANObjectCache( $cacheBag ),
new NullLogger(),
'slot_roles', 'role_id', 'role_name',
$this->assertSame( 7251, $store->acquireId( 'A' ) );
}
+ public function testTransactionRollback() {
+ $lb = MediaWikiServices::getInstance()->getDBLoadBalancer();
+
+ // Two instances hitting the real database using separate caches.
+ $store1 = new NameTableStore(
+ $lb,
+ $this->getHashWANObjectCache( new HashBagOStuff() ),
+ new NullLogger(),
+ 'slot_roles', 'role_id', 'role_name'
+ );
+ $store2 = new NameTableStore(
+ $lb,
+ $this->getHashWANObjectCache( new HashBagOStuff() ),
+ new NullLogger(),
+ 'slot_roles', 'role_id', 'role_name'
+ );
+
+ $this->db->begin( __METHOD__ );
+ $fooId = $store1->acquireId( 'foo' );
+ $this->db->rollback( __METHOD__ );
+
+ $this->assertSame( $fooId, $store2->getId( 'foo' ) );
+ $this->assertSame( $fooId, $store1->getId( 'foo' ) );
+ }
+
+ public function testTransactionRollbackWithFailedRedo() {
+ $insertCalls = 0;
+
+ $db = $this->getProxyDb( 2 );
+ $db->method( 'insert' )
+ ->willReturnCallback( function () use ( &$insertCalls, $db ) {
+ $insertCalls++;
+ switch ( $insertCalls ) {
+ case 1:
+ return true;
+ case 2:
+ throw new RuntimeException( 'Testing' );
+ }
+
+ return true;
+ } );
+
+ $lb = $this->getMockBuilder( LoadBalancer::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $lb->method( 'getConnectionRef' )
+ ->willReturn( $db );
+ $lb->method( 'resolveDomainID' )
+ ->willReturnArgument( 0 );
+
+ // Two instances hitting the real database using separate caches.
+ $store1 = new NameTableStore(
+ $lb,
+ $this->getHashWANObjectCache( new HashBagOStuff() ),
+ new NullLogger(),
+ 'slot_roles', 'role_id', 'role_name'
+ );
+
+ $this->db->begin( __METHOD__ );
+ $store1->acquireId( 'foo' );
+ $this->db->rollback( __METHOD__ );
+
+ $this->assertArrayNotHasKey( 'foo', $store1->getMap() );
+ }
+
+ public function testTransactionRollbackWithInterference() {
+ $lb = MediaWikiServices::getInstance()->getDBLoadBalancer();
+
+ // Two instances hitting the real database using separate caches.
+ $store1 = new NameTableStore(
+ $lb,
+ $this->getHashWANObjectCache( new HashBagOStuff() ),
+ new NullLogger(),
+ 'slot_roles', 'role_id', 'role_name'
+ );
+ $store2 = new NameTableStore(
+ $lb,
+ $this->getHashWANObjectCache( new HashBagOStuff() ),
+ new NullLogger(),
+ 'slot_roles', 'role_id', 'role_name'
+ );
+
+ $this->db->begin( __METHOD__ );
+
+ $quuxId = null;
+ $this->db->onTransactionResolution(
+ function () use ( $store1, &$quuxId ) {
+ $quuxId = $store1->acquireId( 'quux' );
+ }
+ );
+
+ $store1->acquireId( 'foo' );
+ $this->db->rollback( __METHOD__ );
+
+ // $store2 should know about the insert by $store1
+ $this->assertSame( $quuxId, $store2->getId( 'quux' ) );
+
+ // A "best effort" attempt was made to restore the entry for 'foo'
+ // after the transaction failed. This may succeed on some databases like MySQL,
+ // while it fails on others. Since we are giving no guarantee about this,
+ // the only thing we can test here is that acquireId( 'foo' ) returns an
+ // ID that is distinct from the ID of quux (but might be different from the
+ // value returned by the original call to acquireId( 'foo' ).
+ // Note that $store2 will not know about the ID for 'foo' acquired by $store1,
+ // because it's using a separate cache, and getId() does not fall back to
+ // checking the database.
+ $this->assertNotSame( $quuxId, $store1->acquireId( 'foo' ) );
+ }
+
+ public function testTransactionDoubleRollback() {
+ $fname = __METHOD__;
+
+ $lb = MediaWikiServices::getInstance()->getDBLoadBalancer();
+ $store = new NameTableStore(
+ $lb,
+ $this->getHashWANObjectCache( new HashBagOStuff() ),
+ new NullLogger(),
+ 'slot_roles', 'role_id', 'role_name'
+ );
+
+ // Nested atomic sections
+ $atomic1 = $this->db->startAtomic( $fname, $this->db::ATOMIC_CANCELABLE );
+ $atomic2 = $this->db->startAtomic( $fname, $this->db::ATOMIC_CANCELABLE );
+
+ // Acquire ID
+ $id = $store->acquireId( 'foo' );
+
+ // Oops, rolled back
+ $this->db->cancelAtomic( $fname, $atomic2 );
+
+ // Should have been re-inserted
+ $store->reloadMap();
+ $this->assertSame( $id, $store->getId( 'foo' ) );
+
+ // Oops, re-insert was rolled back too.
+ $this->db->cancelAtomic( $fname, $atomic1 );
+
+ // This time, no re-insertion happened.
+ try {
+ $id2 = $store->getId( 'foo' );
+ $this->fail( "Expected NameTableAccessException, got $id2 (originally was $id)" );
+ } catch ( NameTableAccessException $ex ) {
+ // expected
+ }
+ }
+
}