NameTableStore: ensure consistency upon rollback.
[lhc/web/wiklou.git] / tests / phpunit / includes / Storage / NameTableStoreTest.php
index 7a4ea2d..ff106ba 100644 (file)
@@ -5,10 +5,13 @@ namespace MediaWiki\Tests\Storage;
 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;
@@ -59,7 +62,13 @@ class NameTableStoreTest extends MediaWikiTestCase {
                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,
@@ -67,7 +76,12 @@ class NameTableStoreTest extends MediaWikiTestCase {
                        '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()
@@ -90,7 +104,7 @@ class NameTableStoreTest extends MediaWikiTestCase {
                $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',
@@ -344,4 +358,150 @@ class NameTableStoreTest extends MediaWikiTestCase {
                $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
+               }
+       }
+
 }