Merge "Log errors in DeferredUpdates::handleUpdateQueue()"
authorjenkins-bot <jenkins-bot@gerrit.wikimedia.org>
Thu, 11 Jul 2019 22:50:50 +0000 (22:50 +0000)
committerGerrit Code Review <gerrit@wikimedia.org>
Thu, 11 Jul 2019 22:50:50 +0000 (22:50 +0000)
44 files changed:
RELEASE-NOTES-1.34
includes/Title.php
includes/actions/pagers/HistoryPager.php
includes/cache/LinkCache.php
includes/db/DatabaseOracle.php
includes/deferred/LinksUpdate.php
includes/installer/SqliteInstaller.php
includes/installer/i18n/be-tarask.json
includes/installer/i18n/da.json
includes/installer/i18n/it.json
includes/jobqueue/JobQueueRedis.php
includes/jobqueue/jobs/CategoryMembershipChangeJob.php
includes/jobqueue/jobs/ClearUserWatchlistJob.php
includes/libs/MapCacheLRU.php
includes/libs/rdbms/database/DatabaseMssql.php
includes/libs/rdbms/database/DatabaseMysqlBase.php
includes/libs/rdbms/database/DatabasePostgres.php
includes/libs/rdbms/database/DatabaseSqlite.php
includes/specials/SpecialBrokenRedirects.php
includes/specials/SpecialComparePages.php
includes/specials/SpecialDeletedContributions.php
includes/specials/SpecialDoubleRedirects.php
includes/specials/SpecialListGroupRights.php
includes/specials/SpecialListredirects.php
includes/specials/SpecialProtectedpages.php
includes/specials/SpecialProtectedtitles.php
includes/specials/SpecialTags.php
includes/specials/SpecialUncategorizedimages.php
includes/specials/SpecialUncategorizedpages.php
includes/specials/SpecialUndelete.php
includes/user/UserGroupMembership.php
includes/watcheditem/WatchedItemStore.php
languages/i18n/be-tarask.json
languages/i18n/de.json
languages/i18n/hr.json
languages/i18n/id.json
languages/i18n/ru.json
languages/i18n/sr-ec.json
maintenance/copyFileBackend.php
maintenance/createAndPromote.php
maintenance/nukePage.php
tests/phpunit/includes/libs/MapCacheLRUTest.php
tests/phpunit/includes/libs/rdbms/resultwrapper/FakeResultWrapperTest.php [new file with mode: 0644]
tests/phpunit/includes/libs/rdbms/resultwrapper/ResultWrapperTest.php [new file with mode: 0644]

index ce31543..574e460 100644 (file)
@@ -267,6 +267,9 @@ because of Phabricator reports.
 * The 'user.groups' module, deprecated in 1.28, was removed.
   Use the 'user' module instead.
 * The ability to override User::$mRights has been removed.
+* Previously, when iterating ResultWrapper with foreach() or a similar
+  construct, the range of the index was 1..numRows. This has been fixed to be
+  0..(numRows-1).
 * …
 
 === Deprecations in 1.34 ===
index 6e75102..a956ca2 100644 (file)
@@ -2294,34 +2294,6 @@ class Title implements LinkTarget, IDBAccessObject {
                        ->getPermissionErrors( $action, $user, $this, $rigor, $ignoreErrors );
        }
 
-       /**
-        * Add the resulting error code to the errors array
-        *
-        * @param array $errors List of current errors
-        * @param array|string|MessageSpecifier|false $result Result of errors
-        *
-        * @return array List of errors
-        */
-       private function resultToError( $errors, $result ) {
-               if ( is_array( $result ) && count( $result ) && !is_array( $result[0] ) ) {
-                       // A single array representing an error
-                       $errors[] = $result;
-               } elseif ( is_array( $result ) && is_array( $result[0] ) ) {
-                       // A nested array representing multiple errors
-                       $errors = array_merge( $errors, $result );
-               } elseif ( $result !== '' && is_string( $result ) ) {
-                       // A string representing a message-id
-                       $errors[] = [ $result ];
-               } elseif ( $result instanceof MessageSpecifier ) {
-                       // A message specifier representing an error
-                       $errors[] = [ $result ];
-               } elseif ( $result === false ) {
-                       // a generic "We don't want them to do that"
-                       $errors[] = [ 'badaccess-group0' ];
-               }
-               return $errors;
-       }
-
        /**
         * Get a filtered list of all restriction types supported by this wiki.
         * @param bool $exists True to get all restriction types that apply to
@@ -2949,7 +2921,7 @@ class Title implements LinkTarget, IDBAccessObject {
                        $this->mHasSubpages = false;
                        $subpages = $this->getSubpages( 1 );
                        if ( $subpages instanceof TitleArray ) {
-                               $this->mHasSubpages = (bool)$subpages->count();
+                               $this->mHasSubpages = (bool)$subpages->current();
                        }
                }
 
index c9c1b51..99c57e1 100644 (file)
@@ -123,7 +123,6 @@ class HistoryPager extends ReverseChronologicalPager {
         */
        function formatRow( $row ) {
                if ( $this->lastRow ) {
-                       $latest = ( $this->counter == 1 && $this->mIsFirst );
                        $firstInList = $this->counter == 1;
                        $this->counter++;
 
@@ -131,8 +130,7 @@ class HistoryPager extends ReverseChronologicalPager {
                                ? $this->getTitle()->getNotificationTimestamp( $this->getUser() )
                                : false;
 
-                       $s = $this->historyLine(
-                               $this->lastRow, $row, $notifTimestamp, $latest, $firstInList );
+                       $s = $this->historyLine( $this->lastRow, $row, $notifTimestamp, false, $firstInList );
                } else {
                        $s = '';
                }
@@ -185,34 +183,40 @@ class HistoryPager extends ReverseChronologicalPager {
                $s .= Html::hidden( 'type', 'revision' ) . "\n";
 
                // Button container stored in $this->buttons for re-use in getEndBody()
-               $this->buttons = Html::openElement( 'div', [ 'class' => 'mw-history-compareselectedversions' ] );
-               $className = 'historysubmit mw-history-compareselectedversions-button';
-               $attrs = [ 'class' => $className ]
-                       + Linker::tooltipAndAccesskeyAttribs( 'compareselectedversions' );
-               $this->buttons .= $this->submitButton( $this->msg( 'compareselectedversions' )->text(),
-                       $attrs
-               ) . "\n";
-
-               $user = $this->getUser();
-               $actionButtons = '';
-               if ( $user->isAllowed( 'deleterevision' ) ) {
-                       $actionButtons .= $this->getRevisionButton( 'revisiondelete', 'showhideselectedversions' );
-               }
-               if ( $this->showTagEditUI ) {
-                       $actionButtons .= $this->getRevisionButton( 'editchangetags', 'history-edit-tags' );
-               }
-               if ( $actionButtons ) {
-                       $this->buttons .= Xml::tags( 'div', [ 'class' =>
-                               'mw-history-revisionactions' ], $actionButtons );
-               }
+               $this->buttons = '';
+               if ( $this->getNumRows() > 0 ) {
+                       $this->buttons .= Html::openElement(
+                               'div', [ 'class' => 'mw-history-compareselectedversions' ] );
+                       $className = 'historysubmit mw-history-compareselectedversions-button';
+                       $attrs = [ 'class' => $className ]
+                               + Linker::tooltipAndAccesskeyAttribs( 'compareselectedversions' );
+                       $this->buttons .= $this->submitButton( $this->msg( 'compareselectedversions' )->text(),
+                               $attrs
+                       ) . "\n";
+
+                       $user = $this->getUser();
+                       $actionButtons = '';
+                       if ( $user->isAllowed( 'deleterevision' ) ) {
+                               $actionButtons .= $this->getRevisionButton(
+                                       'revisiondelete', 'showhideselectedversions' );
+                       }
+                       if ( $this->showTagEditUI ) {
+                               $actionButtons .= $this->getRevisionButton(
+                                       'editchangetags', 'history-edit-tags' );
+                       }
+                       if ( $actionButtons ) {
+                               $this->buttons .= Xml::tags( 'div', [ 'class' =>
+                                       'mw-history-revisionactions' ], $actionButtons );
+                       }
 
-               if ( $user->isAllowed( 'deleterevision' ) || $this->showTagEditUI ) {
-                       $this->buttons .= ( new ListToggle( $this->getOutput() ) )->getHTML();
-               }
+                       if ( $user->isAllowed( 'deleterevision' ) || $this->showTagEditUI ) {
+                               $this->buttons .= ( new ListToggle( $this->getOutput() ) )->getHTML();
+                       }
 
-               $this->buttons .= '</div>';
+                       $this->buttons .= '</div>';
 
-               $s .= $this->buttons;
+                       $s .= $this->buttons;
+               }
                $s .= '<ul id="pagehistory">' . "\n";
 
                return $s;
@@ -236,7 +240,6 @@ class HistoryPager extends ReverseChronologicalPager {
 
        protected function getEndBody() {
                if ( $this->lastRow ) {
-                       $latest = $this->counter == 1 && $this->mIsFirst;
                        $firstInList = $this->counter == 1;
                        if ( $this->mIsBackwards ) {
                                # Next row is unknown, but for UI reasons, probably exists if an offset has been specified
@@ -255,8 +258,7 @@ class HistoryPager extends ReverseChronologicalPager {
                                ? $this->getTitle()->getNotificationTimestamp( $this->getUser() )
                                : false;
 
-                       $s = $this->historyLine(
-                               $this->lastRow, $next, $notifTimestamp, $latest, $firstInList );
+                       $s = $this->historyLine( $this->lastRow, $next, $notifTimestamp, false, $firstInList );
                } else {
                        $s = '';
                }
@@ -295,13 +297,13 @@ class HistoryPager extends ReverseChronologicalPager {
         * @param mixed $next The database row corresponding to the next line
         *   (chronologically previous)
         * @param bool|string $notificationtimestamp
-        * @param bool $latest Whether this row corresponds to the page's latest revision.
+        * @param bool $dummy Unused.
         * @param bool $firstInList Whether this row corresponds to the first
         *   displayed on this history page.
         * @return string HTML output for the row
         */
        function historyLine( $row, $next, $notificationtimestamp = false,
-               $latest = false, $firstInList = false ) {
+               $dummy = false, $firstInList = false ) {
                $rev = new Revision( $row, 0, $this->getTitle() );
 
                if ( is_object( $next ) ) {
@@ -310,7 +312,8 @@ class HistoryPager extends ReverseChronologicalPager {
                        $prevRev = null;
                }
 
-               $curlink = $this->curLink( $rev, $latest );
+               $latest = $rev->getId() === $this->getWikiPage()->getLatest();
+               $curlink = $this->curLink( $rev );
                $lastlink = $this->lastLink( $rev, $next );
                $curLastlinks = Html::rawElement( 'span', [], $curlink ) .
                        Html::rawElement( 'span', [], $lastlink );
@@ -483,12 +486,12 @@ class HistoryPager extends ReverseChronologicalPager {
         * Create a diff-to-current link for this revision for this page
         *
         * @param Revision $rev
-        * @param bool $latest This is the latest revision of the page?
         * @return string
         */
-       function curLink( $rev, $latest ) {
+       function curLink( $rev ) {
                $cur = $this->historyPage->message['cur'];
-               if ( $latest || !$rev->userCan( Revision::DELETED_TEXT, $this->getUser() ) ) {
+               $latest = $this->getWikiPage()->getLatest();
+               if ( $latest === $rev->getId() || !$rev->userCan( Revision::DELETED_TEXT, $this->getUser() ) ) {
                        return $cur;
                } else {
                        return MediaWikiServices::getInstance()->getLinkRenderer()->makeKnownLink(
@@ -496,7 +499,7 @@ class HistoryPager extends ReverseChronologicalPager {
                                new HtmlArmor( $cur ),
                                [],
                                [
-                                       'diff' => $this->getWikiPage()->getLatest(),
+                                       'diff' => $latest,
                                        'oldid' => $rev->getId()
                                ]
                        );
index 1bcf948..8018117 100644 (file)
@@ -306,7 +306,7 @@ class LinkCache {
 
        private function isCacheable( LinkTarget $title ) {
                $ns = $title->getNamespace();
-               if ( in_array( $ns, [ NS_TEMPLATE, NS_FILE, NS_CATEGORY ] ) ) {
+               if ( in_array( $ns, [ NS_TEMPLATE, NS_FILE, NS_CATEGORY, NS_MEDIAWIKI ] ) ) {
                        return true;
                }
                // Focus on transcluded pages more than the main content
index a123d00..501f01a 100644 (file)
@@ -62,7 +62,7 @@ class DatabaseOracle extends Database {
         * @param array $params Additional parameters include:
         *   - keywordTableMap : Map of reserved table names to alternative table names to use
         */
-       function __construct( array $params ) {
+       public function __construct( array $params ) {
                $this->keywordTableMap = $params['keywordTableMap'] ?? [];
                $params['tablePrefix'] = strtoupper( $params['tablePrefix'] );
                parent::__construct( $params );
@@ -97,6 +97,15 @@ class DatabaseOracle extends Database {
                                        "and database)\n" );
                }
 
+               if ( $schema !== null ) {
+                       // We use the *database* aspect of $domain for schema, not the domain schema
+                       throw new DBExpectedError(
+                               $this,
+                               __CLASS__ . ": cannot use schema '$schema'; " .
+                               "the database component '$dbName' is actually interpreted as the Oracle schema."
+                       );
+               }
+
                $this->close();
                $this->user = $user;
                $this->password = $password;
@@ -1028,7 +1037,11 @@ class DatabaseOracle extends Database {
        protected function doSelectDomain( DatabaseDomain $domain ) {
                if ( $domain->getSchema() !== null ) {
                        // We use the *database* aspect of $domain for schema, not the domain schema
-                       throw new DBExpectedError( $this, __CLASS__ . ": domain schemas are not supported." );
+                       throw new DBExpectedError(
+                               $this,
+                               __CLASS__ . ": domain '{$domain->getId()}' has a schema component; " .
+                               "the database component is actually interpreted as the Oracle schema."
+                       );
                }
 
                $database = $domain->getDatabase();
index 5b68ff8..603e49c 100644 (file)
@@ -212,7 +212,7 @@ class LinksUpdate extends DataUpdate implements EnqueueableDataUpdate {
         * @since 1.27
         */
        public static function acquirePageLock( IDatabase $dbw, $pageId, $why = 'atomicity' ) {
-               $key = "LinksUpdate:$why:pageid:$pageId";
+               $key = "{$dbw->getDomainID()}:LinksUpdate:$why:pageid:$pageId"; // per-wiki
                $scopedLock = $dbw->getScopedLockAndFlush( $key, __METHOD__, 15 );
                if ( !$scopedLock ) {
                        $logger = LoggerFactory::getInstance( 'SecondaryDataUpdate' );
index 17332ff..cf91ccd 100644 (file)
@@ -406,6 +406,7 @@ EOT;
                'type' => 'sqlite',
                'dbname' => \"{\$wgDBname}_jobqueue\",
                'tablePrefix' => '',
+               'variables' => [ 'synchronous' => 'NORMAL' ],
                'dbDirectory' => \$wgSQLiteDataDir,
                'trxMode' => 'IMMEDIATE',
                'flags' => 0
index 35fce98..52cab04 100644 (file)
@@ -50,6 +50,7 @@
        "config-copyright": "== Аўтарскае права і ўмовы ==\n\n$1\n\nThis 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.\n\nThis 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'''.\nSee the GNU General Public License for more details.\n\nYou should have received <doclink href=Copying>a copy of the GNU General Public License</doclink> along with this program; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. or [https://www.gnu.org/copyleft/gpl.html read it online].",
        "config-sidebar": "* [https://www.mediawiki.org Хатняя старонка MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Даведка для ўдзельнікаў]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents Даведка для адміністратараў]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Адказы на частыя пытаньні]",
        "config-sidebar-readme": "Прачытай мяне",
+       "config-sidebar-relnotes": "Заўвагі да выпуску",
        "config-env-good": "Асяродзьдзе было праверанае.\nВы можаце ўсталёўваць MediaWiki.",
        "config-env-bad": "Асяродзьдзе было праверанае.\nУсталяваньне MediaWiki немагчымае.",
        "config-env-php": "Усталяваны PHP $1.",
index 946955e..3798468 100644 (file)
@@ -44,6 +44,7 @@
        "config-page-existingwiki": "Eksisterende wiki",
        "config-help-restart": "Vil du rydde alle gemte data, du har indtastet og genstarte installationen?",
        "config-restart": "Ja, genstart den",
+       "config-sidebar-upgrade": "Opgraderer",
        "config-env-php": "PHP $1 er installeret.",
        "config-env-hhvm": "HHVM $1 er installeret.",
        "config-apc": "[https://www.php.net/apc APC] er installeret",
index dbcfb03..36e1902 100644 (file)
@@ -22,7 +22,8 @@
                        "Tosky",
                        "Selven",
                        "Sarah Bernabei",
-                       "ArTrix"
+                       "ArTrix",
+                       "Annibale covini gerolamo"
                ]
        },
        "config-desc": "Programma di installazione per MediaWiki",
@@ -66,6 +67,7 @@
        "config-sidebar": "* [https://www.mediawiki.org Pagina principale MediaWiki]\n* [https://www.mediawiki.org/Special:MyLanguage/Help:Contents Guida ai contenuti per utenti]\n* [https://www.mediawiki.org/Special:MyLanguage/Manual:Contents Guida ai contenuti per admin]\n* [https://www.mediawiki.org/Special:MyLanguage/Manual:FAQ FAQ]",
        "config-sidebar-readme": "Leggimi",
        "config-sidebar-relnotes": "Note di versione",
+       "config-sidebar-license": "copiando",
        "config-sidebar-upgrade": "Aggiornamento",
        "config-env-good": "L'ambiente è stato controllato.\nÈ possibile installare MediaWiki.",
        "config-env-bad": "L'ambiente è stato controllato.\nNon è possibile installare MediaWiki.",
index 2140043..b8a5ad2 100644 (file)
@@ -19,6 +19,8 @@
  *
  * @file
  */
+
+use MediaWiki\Logger\LoggerFactory;
 use Psr\Log\LoggerInterface;
 
 /**
@@ -100,7 +102,7 @@ class JobQueueRedis extends JobQueue {
                                "Non-daemonized mode is no longer supported. Please install the " .
                                "mediawiki/services/jobrunner service and update \$wgJobTypeConf as needed." );
                }
-               $this->logger = \MediaWiki\Logger\LoggerFactory::getInstance( 'redis' );
+               $this->logger = LoggerFactory::getInstance( 'redis' );
        }
 
        protected function supportedOrders() {
@@ -134,7 +136,7 @@ class JobQueueRedis extends JobQueue {
                try {
                        return $conn->lSize( $this->getQueueKey( 'l-unclaimed' ) );
                } catch ( RedisException $e ) {
-                       $this->throwRedisException( $conn, $e );
+                       throw $this->handleErrorAndMakeException( $conn, $e );
                }
        }
 
@@ -152,7 +154,7 @@ class JobQueueRedis extends JobQueue {
 
                        return array_sum( $conn->exec() );
                } catch ( RedisException $e ) {
-                       $this->throwRedisException( $conn, $e );
+                       throw $this->handleErrorAndMakeException( $conn, $e );
                }
        }
 
@@ -166,7 +168,7 @@ class JobQueueRedis extends JobQueue {
                try {
                        return $conn->zSize( $this->getQueueKey( 'z-delayed' ) );
                } catch ( RedisException $e ) {
-                       $this->throwRedisException( $conn, $e );
+                       throw $this->handleErrorAndMakeException( $conn, $e );
                }
        }
 
@@ -180,7 +182,7 @@ class JobQueueRedis extends JobQueue {
                try {
                        return $conn->zSize( $this->getQueueKey( 'z-abandoned' ) );
                } catch ( RedisException $e ) {
-                       $this->throwRedisException( $conn, $e );
+                       throw $this->handleErrorAndMakeException( $conn, $e );
                }
        }
 
@@ -235,7 +237,7 @@ class JobQueueRedis extends JobQueue {
                                throw new RedisException( $err );
                        }
                } catch ( RedisException $e ) {
-                       $this->throwRedisException( $conn, $e );
+                       throw $this->handleErrorAndMakeException( $conn, $e );
                }
        }
 
@@ -332,7 +334,7 @@ LUA;
                                $job = $this->getJobFromFields( $item ); // may be false
                        } while ( !$job ); // job may be false if invalid
                } catch ( RedisException $e ) {
-                       $this->throwRedisException( $conn, $e );
+                       throw $this->handleErrorAndMakeException( $conn, $e );
                }
 
                return $job;
@@ -426,7 +428,7 @@ LUA;
 
                        $this->incrStats( 'acks', $this->type );
                } catch ( RedisException $e ) {
-                       $this->throwRedisException( $conn, $e );
+                       throw $this->handleErrorAndMakeException( $conn, $e );
                }
 
                return true;
@@ -457,7 +459,7 @@ LUA;
                        // Update the timestamp of the last root job started at the location...
                        return $conn->set( $key, $params['rootJobTimestamp'], self::ROOTJOB_TTL ); // 2 weeks
                } catch ( RedisException $e ) {
-                       $this->throwRedisException( $conn, $e );
+                       throw $this->handleErrorAndMakeException( $conn, $e );
                }
        }
 
@@ -478,8 +480,7 @@ LUA;
                        // Get the last time this root job was enqueued
                        $timestamp = $conn->get( $this->getRootJobCacheKey( $params['rootJobSignature'] ) );
                } catch ( RedisException $e ) {
-                       $timestamp = false;
-                       $this->throwRedisException( $conn, $e );
+                       throw $this->handleErrorAndMakeException( $conn, $e );
                }
 
                // Check if a new root job was started at the location after this one's...
@@ -507,7 +508,7 @@ LUA;
 
                        return $ok;
                } catch ( RedisException $e ) {
-                       $this->throwRedisException( $conn, $e );
+                       throw $this->handleErrorAndMakeException( $conn, $e );
                }
        }
 
@@ -521,7 +522,7 @@ LUA;
                try {
                        $uids = $conn->lRange( $this->getQueueKey( 'l-unclaimed' ), 0, -1 );
                } catch ( RedisException $e ) {
-                       $this->throwRedisException( $conn, $e );
+                       throw $this->handleErrorAndMakeException( $conn, $e );
                }
 
                return $this->getJobIterator( $conn, $uids );
@@ -537,7 +538,7 @@ LUA;
                try {
                        $uids = $conn->zRange( $this->getQueueKey( 'z-delayed' ), 0, -1 );
                } catch ( RedisException $e ) {
-                       $this->throwRedisException( $conn, $e );
+                       throw $this->handleErrorAndMakeException( $conn, $e );
                }
 
                return $this->getJobIterator( $conn, $uids );
@@ -553,7 +554,7 @@ LUA;
                try {
                        $uids = $conn->zRange( $this->getQueueKey( 'z-claimed' ), 0, -1 );
                } catch ( RedisException $e ) {
-                       $this->throwRedisException( $conn, $e );
+                       throw $this->handleErrorAndMakeException( $conn, $e );
                }
 
                return $this->getJobIterator( $conn, $uids );
@@ -569,7 +570,7 @@ LUA;
                try {
                        $uids = $conn->zRange( $this->getQueueKey( 'z-abandoned' ), 0, -1 );
                } catch ( RedisException $e ) {
-                       $this->throwRedisException( $conn, $e );
+                       throw $this->handleErrorAndMakeException( $conn, $e );
                }
 
                return $this->getJobIterator( $conn, $uids );
@@ -616,7 +617,7 @@ LUA;
                                }
                        }
                } catch ( RedisException $e ) {
-                       $this->throwRedisException( $conn, $e );
+                       throw $this->handleErrorAndMakeException( $conn, $e );
                }
 
                return $sizes;
@@ -626,12 +627,12 @@ LUA;
         * This function should not be called outside JobQueueRedis
         *
         * @param string $uid
-        * @param RedisConnRef $conn
+        * @param RedisConnRef|Redis $conn
         * @return RunnableJob|bool Returns false if the job does not exist
         * @throws JobQueueError
         * @throws UnexpectedValueException
         */
-       public function getJobFromUidInternal( $uid, RedisConnRef $conn ) {
+       public function getJobFromUidInternal( $uid, $conn ) {
                try {
                        $data = $conn->hGet( $this->getQueueKey( 'h-data' ), $uid );
                        if ( $data === false ) {
@@ -653,7 +654,7 @@ LUA;
 
                        return $job;
                } catch ( RedisException $e ) {
-                       $this->throwRedisException( $conn, $e );
+                       throw $this->handleErrorAndMakeException( $conn, $e );
                }
        }
 
@@ -672,7 +673,7 @@ LUA;
                                $queues[] = $this->decodeQueueName( $queue );
                        }
                } catch ( RedisException $e ) {
-                       $this->throwRedisException( $conn, $e );
+                       throw $this->handleErrorAndMakeException( $conn, $e );
                }
 
                return $queues;
@@ -754,7 +755,7 @@ LUA;
        /**
         * Get a connection to the server that handles all sub-queues for this queue
         *
-        * @return RedisConnRef
+        * @return RedisConnRef|Redis
         * @throws JobQueueConnectionError
         */
        protected function getConnection() {
@@ -770,11 +771,11 @@ LUA;
        /**
         * @param RedisConnRef $conn
         * @param RedisException $e
-        * @throws JobQueueError
+        * @return JobQueueError
         */
-       protected function throwRedisException( RedisConnRef $conn, $e ) {
+       protected function handleErrorAndMakeException( RedisConnRef $conn, $e ) {
                $this->redisPool->handleError( $conn, $e );
-               throw new JobQueueError( "Redis server error: {$e->getMessage()}\n" );
+               return new JobQueueError( "Redis server error: {$e->getMessage()}\n" );
        }
 
        /**
index be76fc6..882ae64 100644 (file)
@@ -99,7 +99,7 @@ class CategoryMembershipChangeJob extends Job {
                }
 
                // Use a named lock so that jobs for this page see each others' changes
-               $lockKey = "CategoryMembershipUpdates:{$page->getId()}";
+               $lockKey = "{$dbw->getDomainID()}:CategoryMembershipChange:{$page->getId()}"; // per-wiki
                $scopedLock = $dbw->getScopedLockAndFlush( $lockKey, __METHOD__, 3 );
                if ( !$scopedLock ) {
                        $this->setLastError( "Could not acquire lock '$lockKey'" );
index 1793053..74b90ed 100644 (file)
@@ -50,7 +50,7 @@ class ClearUserWatchlistJob extends Job implements GenericParameterJob {
                }
 
                // Use a named lock so that jobs for this user see each others' changes
-               $lockKey = "ClearUserWatchlistJob:$userId";
+               $lockKey = "{{$dbw->getDomainID()}}:ClearUserWatchlist:$userId"; // per-wiki
                $scopedLock = $dbw->getScopedLockAndFlush( $lockKey, __METHOD__, 10 );
                if ( !$scopedLock ) {
                        $this->setLastError( "Could not acquire lock '$lockKey'" );
index 3a549af..e0c81ed 100644 (file)
@@ -48,6 +48,7 @@ class MapCacheLRU implements IExpiringStore, Serializable {
        /** @var float|null */
        private $wallClockOverride;
 
+       /** @var float */
        const RANK_TOP = 1.0;
 
        /** @var int Array key that holds the entry's main timestamp (flat key use) */
@@ -103,7 +104,7 @@ class MapCacheLRU implements IExpiringStore, Serializable {
         *
         * @param string $key
         * @param mixed $value
-        * @param float $rank Bottom fraction of the list where keys start off [Default: 1.0]
+        * @param float $rank Bottom fraction of the list where keys start off [default: 1.0]
         * @return void
         */
        public function set( $key, $value, $rank = self::RANK_TOP ) {
@@ -135,10 +136,11 @@ class MapCacheLRU implements IExpiringStore, Serializable {
         * Check if a key exists
         *
         * @param string $key
-        * @param float $maxAge Ignore items older than this many seconds [optional] (since 1.32)
+        * @param float $maxAge Ignore items older than this many seconds [default: INF]
         * @return bool
+        * @since 1.32 Added $maxAge
         */
-       public function has( $key, $maxAge = 0.0 ) {
+       public function has( $key, $maxAge = INF ) {
                if ( !is_int( $key ) && !is_string( $key ) ) {
                        throw new UnexpectedValueException(
                                __METHOD__ . ': invalid key; must be string or integer.' );
@@ -157,12 +159,15 @@ class MapCacheLRU implements IExpiringStore, Serializable {
         * If the item is already set, it will be pushed to the top of the cache.
         *
         * @param string $key
-        * @param float $maxAge Ignore items older than this many seconds [optional] (since 1.32)
-        * @return mixed Returns null if the key was not found or is older than $maxAge
+        * @param float $maxAge Ignore items older than this many seconds [default: INF]
+        * @param mixed|null $default Value to return if no key is found [default: null]
+        * @return mixed Returns $default if the key was not found or is older than $maxAge
+        * @since 1.32 Added $maxAge
+        * @since 1.34 Added $default
         */
-       public function get( $key, $maxAge = 0.0 ) {
+       public function get( $key, $maxAge = INF, $default = null ) {
                if ( !$this->has( $key, $maxAge ) ) {
-                       return null;
+                       return $default;
                }
 
                $this->ping( $key );
@@ -201,10 +206,11 @@ class MapCacheLRU implements IExpiringStore, Serializable {
        /**
         * @param string|int $key
         * @param string|int $field
-        * @param float $maxAge Ignore items older than this many seconds [optional] (since 1.32)
+        * @param float $maxAge Ignore items older than this many seconds [default: INF]
         * @return bool
+        * @since 1.32 Added $maxAge
         */
-       public function hasField( $key, $field, $maxAge = 0.0 ) {
+       public function hasField( $key, $field, $maxAge = INF ) {
                $value = $this->get( $key );
 
                if ( !is_int( $field ) && !is_string( $field ) ) {
@@ -222,10 +228,11 @@ class MapCacheLRU implements IExpiringStore, Serializable {
        /**
         * @param string|int $key
         * @param string|int $field
-        * @param float $maxAge Ignore items older than this many seconds [optional] (since 1.32)
+        * @param float $maxAge Ignore items older than this many seconds [default: INF]
         * @return mixed Returns null if the key was not found or is older than $maxAge
+        * @since 1.32 Added $maxAge
         */
-       public function getField( $key, $field, $maxAge = 0.0 ) {
+       public function getField( $key, $field, $maxAge = INF ) {
                if ( !$this->hasField( $key, $field, $maxAge ) ) {
                        return null;
                }
@@ -249,12 +256,13 @@ class MapCacheLRU implements IExpiringStore, Serializable {
         * @since 1.28
         * @param string $key
         * @param callable $callback Callback that will produce the value
-        * @param float $rank Bottom fraction of the list where keys start off [Default: 1.0]
-        * @param float $maxAge Ignore items older than this many seconds [Default: 0.0] (since 1.32)
+        * @param float $rank Bottom fraction of the list where keys start off [default: 1.0]
+        * @param float $maxAge Ignore items older than this many seconds [default: INF]
         * @return mixed The cached value if found or the result of $callback otherwise
+        * @since 1.32 Added $maxAge
         */
        public function getWithSetCallback(
-               $key, callable $callback, $rank = self::RANK_TOP, $maxAge = 0.0
+               $key, callable $callback, $rank = self::RANK_TOP, $maxAge = INF
        ) {
                if ( $this->has( $key, $maxAge ) ) {
                        $value = $this->get( $key );
index 69174f9..d06bcb9 100644 (file)
@@ -1165,7 +1165,10 @@ class DatabaseMssql extends Database {
 
        protected function doSelectDomain( DatabaseDomain $domain ) {
                if ( $domain->getSchema() !== null ) {
-                       throw new DBExpectedError( $this, __CLASS__ . ": domain schemas are not supported." );
+                       throw new DBExpectedError(
+                               $this,
+                               __CLASS__ . ": domain '{$domain->getId()}' has a schema component"
+                       );
                }
 
                $database = $domain->getDatabase();
index 417b464..4774390 100644 (file)
@@ -96,7 +96,7 @@ abstract class DatabaseMysqlBase extends Database {
         *   - sslCiphers : array list of allowable ciphers [default: null]
         * @param array $params
         */
-       function __construct( array $params ) {
+       public function __construct( array $params ) {
                $this->lagDetectionMethod = $params['lagDetectionMethod'] ?? 'Seconds_Behind_Master';
                $this->lagDetectionOptions = $params['lagDetectionOptions'] ?? [];
                $this->useGTIDs = !empty( $params['useGTIDs' ] );
@@ -125,7 +125,7 @@ abstract class DatabaseMysqlBase extends Database {
                $this->close();
 
                if ( $schema !== null ) {
-                       throw new DBExpectedError( $this, __CLASS__ . ": domain schemas are not supported." );
+                       throw new DBExpectedError( $this, __CLASS__ . ": cannot use schemas ('$schema')" );
                }
 
                $this->server = $server;
@@ -194,7 +194,10 @@ abstract class DatabaseMysqlBase extends Database {
 
        protected function doSelectDomain( DatabaseDomain $domain ) {
                if ( $domain->getSchema() !== null ) {
-                       throw new DBExpectedError( $this, __CLASS__ . ": domain schemas are not supported." );
+                       throw new DBExpectedError(
+                               $this,
+                               __CLASS__ . ": domain '{$domain->getId()}' has a schema component"
+                       );
                }
 
                $database = $domain->getDatabase();
index 08987d9..954d400 100644 (file)
@@ -97,6 +97,8 @@ class DatabasePostgres extends Database {
                        );
                }
 
+               $this->close();
+
                $this->server = $server;
                $this->user = $user;
                $this->password = $password;
@@ -120,9 +122,8 @@ class DatabasePostgres extends Database {
                }
 
                $this->connectString = $this->makeConnectionString( $connectVars );
-               $this->close();
-               $this->installErrorHandler();
 
+               $this->installErrorHandler();
                try {
                        // Use new connections to let LoadBalancer/LBFactory handle reuse
                        $this->conn = pg_connect( $this->connectString, PGSQL_CONNECT_FORCE_NEW );
@@ -130,7 +131,6 @@ class DatabasePostgres extends Database {
                        $this->restoreErrorHandler();
                        throw $ex;
                }
-
                $phpError = $this->restoreErrorHandler();
 
                if ( !$this->conn ) {
@@ -1053,7 +1053,7 @@ __INDEXATTR__;
                        // See https://www.postgresql.org/docs/8.3/sql-set.html
                        throw new DBUnexpectedError(
                                $this,
-                               __METHOD__ . ": a transaction is currently active."
+                               __METHOD__ . ": a transaction is currently active"
                        );
                }
 
index c875e56..7b3dbb3 100644 (file)
@@ -29,7 +29,6 @@ use PDOException;
 use Exception;
 use LockManager;
 use FSLockManager;
-use InvalidArgumentException;
 use RuntimeException;
 use stdClass;
 
@@ -37,12 +36,9 @@ use stdClass;
  * @ingroup Database
  */
 class DatabaseSqlite extends Database {
-       /** @var bool Whether full text is enabled */
-       private static $fulltextEnabled = null;
-
-       /** @var string|null Directory */
+       /** @var string|null Directory for SQLite database files listed under their DB name */
        protected $dbDir;
-       /** @var string File name for SQLite database file */
+       /** @var string|null Explicit path for the SQLite database file */
        protected $dbPath;
        /** @var string Transaction mode */
        protected $trxMode;
@@ -61,49 +57,40 @@ class DatabaseSqlite extends Database {
        /** @var array List of shared database already attached to this connection */
        private $alreadyAttached = [];
 
+       /** @var bool Whether full text is enabled */
+       private static $fulltextEnabled = null;
+
        /**
         * Additional params include:
         *   - dbDirectory : directory containing the DB and the lock file directory
-        *                   [defaults to $wgSQLiteDataDir]
         *   - dbFilePath  : use this to force the path of the DB file
         *   - trxMode     : one of (deferred, immediate, exclusive)
         * @param array $p
         */
-       function __construct( array $p ) {
+       public function __construct( array $p ) {
                if ( isset( $p['dbFilePath'] ) ) {
                        $this->dbPath = $p['dbFilePath'];
-                       $lockDomain = md5( $this->dbPath );
-                       // Use "X" for things like X.sqlite and ":memory:" for RAM-only DBs
-                       if ( !isset( $p['dbname'] ) || !strlen( $p['dbname'] ) ) {
-                               $p['dbname'] = preg_replace( '/\.sqlite\d?$/', '', basename( $this->dbPath ) );
+                       if ( !strlen( $p['dbname'] ) ) {
+                               $p['dbname'] = self::generateDatabaseName( $this->dbPath );
                        }
                } elseif ( isset( $p['dbDirectory'] ) ) {
                        $this->dbDir = $p['dbDirectory'];
-                       $lockDomain = $p['dbname'];
-               } else {
-                       throw new InvalidArgumentException( "Need 'dbDirectory' or 'dbFilePath' parameter." );
                }
 
-               $this->trxMode = isset( $p['trxMode'] ) ? strtoupper( $p['trxMode'] ) : null;
-               if ( $this->trxMode &&
-                       !in_array( $this->trxMode, [ 'DEFERRED', 'IMMEDIATE', 'EXCLUSIVE' ] )
-               ) {
-                       $this->trxMode = null;
-                       $this->queryLogger->warning( "Invalid SQLite transaction mode provided." );
-               }
+               // Set a dummy user to make initConnection() trigger open()
+               parent::__construct( [ 'user' => '@' ] + $p );
 
-               if ( $this->hasProcessMemoryPath() ) {
-                       $this->lockMgr = new NullLockManager( [ 'domain' => $lockDomain ] );
-               } else {
+               $this->trxMode = strtoupper( $p['trxMode'] ?? '' );
+
+               $lockDirectory = $this->getLockFileDirectory();
+               if ( $lockDirectory !== null ) {
                        $this->lockMgr = new FSLockManager( [
-                               'domain' => $lockDomain,
-                               'lockDirectory' => is_string( $this->dbDir )
-                                       ? "{$this->dbDir}/locks"
-                                       : dirname( $this->dbPath ) . "/locks"
+                               'domain' => $this->getDomainID(),
+                               'lockDirectory' => $lockDirectory
                        ] );
+               } else {
+                       $this->lockMgr = new NullLockManager( [ 'domain' => $this->getDomainID() ] );
                }
-
-               parent::__construct( $p );
        }
 
        protected static function getAttributes() {
@@ -129,38 +116,10 @@ class DatabaseSqlite extends Database {
                return $db;
        }
 
-       protected function doInitConnection() {
-               if ( $this->dbPath !== null ) {
-                       // Standalone .sqlite file mode.
-                       $this->openFile(
-                               $this->dbPath,
-                               $this->connectionParams['dbname'],
-                               $this->connectionParams['tablePrefix']
-                       );
-               } elseif ( $this->dbDir !== null ) {
-                       // Stock wiki mode using standard file names per DB
-                       if ( strlen( $this->connectionParams['dbname'] ) ) {
-                               $this->open(
-                                       $this->connectionParams['host'],
-                                       $this->connectionParams['user'],
-                                       $this->connectionParams['password'],
-                                       $this->connectionParams['dbname'],
-                                       $this->connectionParams['schema'],
-                                       $this->connectionParams['tablePrefix']
-                               );
-                       } else {
-                               // Caller will manually call open() later?
-                               $this->connLogger->debug( __METHOD__ . ': no database opened.' );
-                       }
-               } else {
-                       throw new InvalidArgumentException( "Need 'dbDirectory' or 'dbFilePath' parameter." );
-               }
-       }
-
        /**
         * @return string
         */
-       function getType() {
+       public function getType() {
                return 'sqlite';
        }
 
@@ -169,31 +128,35 @@ class DatabaseSqlite extends Database {
         *
         * @return bool
         */
-       function implicitGroupby() {
+       public function implicitGroupby() {
                return false;
        }
 
        protected function open( $server, $user, $pass, $dbName, $schema, $tablePrefix ) {
                $this->close();
 
+               // Note that for SQLite, $server, $user, and $pass are ignored
+
                if ( $schema !== null ) {
-                       throw new DBExpectedError( $this, __CLASS__ . ": domain schemas are not supported." );
+                       throw new DBExpectedError( $this, __CLASS__ . ": cannot use schemas ('$schema')" );
                }
 
-               // Only $dbName is used, the other parameters are irrelevant for SQLite databases
-               $this->openFile( self::generateFileName( $this->dbDir, $dbName ), $dbName, $tablePrefix );
-       }
+               if ( $this->dbPath !== null ) {
+                       $path = $this->dbPath;
+               } elseif ( $this->dbDir !== null ) {
+                       $path = self::generateFileName( $this->dbDir, $dbName );
+               } else {
+                       throw new DBExpectedError( $this, __CLASS__ . ": DB path or directory required" );
+               }
 
-       /**
-        * Opens a database file
-        *
-        * @param string $fileName
-        * @param string $dbName
-        * @param string $tablePrefix
-        * @throws DBConnectionError
-        */
-       protected function openFile( $fileName, $dbName, $tablePrefix ) {
-               if ( !$this->hasProcessMemoryPath() && !is_readable( $fileName ) ) {
+               if ( !in_array( $this->trxMode, [ '', 'DEFERRED', 'IMMEDIATE', 'EXCLUSIVE' ], true ) ) {
+                       throw new DBExpectedError(
+                               $this,
+                               __CLASS__ . ": invalid transaction mode '{$this->trxMode}'"
+                       );
+               }
+
+               if ( !self::isProcessMemoryPath( $path ) && !is_readable( $path ) ) {
                        $error = "SQLite database file not readable";
                        $this->connLogger->error(
                                "Error connecting to {db_server}: {error}",
@@ -202,20 +165,17 @@ class DatabaseSqlite extends Database {
                        throw new DBConnectionError( $this, $error );
                }
 
-               $this->dbPath = $fileName;
                try {
-                       $this->conn = new PDO(
-                               "sqlite:$fileName",
+                       $conn = new PDO(
+                               "sqlite:$path",
                                '',
                                '',
                                [ PDO::ATTR_PERSISTENT => (bool)( $this->flags & self::DBO_PERSISTENT ) ]
                        );
-                       $error = 'unknown error';
+                       // Set error codes only, don't raise exceptions
+                       $conn->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_SILENT );
                } catch ( PDOException $e ) {
                        $error = $e->getMessage();
-               }
-
-               if ( !$this->conn ) {
                        $this->connLogger->error(
                                "Error connecting to {db_server}: {error}",
                                $this->getLogContext( [ 'method' => __METHOD__, 'error' => $error ] )
@@ -223,19 +183,17 @@ class DatabaseSqlite extends Database {
                        throw new DBConnectionError( $this, $error );
                }
 
-               try {
-                       // Set error codes only, don't raise exceptions
-                       $this->conn->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_SILENT );
-
-                       $this->currentDomain = new DatabaseDomain( $dbName, null, $tablePrefix );
+               $this->conn = $conn;
+               $this->currentDomain = new DatabaseDomain( $dbName, null, $tablePrefix );
 
+               try {
                        $flags = self::QUERY_IGNORE_DBO_TRX | self::QUERY_NO_RETRY;
                        // Enforce LIKE to be case sensitive, just like MySQL
                        $this->query( 'PRAGMA case_sensitive_like = 1', __METHOD__, $flags );
                        // Apply an optimizations or requirements regarding fsync() usage
                        $sync = $this->connectionVariables['synchronous'] ?? null;
                        if ( in_array( $sync, [ 'EXTRA', 'FULL', 'NORMAL', 'OFF' ], true ) ) {
-                               $this->query( "PRAGMA synchronous = $sync", __METHOD__ );
+                               $this->query( "PRAGMA synchronous = $sync", __METHOD__, $flags );
                        }
                } catch ( Exception $e ) {
                        // Connection was not fully initialized and is not safe for use
@@ -245,11 +203,25 @@ class DatabaseSqlite extends Database {
        }
 
        /**
-        * @return string SQLite DB file path
+        * @return string|null SQLite DB file path
+        * @throws DBUnexpectedError
         * @since 1.25
         */
        public function getDbFilePath() {
-               return $this->dbPath;
+               return $this->dbPath ?? self::generateFileName( $this->dbDir, $this->getDBname() );
+       }
+
+       /**
+        * @return string|null Lock file directory
+        */
+       public function getLockFileDirectory() {
+               if ( $this->dbPath !== null && !self::isProcessMemoryPath( $this->dbPath ) ) {
+                       return dirname( $this->dbPath ) . '/locks';
+               } elseif ( $this->dbDir !== null && !self::isProcessMemoryPath( $this->dbDir ) ) {
+                       return $this->dbDir . '/locks';
+               }
+
+               return null;
        }
 
        /**
@@ -265,13 +237,50 @@ class DatabaseSqlite extends Database {
        /**
         * Generates a database file name. Explicitly public for installer.
         * @param string $dir Directory where database resides
-        * @param string $dbName Database name
+        * @param string|bool $dbName Database name (or false from Database::factory, validated here)
         * @return string
+        * @throws DBUnexpectedError
         */
        public static function generateFileName( $dir, $dbName ) {
+               if ( $dir == '' ) {
+                       throw new DBUnexpectedError( null, __CLASS__ . ": no DB directory specified" );
+               } elseif ( self::isProcessMemoryPath( $dir ) ) {
+                       throw new DBUnexpectedError(
+                               null,
+                               __CLASS__ . ": cannot use process memory directory '$dir'"
+                       );
+               } elseif ( !strlen( $dbName ) ) {
+                       throw new DBUnexpectedError( null, __CLASS__ . ": no DB name specified" );
+               }
+
                return "$dir/$dbName.sqlite";
        }
 
+       /**
+        * @param string $path
+        * @return string
+        */
+       private static function generateDatabaseName( $path ) {
+               if ( preg_match( '/^(:memory:$|file::memory:)/', $path ) ) {
+                       // E.g. "file::memory:?cache=shared" => ":memory":
+                       return ':memory:';
+               } elseif ( preg_match( '/^file::([^?]+)\?mode=memory(&|$)/', $path, $m ) ) {
+                       // E.g. "file:memdb1?mode=memory" => ":memdb1:"
+                       return ":{$m[1]}:";
+               } else {
+                       // E.g. "/home/.../some_db.sqlite3" => "some_db"
+                       return preg_replace( '/\.sqlite\d?$/', '', basename( $path ) );
+               }
+       }
+
+       /**
+        * @param string $path
+        * @return bool
+        */
+       private static function isProcessMemoryPath( $path ) {
+               return preg_match( '/^(:memory:$|file:(:memory:|[^?]+\?mode=memory(&|$)))/', $path );
+       }
+
        /**
         * Check if the searchindext table is FTS enabled.
         * @return bool False if not enabled.
@@ -322,13 +331,11 @@ class DatabaseSqlite extends Database {
         * @param string $fname Calling function name
         * @return IResultWrapper
         */
-       function attachDatabase( $name, $file = false, $fname = __METHOD__ ) {
-               if ( !$file ) {
-                       $file = self::generateFileName( $this->dbDir, $name );
-               }
-               $file = $this->addQuotes( $file );
+       public function attachDatabase( $name, $file = false, $fname = __METHOD__ ) {
+               $file = is_string( $file ) ? $file : self::generateFileName( $this->dbDir, $name );
+               $encFile = $this->addQuotes( $file );
 
-               return $this->query( "ATTACH DATABASE $file AS $name", $fname );
+               return $this->query( "ATTACH DATABASE $encFile AS $name", $fname );
        }
 
        protected function isWriteQuery( $sql ) {
@@ -765,14 +772,9 @@ class DatabaseSqlite extends Database {
        }
 
        public function serverIsReadOnly() {
-               return ( !$this->hasProcessMemoryPath() && !is_writable( $this->dbPath ) );
-       }
+               $path = $this->getDbFilePath();
 
-       /**
-        * @return bool
-        */
-       private function hasProcessMemoryPath() {
-               return ( strpos( $this->dbPath, ':memory:' ) === 0 );
+               return ( !self::isProcessMemoryPath( $path ) && !is_writable( $path ) );
        }
 
        /**
@@ -813,7 +815,7 @@ class DatabaseSqlite extends Database {
        }
 
        protected function doBegin( $fname = '' ) {
-               if ( $this->trxMode ) {
+               if ( $this->trxMode != '' ) {
                        $this->query( "BEGIN {$this->trxMode}", $fname );
                } else {
                        $this->query( 'BEGIN', $fname );
@@ -967,15 +969,15 @@ class DatabaseSqlite extends Database {
        }
 
        public function lock( $lockName, $method, $timeout = 5 ) {
-               // Give better error message for permission problems than just returning false
+               $status = $this->lockMgr->lock( [ $lockName ], LockManager::LOCK_EX, $timeout );
                if (
-                       !is_dir( "{$this->dbDir}/locks" ) &&
-                       ( !is_writable( $this->dbDir ) || !mkdir( "{$this->dbDir}/locks" ) )
+                       $this->lockMgr instanceof FSLockManager &&
+                       $status->hasMessage( 'lockmanager-fail-openlock' )
                ) {
-                       throw new DBError( $this, "Cannot create directory \"{$this->dbDir}/locks\"." );
+                       throw new DBError( $this, "Cannot create directory \"{$this->getLockFileDirectory()}\"" );
                }
 
-               return $this->lockMgr->lock( [ $lockName ], LockManager::LOCK_EX, $timeout )->isOK();
+               return $status->isOK();
        }
 
        public function unlock( $lockName, $method ) {
index 3e1909b..17f89f9 100644 (file)
@@ -163,6 +163,11 @@ class BrokenRedirectsPage extends QueryPage {
                return $out;
        }
 
+       public function execute( $par ) {
+               $this->addHelpLink( 'Help:Redirects' );
+               parent::execute( $par );
+       }
+
        /**
         * Cache page content model for performance
         *
index 36928ca..6d9dc0f 100644 (file)
@@ -49,6 +49,7 @@ class SpecialComparePages extends SpecialPage {
                $this->setHeaders();
                $this->outputHeader();
                $this->getOutput()->addModuleStyles( 'mediawiki.special' );
+               $this->addHelpLink( 'Help:Diff' );
 
                $form = HTMLForm::factory( 'ooui', [
                        'Page1' => [
index 8817ba3..40d8962 100644 (file)
@@ -46,6 +46,7 @@ class DeletedContributionsPage extends SpecialPage {
                $this->setHeaders();
                $this->outputHeader();
                $this->checkPermissions();
+               $this->addHelpLink( 'Help:User contributions' );
 
                $out = $this->getOutput();
                $out->setPageTitle( $this->msg( 'deletedcontributions-title' ) );
index 77c59f0..fcf1bb2 100644 (file)
@@ -198,6 +198,11 @@ class DoubleRedirectsPage extends QueryPage {
                return ( "{$linkA} {$edit} {$arr} {$linkB} {$arr} {$linkC}" );
        }
 
+       public function execute( $par ) {
+               $this->addHelpLink( 'Help:Redirects' );
+               parent::execute( $par );
+       }
+
        /**
         * Cache page content model and gender distinction for performance
         *
index ae4b090..7f00311 100644 (file)
@@ -45,6 +45,7 @@ class SpecialListGroupRights extends SpecialPage {
 
                $out = $this->getOutput();
                $out->addModuleStyles( 'mediawiki.special' );
+               $this->addHelpLink( 'Help:User_rights_and_groups' );
 
                $out->wrapWikiMsg( "<div class=\"mw-listgrouprights-key\">\n$1\n</div>", 'listgrouprights-key' );
 
index 48f3640..3284c57 100644 (file)
@@ -145,6 +145,11 @@ class ListredirectsPage extends QueryPage {
                }
        }
 
+       public function execute( $par ) {
+               $this->addHelpLink( 'Help:Redirects' );
+               parent::execute( $par );
+       }
+
        protected function getGroupName() {
                return 'pages';
        }
index e9dca35..38c6b11 100644 (file)
@@ -38,6 +38,7 @@ class SpecialProtectedpages extends SpecialPage {
                $this->setHeaders();
                $this->outputHeader();
                $this->getOutput()->addModuleStyles( 'mediawiki.special' );
+               $this->addHelpLink( 'Help:Protected_pages' );
 
                $request = $this->getRequest();
                $type = $request->getVal( $this->IdType );
index 5dc49ea..4b0997e 100644 (file)
@@ -37,6 +37,7 @@ class SpecialProtectedtitles extends SpecialPage {
        function execute( $par ) {
                $this->setHeaders();
                $this->outputHeader();
+               $this->addHelpLink( 'Help:Protected_pages' );
 
                $request = $this->getRequest();
                $type = $request->getVal( $this->IdType );
index 110fb1f..9a95249 100644 (file)
@@ -50,6 +50,7 @@ class SpecialTags extends SpecialPage {
        function execute( $par ) {
                $this->setHeaders();
                $this->outputHeader();
+               $this->addHelpLink( 'Manual:Tags' );
 
                $request = $this->getRequest();
                switch ( $par ) {
index 1cb27a3..ed2d5cf 100644 (file)
@@ -31,6 +31,7 @@
 class UncategorizedImagesPage extends ImageQueryPage {
        function __construct( $name = 'Uncategorizedimages' ) {
                parent::__construct( $name );
+               $this->addHelpLink( 'Help:Categories' );
        }
 
        function sortDescending() {
index ab83af1..0b7da7b 100644 (file)
@@ -35,6 +35,7 @@ class UncategorizedPagesPage extends PageQueryPage {
 
        function __construct( $name = 'Uncategorizedpages' ) {
                parent::__construct( $name );
+               $this->addHelpLink( 'Help:Categories' );
        }
 
        function sortDescending() {
index 95563d2..31e4836 100644 (file)
@@ -158,6 +158,7 @@ class SpecialUndelete extends SpecialPage {
 
                $this->setHeaders();
                $this->outputHeader();
+               $this->addHelpLink( 'Help:Deletion_and_undeletion' );
 
                $this->loadRequest( $par );
                $this->checkPermissions(); // Needs to be after mTargetObj is set
index e06df9f..dff19ff 100644 (file)
@@ -159,7 +159,15 @@ class UserGroupMembership {
                }
 
                // Purge old, expired memberships from the DB
-               JobQueueGroup::singleton()->push( new UserGroupExpiryJob() );
+               $hasExpiredRow = $dbw->selectField(
+                       'user_groups',
+                       '1',
+                       [ 'ug_expiry < ' . $dbw->addQuotes( $dbw->timestamp() ) ],
+                       __METHOD__
+               );
+               if ( $hasExpiredRow ) {
+                       JobQueueGroup::singleton()->lazyPush( new UserGroupExpiryJob() );
+               }
 
                // Check that the values make sense
                if ( $this->group === null ) {
@@ -250,7 +258,7 @@ class UserGroupMembership {
                $ticket = $lbFactory->getEmptyTransactionTicket( __METHOD__ );
                $dbw = $services->getDBLoadBalancer()->getConnection( DB_MASTER );
 
-               $lockKey = $dbw->getDomainID() . ':usergroups-prune'; // specific to this wiki
+               $lockKey = "{$dbw->getDomainID()}:UserGroupMembership:purge"; // per-wiki
                $scopedLock = $dbw->getScopedLockAndFlush( $lockKey, __METHOD__, 0 );
                if ( !$scopedLock ) {
                        return false; // already running
index 1a39945..d33b6ae 100644 (file)
@@ -1120,6 +1120,11 @@ class WatchedItemStore implements WatchedItemStoreInterface, StatsdAwareInterfac
                }
 
                $oldRev = $this->revisionLookup->getRevisionById( $oldid );
+               if ( !$oldRev ) {
+                       // Oldid given but does not exist (probably deleted)
+                       return false;
+               }
+
                $nextRev = $this->revisionLookup->getNextRevision( $oldRev );
                if ( !$nextRev ) {
                        // Oldid given and is the latest revision for this title; clear the timestamp.
index 967a723..7882aaa 100644 (file)
        "delete-legend": "Выдаліць",
        "historywarning": "<strong>Папярэджаньне</strong>: старонка, якую Вы зьбіраецеся выдаліць, мае гісторыю з $1 {{PLURAL:$1|вэрсіі|вэрсіяў|вэрсіяў}}:",
        "historyaction-submit": "Паказаць вэрсіі",
-       "confirmdeletetext": "Ð\97аÑ\80аз Ð\92Ñ\8b Ð²Ñ\8bдалÑ\96Ñ\86е Ñ\81Ñ\82аÑ\80онкÑ\83 Ñ\80азам Ð· Ñ\83Ñ\81Ñ\91й Ð³Ñ\96Ñ\81Ñ\82оÑ\80Ñ\8bÑ\8fй Ð·Ñ\8cменаÑ\9e.\nÐ\9aалÑ\96 Ð»Ð°Ñ\81ка, Ð¿Ð°Ñ\86Ñ\8cвеÑ\80дзÑ\96Ñ\86е, Ñ\88Ñ\82о Ð\92Ñ\8b Ð·Ñ\8cбÑ\96Ñ\80аеÑ\86еÑ\81Ñ\8f Ð³Ñ\8dÑ\82а Ð·Ñ\80абÑ\96Ñ\86Ñ\8c Ñ\96 Ñ\88Ñ\82о Ð\92ы разумееце ўсе наступствы, а таксама робіце гэта ў адпаведнасьці з [[{{MediaWiki:Policy-url}}|правіламі]].",
+       "confirmdeletetext": "Ð\97аÑ\80аз Ð²Ñ\8b Ð²Ñ\8bдалÑ\96Ñ\86е Ñ\81Ñ\82аÑ\80онкÑ\83 Ñ\80азам Ð· Ñ\83Ñ\81Ñ\91й Ð³Ñ\96Ñ\81Ñ\82оÑ\80Ñ\8bÑ\8fй Ð·Ñ\8cменаÑ\9e.\nÐ\9aалÑ\96 Ð»Ð°Ñ\81ка, Ð¿Ð°Ñ\86Ñ\8cвеÑ\80дзÑ\96Ñ\86е, Ñ\88Ñ\82о Ð²Ñ\8b Ð·Ñ\8cбÑ\96Ñ\80аеÑ\86еÑ\81Ñ\8f Ð³Ñ\8dÑ\82а Ð·Ñ\80абÑ\96Ñ\86Ñ\8c Ñ\96 Ñ\88Ñ\82о Ð²ы разумееце ўсе наступствы, а таксама робіце гэта ў адпаведнасьці з [[{{MediaWiki:Policy-url}}|правіламі]].",
        "actioncomplete": "Дзеяньне выкананае",
        "actionfailed": "Дзеяньне ня выкананае",
        "deletedtext": "«$1» была выдаленая.\nЗапісы пра выдаленыя старонкі зьмяшчаюцца ў $2.",
index ee787a3..1c510f3 100644 (file)
        "autoblockedtext": "Deine IP-Adresse wurde automatisch gesperrt, da sie von einem anderen Benutzer genutzt wurde, der von $1 gesperrt wurde.\nAls Grund wurde angegeben:\n\n:''$2''\n\n* Beginn der Sperre: $8\n* Ende der Sperre: $6\n* Sperre betrifft: $7\n\nDu kannst $1 oder einen der anderen [[{{MediaWiki:Grouppage-sysop}}|Administratoren]] kontaktieren, um über die Sperre zu diskutieren.\n\nDu kannst die „{{int:emailuser}}“-Funktion nicht nutzen, solange keine gültige E-Mail-Adresse in deinen [[Special:Preferences|Benutzerkonto-Einstellungen]] eingetragen ist oder diese Funktion für dich gesperrt wurde.\n\nDeine aktuelle IP-Adresse ist $3, und die Sperr-ID ist $5.\nBitte füge alle Informationen jeder Anfrage hinzu, die du stellst.",
        "systemblockedtext": "Dein Benutzername oder deine IP-Adresse wurde von MediaWiki automatisch gesperrt.\nDer angegebene Grund ist:\n\n:<em>$2</em>\n\n* Beginn der Sperre: $8\n* Ablauf der Sperre: $6\n* Sperre betrifft: $7\n\nDeine aktuelle IP-Adresse ist $3.\nBitte gib alle oben stehenden Details in jeder Anfrage an.",
        "blockednoreason": "keine Begründung angegeben",
+       "blockedtext-composite": "<strong>Dein Benutzername oder deine IP-Adresse wurde gesperrt.</strong>\n\nDer Angegebene Grund ist:\n\n:<em>$2</em>\n\n* Beginn der Sperre: $8\n* Ablauf der längsten Sperre: $6\n\nDeine aktuelle IP-Adresse ist $3.\nBitte gib alle oben stehenden Details in jeder Anfrage an.",
+       "blockedtext-composite-reason": "Es gibt mehrere Sperren gegen dein Benutzerkonto und/oder deine IP-Adresse",
        "whitelistedittext": "Du musst dich $1, um Seiten bearbeiten zu können.",
        "confirmedittext": "Du musst deine E-Mail-Adresse erst bestätigen, bevor du Bearbeitungen durchführen kannst. Bitte ergänze und bestätige deine E-Mail in den [[Special:Preferences|Einstellungen]].",
        "nosuchsectiontitle": "Abschnitt nicht gefunden",
        "mw-widgets-abandonedit-title": "Bist du sicher?",
        "mw-widgets-copytextlayout-copy": "Kopieren",
        "mw-widgets-copytextlayout-copy-fail": "Der Text konnte nicht in die Zwischenablage kopiert werden.",
+       "mw-widgets-copytextlayout-copy-success": "Text in die Zwischenablage kopiert.",
        "mw-widgets-dateinput-no-date": "Kein Datum ausgewählt",
        "mw-widgets-dateinput-placeholder-day": "JJJJ-MM-TT",
        "mw-widgets-dateinput-placeholder-month": "JJJJ-MM",
        "restrictionsfield-help": "Eine IP-Adresse oder ein CIDR-Bereich pro Zeile. Um alles zu aktivieren, verwende:\n<pre>\n0.0.0.0/0\n::/0\n</pre>",
        "edit-error-short": "Fehler: $1",
        "edit-error-long": "Fehler:\n\n$1",
+       "specialmute": "Stumm",
+       "specialmute-success": "Deine Stummschaltungseinstellungen wurden aktualisiert. Schau dir alle stummgeschalteten Benutzer in [[Special:Preferences|deinen Einstellungen]] an.",
+       "specialmute-submit": "Bestätigen",
+       "specialmute-label-mute-email": "E-Mails von diesem Benutzer stummschalten",
+       "specialmute-header": "Bitte wähle deine Stummschaltungseinstellungen für {{BIDI:[[User:$1]]}}.",
+       "specialmute-error-invalid-user": "Der gesuchte Benutzername konnte nicht gefunden werden.",
+       "specialmute-error-email-blacklist-disabled": "Das Stummschalten von E-Mails von Benutzern ist nicht aktiviert.",
+       "specialmute-error-email-preferences": "Du musst deine E-Mail Adresse bestätigen bevor du einen Benutzer bestätigen kannst. Du kannst dies [[Special:Preferences|in deinen Einstellungen]] tun.",
+       "specialmute-email-footer": "Um deine E-Mail Einstellungen für {{BIDI:$2}} zu verwalten besuche bitte $1.",
+       "specialmute-login-required": "Bitte melde dich an um deine Stummschaltungseinstellungen zu ändern.",
        "revid": "Version $1",
        "pageid": "Seitenkennung $1",
        "interfaceadmin-info": "$1\n\nBerechtigungen für das Bearbeiten von wikiweiten CSS/JS/JSON-Dateien wurden kürzlich vom Recht <code>editinterface</code> getrennt. Falls du nicht verstehst, warum du diesen Fehler erhältst, siehe [[mw:MediaWiki_1.32/interface-admin]].",
index 3a4ed91..1bffe93 100644 (file)
        "logentry-pagelang-pagelang": "$1 {{GENDER:$2|promijenio|promijenila}} je jezik stranice $3 iz $4 u $5.",
        "mediastatistics": "Statistika datoteka",
        "mediastatistics-summary": "Slijede statistike postavljenih datoteka koje pokazuju zadnju inačicu datoteke. Starije ili izbrisane inačice nisu prikazane.",
-       "mediastatistics-nfiles": "$1 ($2%)",
+       "mediastatistics-nfiles": "$1 ($2 %)",
        "mediastatistics-nbytes": "{{PLURAL:$1|$1 bajt|$1 bajta|$1 bajtova}} ($2; $3 %)",
        "mediastatistics-bytespertype": "Ukupna veličina datoteka za ovaj odlomak: {{PLURAL:$1|$1 bajt|$1 bajta|$1 bajtova}} ($2; $3%).",
        "mediastatistics-allbytes": "Ukupna veličina svih datoteka: {{PLURAL:$1|$1 bajt|$1 bajta|$1 bajtova}} ($2).",
        "specialmute-success": "Vaše postavke utišavanja su uspješno ažurirane. Vidite sve utišane korisnike ovdje: [[Special:Preferences]].",
        "specialmute-submit": "Potvrdi",
        "specialmute-error-invalid-user": "Korisničko ime koje ste tražili nije moguće pronaći.",
-       "specialmute-error-email-preferences": "Morate potvrditi svoju email adresu prije nego što možete utišati ovoga korisnika. To možete učiniti putem [[Special:Preferences]].",
-       "specialmute-login-required": "Molimo Vas prijavite se da biste promijenili postavke.",
+       "specialmute-error-email-preferences": "Morate potvrditi svoju adresu e-pošte prije nego što možete utišati ovoga korisnika. To možete učiniti putem [[Special:Preferences]].",
+       "specialmute-login-required": "Molimo Vas, prijavite se da biste promijenili postavke.",
        "gotointerwiki": "Napuštate projekt {{SITENAME}}",
        "gotointerwiki-invalid": "Navedeni naslov nije valjan.",
        "gotointerwiki-external": "Napuštate projekt {{SITENAME}} da biste posjetili zasebno mrežno mjesto [[$2]].\n\n<strong>[$1 Nastavljate na $1]</strong>",
index 27d9569..107c22f 100644 (file)
        "history": "Riwayat halaman",
        "history_short": "Versi terdahulu",
        "history_small": "riwayat",
-       "updatedmarker": "diubah sejak kunjungan terakhir saya",
+       "updatedmarker": "berubah sejak kunjungan terakhir saya",
        "printableversion": "Versi cetak",
        "permalink": "Pranala permanen",
        "print": "Cetak",
        "autoblockedtext": "Alamat IP Anda telah terblokir secara otomatis karena digunakan oleh pengguna lain, yang diblokir oleh $1. Pemblokiran dilakukan dengan alasan:\n\n:<em>$2</em>\n\n* Diblokir sejak: $8\n* Blokir kedaluwarsa pada: $6\n* Sasaran pemblokiran: $7\n\nAnda dapat menghubungi $1 atau [[{{MediaWiki:Grouppage-sysop}}|pengurus]] lainnya untuk membicarakan pemblokiran ini.\n\nAnda tidak dapat menggunakan fitur \"{{int:emailuser}}\" kecuali Anda telah memasukkan alamat surel yang sah di [[Special:Preferences|preferensi akun]] Anda dan Anda tidak diblokir untuk menggunakannya.\n\nAlamat IP Anda saat ini adalah $3, dan ID pemblokiran adalah #$5.\nTolong sertakan informasi-informasi ini dalam setiap pertanyaan Anda.",
        "systemblockedtext": "Nama pengguna atau alamat IP Anda telah diblokir secara otomatis oleh MediaWiki.\nAlasan yang diberikan adalah:\n\n:<em>$2</em>\n\n* Diblokir sejak: $8\n* Blokir kedaluwarsa pada: $6\n* Sasaran pemblokiran: $7\n\nAlamat IP Anda saat ini adalah $3\nMohon sertakan semua perincian di atas dalam setiap pertanyaan yang Anda ajukan.",
        "blockednoreason": "tidak ada alasan yang diberikan",
+       "blockedtext-composite-reason": "Ada pemblokiran berganda terhadap akun Anda dan/atau alamat IP Anda.",
        "whitelistedittext": "Anda harus $1 untuk dapat menyunting halaman.",
        "confirmedittext": "Anda harus mengkonfirmasikan dulu alamat surel Anda sebelum menyunting halaman.\nHarap masukkan dan validasikan alamat surel Anda melalui [[Special:Preferences|halaman preferensi pengguna]] Anda.",
        "nosuchsectiontitle": "Bagian tidak ditemukan",
        "mw-widgets-abandonedit-discard": "Buang suntingan",
        "mw-widgets-abandonedit-keep": "Lanjutkan penyuntingan",
        "mw-widgets-abandonedit-title": "Apakah Anda yakin?",
+       "mw-widgets-copytextlayout-copy": "Salin",
+       "mw-widgets-copytextlayout-copy-fail": "Gagal menyalin ke papan klip.",
+       "mw-widgets-copytextlayout-copy-success": "Salin ke papan klip.",
        "mw-widgets-dateinput-no-date": "Tanggal tidak ada yang terpilih",
        "mw-widgets-dateinput-placeholder-day": "TTTT-BB-HH",
        "mw-widgets-dateinput-placeholder-month": "TTTT-BB",
        "restrictionsfield-help": "Satu alamat IP atau rentang CIDR per baris. Untuk mengaktifkan semuanya, gunakan:\n<pre>0.0.0.0/0\n::/0</pre>",
        "edit-error-short": "Galat: $1",
        "edit-error-long": "Galat:\n\n$1",
+       "specialmute": "Diam",
+       "specialmute-submit": "Konfirmasi",
        "revid": "revisi $1",
        "pageid": "ID halaman $1",
        "rawhtml-notallowed": "Tag &lt;html&gt; tidak dapat digunakan di luar halaman normal.",
index 7a5671a..3783e26 100644 (file)
        "log-action-filter-suppress-block": "Сокрытие пользователя через блокировки",
        "log-action-filter-suppress-reblock": "Сокрытие пользователя через повторное блокирование",
        "log-action-filter-upload-upload": "Новая загрузка",
-       "log-action-filter-upload-overwrite": "Ð\9fовÑ\82оÑ\80но Ð·Ð°Ð³Ñ\80Ñ\83зиÑ\82Ñ\8c",
-       "log-action-filter-upload-revert": "Ð\9eÑ\82каÑ\82иÑ\82Ñ\8c",
+       "log-action-filter-upload-overwrite": "Ð\9fеÑ\80езапиÑ\81Ñ\8c Ñ\84айла",
+       "log-action-filter-upload-revert": "Ð\92озвÑ\80аÑ\82 Ñ\81Ñ\82аÑ\80ой Ð²ÐµÑ\80Ñ\81ии Ñ\84айла",
        "authmanager-authn-not-in-progress": "Проверка подлинности не выполняется или данные сессии были утеряны. Пожалуйста, начните снова с самого начала.",
        "authmanager-authn-no-primary": "Предоставленные учётные данные не могут быть проверены на подлинность.",
        "authmanager-authn-no-local-user": "Предоставленные учётные данные не связаны ни с одним участником этой вики.",
index 33ff4f3..db88999 100644 (file)
        "revertmerge": "растави",
        "mergelogpagetext": "Испод се налази списак најновијих обједињавања историја једне странице у другу.",
        "history-title": "Историја измена странице „$1”",
-       "difference-title": "Разлика између измена на страници „$1”",
+       "difference-title": "$1 — разлика између измена",
        "difference-title-multipage": "Разлика између страница „$1“ и „$2“",
        "difference-multipage": "(разлике између страница)",
        "lineno": "Ред $1:",
        "svg-long-desc": "SVG датотека, номинално $1 × $2 пиксела, величина: $3",
        "svg-long-desc-animated": "Анимирана SVG датотека, номинално: $1 × $2 пиксела, величина: $3",
        "svg-long-error": "Неважећа SVG датотека: $1",
-       "show-big-image": "Ð\9fÑ\80вобиÑ\82на датотека",
+       "show-big-image": "Ð\9eÑ\80игинална датотека",
        "show-big-image-preview": "Величина овог приказа: $1.",
        "show-big-image-preview-differ": "Величина $3 прегледа за ову $2 датотеку је $1.",
        "show-big-image-other": "$2 {{PLURAL:$2|друга резолуција|друге резолуције|других резолуција}}: $1.",
index 9ba5bf5..1142325 100644 (file)
@@ -121,7 +121,6 @@ class CopyFileBackend extends Maintenance {
                        }
                        if ( count( $batchPaths ) ) { // left-overs
                                $this->copyFileBatch( array_keys( $batchPaths ), $backendRel, $src, $dst );
-                               $batchPaths = []; // done
                        }
                        $this->output( "\tCopied $count file(s).\n" );
 
@@ -148,7 +147,6 @@ class CopyFileBackend extends Maintenance {
                                }
                                if ( count( $batchPaths ) ) { // left-overs
                                        $this->delFileBatch( array_keys( $batchPaths ), $backendRel, $dst );
-                                       $batchPaths = []; // done
                                }
 
                                $this->output( "\tDeleted $count file(s).\n" );
@@ -212,7 +210,7 @@ class CopyFileBackend extends Maintenance {
                $ops = [];
                $fsFiles = [];
                $copiedRel = []; // for output message
-               $wikiId = $src->getWikiId();
+               $domainId = $src->getDomainId();
 
                // Download the batch of source files into backend cache...
                if ( $this->hasOption( 'missingonly' ) ) {
@@ -232,7 +230,7 @@ class CopyFileBackend extends Maintenance {
                        $srcPath = $src->getRootStoragePath() . "/$backendRel/$srcPathRel";
                        $dstPath = $dst->getRootStoragePath() . "/$backendRel/$srcPathRel";
                        if ( $this->hasOption( 'utf8only' ) && !mb_check_encoding( $srcPath, 'UTF-8' ) ) {
-                               $this->error( "$wikiId: Detected illegal (non-UTF8) path for $srcPath." );
+                               $this->error( "$domainId: Detected illegal (non-UTF8) path for $srcPath." );
                                continue;
                        } elseif ( !$this->hasOption( 'missingonly' )
                                && $this->filesAreSame( $src, $dst, $srcPath, $dstPath )
@@ -246,24 +244,24 @@ class CopyFileBackend extends Maintenance {
                        if ( !$fsFile ) {
                                $src->clearCache( [ $srcPath ] );
                                if ( $src->fileExists( [ 'src' => $srcPath, 'latest' => 1 ] ) === false ) {
-                                       $this->error( "$wikiId: File '$srcPath' was listed but does not exist." );
+                                       $this->error( "$domainId: File '$srcPath' was listed but does not exist." );
                                } else {
-                                       $this->error( "$wikiId: Could not get local copy of $srcPath." );
+                                       $this->error( "$domainId: Could not get local copy of $srcPath." );
                                }
                                continue;
                        } elseif ( !$fsFile->exists() ) {
                                // FSFileBackends just return the path for getLocalReference() and paths with
                                // illegal slashes may get normalized to a different path. This can cause the
                                // local reference to not exist...skip these broken files.
-                               $this->error( "$wikiId: Detected possible illegal path for $srcPath." );
+                               $this->error( "$domainId: Detected possible illegal path for $srcPath." );
                                continue;
                        }
                        $fsFiles[] = $fsFile; // keep TempFSFile objects alive as needed
                        // Note: prepare() is usually fast for key/value backends
                        $status = $dst->prepare( [ 'dir' => dirname( $dstPath ), 'bypassReadOnly' => 1 ] );
                        if ( !$status->isOK() ) {
-                               $this->error( print_r( $status->getErrorsArray(), true ) );
-                               $this->fatalError( "$wikiId: Could not copy $srcPath to $dstPath." );
+                               $this->error( print_r( Status::wrap( $status )->getWikiText(), true ) );
+                               $this->fatalError( "$domainId: Could not copy $srcPath to $dstPath." );
                        }
                        $ops[] = [ 'op' => 'store',
                                'src' => $fsFile->getPath(), 'dst' => $dstPath, 'overwrite' => 1 ];
@@ -279,8 +277,8 @@ class CopyFileBackend extends Maintenance {
                }
                $elapsed_ms = floor( ( microtime( true ) - $t_start ) * 1000 );
                if ( !$status->isOK() ) {
-                       $this->error( print_r( $status->getErrorsArray(), true ) );
-                       $this->fatalError( "$wikiId: Could not copy file batch." );
+                       $this->error( print_r( Status::wrap( $status )->getWikiText(), true ) );
+                       $this->fatalError( "$domainId: Could not copy file batch." );
                } elseif ( count( $copiedRel ) ) {
                        $this->output( "\n\tCopied these file(s) [{$elapsed_ms}ms]:\n\t" .
                                implode( "\n\t", $copiedRel ) . "\n\n" );
@@ -298,7 +296,7 @@ class CopyFileBackend extends Maintenance {
        ) {
                $ops = [];
                $deletedRel = []; // for output message
-               $wikiId = $dst->getWikiId();
+               $domainId = $dst->getDomainId();
 
                // Determine what files need to be copied over...
                foreach ( $dstPathsRel as $dstPathRel ) {
@@ -316,8 +314,8 @@ class CopyFileBackend extends Maintenance {
                }
                $elapsed_ms = floor( ( microtime( true ) - $t_start ) * 1000 );
                if ( !$status->isOK() ) {
-                       $this->error( print_r( $status->getErrorsArray(), true ) );
-                       $this->fatalError( "$wikiId: Could not delete file batch." );
+                       $this->error( print_r( Status::wrap( $status )->getWikiText(), true ) );
+                       $this->fatalError( "$domainId: Could not delete file batch." );
                } elseif ( count( $deletedRel ) ) {
                        $this->output( "\n\tDeleted these file(s) [{$elapsed_ms}ms]:\n\t" .
                                implode( "\n\t", $deletedRel ) . "\n\n" );
index 93614e0..5ff3af1 100644 (file)
@@ -149,7 +149,7 @@ class CreateAndPromote extends Maintenance {
 
                if ( !$exists ) {
                        # Increment site_stats.ss_users
-                       $ssu = new SiteStatsUpdate( 0, 0, 0, 0, 1 );
+                       $ssu = SiteStatsUpdate::factory( [ 'users' => 1 ] );
                        $ssu->doUpdate();
                }
 
index ba0fe5d..9e0971a 100644 (file)
@@ -92,7 +92,11 @@ class NukePage extends Maintenance {
                        if ( $delete ) {
                                $this->output( "Updating site stats..." );
                                $ga = $isGoodArticle ? -1 : 0; // if it was good, decrement that too
-                               $stats = new SiteStatsUpdate( 0, -$count, $ga, -1 );
+                               $stats = SiteStatsUpdate::factory( [
+                                       'edits' => -$count,
+                                       'articles' => $ga,
+                                       'pages' => -1
+                               ] );
                                $stats->doUpdate();
                                $this->output( "done.\n" );
                        }
index 7147c6f..0c8dc68 100644 (file)
@@ -69,6 +69,21 @@ class MapCacheLRUTest extends PHPUnit\Framework\TestCase {
                );
        }
 
+       /**
+        * @covers MapCacheLRU::has()
+        * @covers MapCacheLRU::get()
+        * @covers MapCacheLRU::set()
+        */
+       function testMissing() {
+               $raw = [ 'a' => 1, 'b' => 2, 'c' => 3 ];
+               $cache = MapCacheLRU::newFromArray( $raw, 3 );
+
+               $this->assertFalse( $cache->has( 'd' ) );
+               $this->assertNull( $cache->get( 'd' ) );
+               $this->assertNull( $cache->get( 'd', 0.0, null ) );
+               $this->assertFalse( $cache->get( 'd', 0.0, false ) );
+       }
+
        /**
         * @covers MapCacheLRU::has()
         * @covers MapCacheLRU::get()
diff --git a/tests/phpunit/includes/libs/rdbms/resultwrapper/FakeResultWrapperTest.php b/tests/phpunit/includes/libs/rdbms/resultwrapper/FakeResultWrapperTest.php
new file mode 100644 (file)
index 0000000..cecdc71
--- /dev/null
@@ -0,0 +1,69 @@
+<?php
+
+/**
+ * Holds tests for FakeResultWrapper MediaWiki class.
+ *
+ * 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
+ */
+
+use Wikimedia\Rdbms\FakeResultWrapper;
+
+/**
+ * @group Database
+ * @covers \Wikimedia\Rdbms\FakeResultWrapper
+ */
+class FakeResultWrapperTest extends PHPUnit\Framework\TestCase {
+       public function testIteration() {
+               $res = new FakeResultWrapper( [
+                       [ 'colA' => 1, 'colB' => 'a' ],
+                       [ 'colA' => 2, 'colB' => 'b' ],
+                       (object)[ 'colA' => 3, 'colB' => 'c' ],
+                       [ 'colA' => 4, 'colB' => 'd' ],
+                       [ 'colA' => 5, 'colB' => 'e' ],
+                       (object)[ 'colA' => 6, 'colB' => 'f' ],
+                       (object)[ 'colA' => 7, 'colB' => 'g' ],
+                       [ 'colA' => 8, 'colB' => 'h' ]
+               ] );
+
+               $expectedRows = [
+                       0 => (object)[ 'colA' => 1, 'colB' => 'a' ],
+                       1 => (object)[ 'colA' => 2, 'colB' => 'b' ],
+                       2 => (object)[ 'colA' => 3, 'colB' => 'c' ],
+                       3 => (object)[ 'colA' => 4, 'colB' => 'd' ],
+                       4 => (object)[ 'colA' => 5, 'colB' => 'e' ],
+                       5 => (object)[ 'colA' => 6, 'colB' => 'f' ],
+                       6 => (object)[ 'colA' => 7, 'colB' => 'g' ],
+                       7 => (object)[ 'colA' => 8, 'colB' => 'h' ]
+               ];
+
+               $this->assertEquals( 8, $res->numRows() );
+
+               $res->seek( 7 );
+               $this->assertEquals( [ 'colA' => 8, 'colB' => 'h' ], $res->fetchRow() );
+               $res->seek( 7 );
+               $this->assertEquals( (object)[ 'colA' => 8, 'colB' => 'h' ], $res->fetchObject() );
+
+               $this->assertEquals( $expectedRows, iterator_to_array( $res, true ) );
+
+               $rows = [];
+               foreach ( $res as $i => $row ) {
+                       $rows[$i] = $row;
+               }
+               $this->assertEquals( $expectedRows, $rows );
+       }
+}
diff --git a/tests/phpunit/includes/libs/rdbms/resultwrapper/ResultWrapperTest.php b/tests/phpunit/includes/libs/rdbms/resultwrapper/ResultWrapperTest.php
new file mode 100644 (file)
index 0000000..ae61966
--- /dev/null
@@ -0,0 +1,112 @@
+<?php
+
+/**
+ * Holds tests for ResultWrapper MediaWiki class.
+ *
+ * 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
+ */
+
+use Wikimedia\Rdbms\IDatabase;
+use Wikimedia\Rdbms\ResultWrapper;
+
+/**
+ * @group Database
+ * @covers \Wikimedia\Rdbms\ResultWrapper
+ */
+class ResultWrapperTest extends PHPUnit\Framework\TestCase {
+       /**
+        * @return IDatabase
+        * @param array[] $rows
+        */
+       private function getDatabaseMock( array $rows ) {
+               $db = $this->getMockBuilder( IDatabase::class )
+                       ->disableOriginalConstructor()
+                       ->getMock();
+               $db->method( 'select' )->willReturnCallback(
+                       function () use ( $db, $rows ) {
+                               return new ResultWrapper( $db, $rows );
+                       }
+               );
+               $db->method( 'dataSeek' )->willReturnCallback(
+                       function ( ResultWrapper $res, $pos ) use ( $db ) {
+                               // Position already set in ResultWrapper
+                       }
+               );
+               $db->method( 'fetchRow' )->willReturnCallback(
+                       function ( ResultWrapper $res ) use ( $db ) {
+                               $row = $res::unwrap( $res )[$res->key()] ?? false;
+
+                               return $row;
+                       }
+               );
+               $db->method( 'fetchObject' )->willReturnCallback(
+                       function ( ResultWrapper $res ) use ( $db ) {
+                               $row = $res::unwrap( $res )[$res->key()] ?? false;
+
+                               return $row ? (object)$row : false;
+                       }
+               );
+               $db->method( 'numRows' )->willReturnCallback(
+                       function ( ResultWrapper $res ) use ( $db ) {
+                               return count( $res::unwrap( $res ) );
+                       }
+               );
+
+               return $db;
+       }
+
+       public function testIteration() {
+               $db = $this->getDatabaseMock( [
+                       [ 'colA' => 1, 'colB' => 'a' ],
+                       [ 'colA' => 2, 'colB' => 'b' ],
+                       [ 'colA' => 3, 'colB' => 'c' ],
+                       [ 'colA' => 4, 'colB' => 'd' ],
+                       [ 'colA' => 5, 'colB' => 'e' ],
+                       [ 'colA' => 6, 'colB' => 'f' ],
+                       [ 'colA' => 7, 'colB' => 'g' ],
+                       [ 'colA' => 8, 'colB' => 'h' ]
+               ] );
+
+               $expectedRows = [
+                       0 => (object)[ 'colA' => 1, 'colB' => 'a' ],
+                       1 => (object)[ 'colA' => 2, 'colB' => 'b' ],
+                       2 => (object)[ 'colA' => 3, 'colB' => 'c' ],
+                       3 => (object)[ 'colA' => 4, 'colB' => 'd' ],
+                       4 => (object)[ 'colA' => 5, 'colB' => 'e' ],
+                       5 => (object)[ 'colA' => 6, 'colB' => 'f' ],
+                       6 => (object)[ 'colA' => 7, 'colB' => 'g' ],
+                       7 => (object)[ 'colA' => 8, 'colB' => 'h' ]
+               ];
+
+               $res = $db->select( 'faketable', [ 'colA', 'colB' ], '1 = 1', __METHOD__ );
+               $this->assertEquals( 8, $res->numRows() );
+
+               $res->seek( 7 );
+               $this->assertEquals( [ 'colA' => 8, 'colB' => 'h' ], $res->fetchRow() );
+               $res->seek( 7 );
+               $this->assertEquals( (object)[ 'colA' => 8, 'colB' => 'h' ], $res->fetchObject() );
+
+               $this->assertEquals( $expectedRows, iterator_to_array( $res, true ) );
+
+               $rows = [];
+               foreach ( $res as $i => $row ) {
+                       $rows[$i] = $row;
+               }
+               $this->assertEquals( $expectedRows, $rows );
+       }
+}