Merge "Make sure we don't try to use a deleted rev ID."
[lhc/web/wiklou.git] / includes / Storage / RevisionStore.php
index 602364c..88d520c 100644 (file)
@@ -56,7 +56,7 @@ use Wikimedia\Assert\Assert;
 use Wikimedia\Rdbms\Database;
 use Wikimedia\Rdbms\DBConnRef;
 use Wikimedia\Rdbms\IDatabase;
-use Wikimedia\Rdbms\LoadBalancer;
+use Wikimedia\Rdbms\ILoadBalancer;
 
 /**
  * Service for looking up page revisions.
@@ -88,7 +88,7 @@ class RevisionStore
        private $contentHandlerUseDB = true;
 
        /**
-        * @var LoadBalancer
+        * @var ILoadBalancer
         */
        private $loadBalancer;
 
@@ -128,9 +128,14 @@ class RevisionStore
        /**
         * @todo $blobStore should be allowed to be any BlobStore!
         *
-        * @param LoadBalancer $loadBalancer
+        * @param ILoadBalancer $loadBalancer
         * @param SqlBlobStore $blobStore
-        * @param WANObjectCache $cache
+        * @param WANObjectCache $cache A cache for caching revision rows. This can be the local
+        *        wiki's default instance even if $wikiId refers to a different wiki, since
+        *        makeGlobalKey() is used to constructed a key that allows cached revision rows from
+        *        the same database to be re-used between wikis. For example, enwiki and frwiki will
+        *        use the same cache keys for revision rows from the wikidatawiki database, regardless
+        *        of the cache's default key space.
         * @param CommentStore $commentStore
         * @param NameTableStore $contentModelStore
         * @param NameTableStore $slotRoleStore
@@ -141,7 +146,7 @@ class RevisionStore
         * @throws MWException if $mcrMigrationStage or $wikiId is invalid.
         */
        public function __construct(
-               LoadBalancer $loadBalancer,
+               ILoadBalancer $loadBalancer,
                SqlBlobStore $blobStore,
                WANObjectCache $cache,
                CommentStore $commentStore,
@@ -202,6 +207,20 @@ class RevisionStore
                return ( $this->mcrMigrationStage & $flags ) === $flags;
        }
 
+       /**
+        * Throws a RevisionAccessException if this RevisionStore is configured for cross-wiki loading
+        * and still reading from the old DB schema.
+        *
+        * @throws RevisionAccessException
+        */
+       private function assertCrossWikiContentLoadingIsSafe() {
+               if ( $this->wikiId !== false && $this->hasMcrSchemaFlags( SCHEMA_COMPAT_READ_OLD ) ) {
+                       throw new RevisionAccessException(
+                               "Cross-wiki content loading is not supported by the pre-MCR schema"
+                       );
+               }
+       }
+
        public function setLogger( LoggerInterface $logger ) {
                $this->logger = $logger;
        }
@@ -239,7 +258,7 @@ class RevisionStore
        }
 
        /**
-        * @return LoadBalancer
+        * @return ILoadBalancer
         */
        private function getDBLoadBalancer() {
                return $this->loadBalancer;
@@ -727,6 +746,76 @@ class RevisionStore
                if ( !isset( $revisionRow['rev_id'] ) ) {
                        // only if auto-increment was used
                        $revisionRow['rev_id'] = intval( $dbw->insertId() );
+
+                       if ( $dbw->getType() === 'mysql' ) {
+                               // (T202032) MySQL until 8.0 and MariaDB until some version after 10.1.34 don't save the
+                               // auto-increment value to disk, so on server restart it might reuse IDs from deleted
+                               // revisions. We can fix that with an insert with an explicit rev_id value, if necessary.
+
+                               $maxRevId = intval( $dbw->selectField( 'archive', 'MAX(ar_rev_id)', '', __METHOD__ ) );
+                               $table = 'archive';
+                               if ( $this->hasMcrSchemaFlags( SCHEMA_COMPAT_WRITE_NEW ) ) {
+                                       $maxRevId2 = intval( $dbw->selectField( 'slots', 'MAX(slot_revision_id)', '', __METHOD__ ) );
+                                       if ( $maxRevId2 >= $maxRevId ) {
+                                               $maxRevId = $maxRevId2;
+                                               $table = 'slots';
+                                       }
+                               }
+
+                               if ( $maxRevId >= $revisionRow['rev_id'] ) {
+                                       $this->logger->debug(
+                                               '__METHOD__: Inserted revision {revid} but {table} has revisions up to {maxrevid}.'
+                                                       . ' Trying to fix it.',
+                                               [
+                                                       'revid' => $revisionRow['rev_id'],
+                                                       'table' => $table,
+                                                       'maxrevid' => $maxRevId,
+                                               ]
+                                       );
+
+                                       if ( !$dbw->lock( 'fix-for-T202032', __METHOD__ ) ) {
+                                               throw new MWException( 'Failed to get database lock for T202032' );
+                                       }
+                                       $fname = __METHOD__;
+                                       $dbw->onTransactionResolution( function ( $trigger, $dbw ) use ( $fname ) {
+                                               $dbw->unlock( 'fix-for-T202032', $fname );
+                                       } );
+
+                                       $dbw->delete( 'revision', [ 'rev_id' => $revisionRow['rev_id'] ], __METHOD__ );
+
+                                       // The locking here is mostly to make MySQL bypass the REPEATABLE-READ transaction
+                                       // isolation (weird MySQL "feature"). It does seem to block concurrent auto-incrementing
+                                       // inserts too, though, at least on MariaDB 10.1.29.
+                                       //
+                                       // Don't try to lock `revision` in this way, it'll deadlock if there are concurrent
+                                       // transactions in this code path thanks to the row lock from the original ->insert() above.
+                                       //
+                                       // And we have to use raw SQL to bypass the "aggregation used with a locking SELECT" warning
+                                       // that's for non-MySQL DBs.
+                                       $row1 = $dbw->query(
+                                               $dbw->selectSqlText( 'archive', [ 'v' => "MAX(ar_rev_id)" ], '', __METHOD__ ) . ' FOR UPDATE'
+                                       )->fetchObject();
+                                       if ( $this->hasMcrSchemaFlags( SCHEMA_COMPAT_WRITE_NEW ) ) {
+                                               $row2 = $dbw->query(
+                                                       $dbw->selectSqlText( 'slots', [ 'v' => "MAX(slot_revision_id)" ], '', __METHOD__ )
+                                                               . ' FOR UPDATE'
+                                               )->fetchObject();
+                                       } else {
+                                               $row2 = null;
+                                       }
+                                       $maxRevId = max(
+                                               $maxRevId,
+                                               $row1 ? intval( $row1->v ) : 0,
+                                               $row2 ? intval( $row2->v ) : 0
+                                       );
+
+                                       // If we don't have SCHEMA_COMPAT_WRITE_NEW, all except the first of any concurrent
+                                       // transactions will throw a duplicate key error here. It doesn't seem worth trying
+                                       // to avoid that.
+                                       $revisionRow['rev_id'] = $maxRevId + 1;
+                                       $dbw->insert( 'revision', $revisionRow, __METHOD__ );
+                               }
+                       }
                }
 
                $commentCallback( $revisionRow['rev_id'] );
@@ -775,6 +864,8 @@ class RevisionStore
 
                        // MCR migration note: rev_content_model and rev_content_format will go away
                        if ( $this->contentHandlerUseDB ) {
+                               $this->assertCrossWikiContentLoadingIsSafe();
+
                                $defaultModel = ContentHandler::getDefaultModelFor( $title );
                                $defaultFormat = ContentHandler::getForModelID( $defaultModel )->getDefaultFormat();
 
@@ -883,6 +974,8 @@ class RevisionStore
                        // if $wgContentHandlerUseDB is not set,
                        // all revisions must use the default content model and format.
 
+                       $this->assertCrossWikiContentLoadingIsSafe();
+
                        $defaultModel = ContentHandler::getDefaultModelFor( $title );
                        $defaultHandler = ContentHandler::getForModelID( $defaultModel );
                        $defaultFormat = $defaultHandler->getDefaultFormat();
@@ -917,7 +1010,7 @@ class RevisionStore
         * Such revisions can for instance identify page rename
         * operations and other such meta-modifications.
         *
-        * @note: This method grabs a FOR UPDATE lock on the relevant row of the page table,
+        * @note This method grabs a FOR UPDATE lock on the relevant row of the page table,
         * to prevent a new revision from being inserted before the null revision has been written
         * to the database.
         *
@@ -1202,6 +1295,8 @@ class RevisionStore
 
                if ( $mainSlotRow->model_name === null ) {
                        $mainSlotRow->model_name = function ( SlotRecord $slot ) use ( $title ) {
+                               $this->assertCrossWikiContentLoadingIsSafe();
+
                                // TODO: MCR: consider slot role in getDefaultModelFor()! Use LinkTarget!
                                // TODO: MCR: deprecate $title->getModel().
                                return ContentHandler::getDefaultModelFor( $title );
@@ -2149,7 +2244,9 @@ class RevisionStore
                                // NOTE: even when this class is set to not read from the old schema, callers
                                // should still be able to join against the text table, as long as we are still
                                // writing the old schema for compatibility.
-                               wfDeprecated( __METHOD__ . ' with `text` option', '1.32' );
+                               // TODO: This should trigger a deprecation warning eventually (T200918), but not
+                               // before all known usages are removed (see T198341 and T201164).
+                               // wfDeprecated( __METHOD__ . ' with `text` option', '1.32' );
                        }
 
                        $ret['tables'][] = 'text';