Merge "maintenance: Avoid usage of deprecated Revision::* constants"
authorjenkins-bot <jenkins-bot@gerrit.wikimedia.org>
Sun, 21 Jul 2019 12:22:09 +0000 (12:22 +0000)
committerGerrit Code Review <gerrit@wikimedia.org>
Sun, 21 Jul 2019 12:22:09 +0000 (12:22 +0000)
60 files changed:
RELEASE-NOTES-1.34
autoload.php
docs/hooks.txt
includes/DefaultSettings.php
includes/DevelopmentSettings.php
includes/GlobalFunctions.php
includes/Permissions/PermissionManager.php
includes/ServiceWiring.php
includes/TitleArrayFromResult.php
includes/api/ApiCSPReport.php
includes/cache/MessageCache.php
includes/deferred/DeferredUpdates.php
includes/htmlform/fields/HTMLNamespacesMultiselectField.php
includes/import/ImportableUploadRevisionImporter.php
includes/import/WikiRevision.php
includes/installer/Installer.php
includes/libs/objectcache/APCBagOStuff.php
includes/libs/objectcache/APCUBagOStuff.php
includes/libs/objectcache/BagOStuff.php
includes/libs/objectcache/CachedBagOStuff.php
includes/libs/objectcache/EmptyBagOStuff.php
includes/libs/objectcache/HashBagOStuff.php
includes/libs/objectcache/MediumSpecificBagOStuff.php [new file with mode: 0644]
includes/libs/objectcache/MemcachedBagOStuff.php
includes/libs/objectcache/MemcachedPeclBagOStuff.php
includes/libs/objectcache/MemcachedPhpBagOStuff.php
includes/libs/objectcache/MultiWriteBagOStuff.php
includes/libs/objectcache/RESTBagOStuff.php
includes/libs/objectcache/RedisBagOStuff.php
includes/libs/objectcache/ReplicatedBagOStuff.php
includes/libs/objectcache/WANObjectCache.php
includes/libs/objectcache/WinCacheBagOStuff.php
includes/objectcache/SqlBagOStuff.php
includes/resourceloader/MessageBlobStore.php
includes/resourceloader/ResourceLoaderFileModule.php
includes/specialpage/AuthManagerSpecialPage.php
includes/specials/SpecialMute.php
includes/upload/UploadBase.php
includes/upload/UploadFromChunks.php
languages/i18n/en.json
languages/i18n/qqq.json
maintenance/mctest.php
resources/Resources.php
tests/phpunit/MediaWikiUnitTestCase.php
tests/phpunit/includes/GlobalFunctions/GlobalTest.php
tests/phpunit/includes/Permissions/PermissionManagerTest.php
tests/phpunit/includes/RevisionDbTestBase.php
tests/phpunit/includes/api/ApiCSPReportTest.php [new file with mode: 0644]
tests/phpunit/includes/block/BlockManagerTest.php
tests/phpunit/includes/cache/MessageCacheTest.php
tests/phpunit/includes/libs/objectcache/BagOStuffTest.php
tests/phpunit/includes/libs/objectcache/WANObjectCacheTest.php
tests/phpunit/includes/resourceloader/ResourceLoaderFilePathTest.php [deleted file]
tests/phpunit/includes/session/TestBagOStuff.php
tests/phpunit/includes/specials/SpecialMuteTest.php
tests/phpunit/unit/includes/SiteConfigurationTest.php
tests/phpunit/unit/includes/resourceloader/ResourceLoaderFilePathTest.php [new file with mode: 0644]
tests/selenium/wdio-mediawiki/CHANGELOG.md
tests/selenium/wdio-mediawiki/README.md
tests/selenium/wdio-mediawiki/package.json

index a92b5c2..57d635e 100644 (file)
@@ -36,6 +36,9 @@ For notes on 1.33.x and older releases, see HISTORY.
 * $wgEnableSpecialMute (T218265) - This configuration controls whether
   Special:Mute is available and whether to include a link to it on emails
   originating from Special:Email.
+* editmyuserjsredirect user right – users without this right now cannot edit JS
+  redirects in their userspace unless the target of the redirect is also in
+  their userspace. By default, this right is given to everyone.
 
 ==== Changed configuration ====
 * $wgUseCdn, $wgCdnServers, $wgCdnServersNoPurge, and $wgCdnMaxAge – These four
@@ -77,6 +80,8 @@ For notes on 1.33.x and older releases, see HISTORY.
   of headers in private wikis.
 * Language::formatTimePeriod now supports the new 'avoidhours' option to output
   strings like "5 days ago" instead of "5 days 13 hours ago".
+* (T220163) Added SpecialMuteModifyFormFields hook to allow extensions
+  to add fields to Special:Mute.
 
 === External library changes in 1.34 ===
 
@@ -300,6 +305,11 @@ because of Phabricator reports.
   deprecated since 1.33.
 * The static properties mw.Api.errors and mw.Api.warnings, deprecated in 1.29,
   have been removed.
+* The UploadVerification hook, deprecated in 1.28, has been removed. Instead,
+  use the UploadVerifyFile hook.
+* UploadBase:: and UploadFromChunks::stashFileGetKey() and stashSession(),
+  deprecated in 1.28, have been removed. Instead, please use the getFileKey()
+  method on the response from doStashFile().
 * …
 
 === Deprecations in 1.34 ===
index 9f9f1a6..5410bb8 100644 (file)
@@ -968,6 +968,7 @@ $wgAutoloadLocalClasses = [
        'MediaWiki\\Widget\\TitlesMultiselectWidget' => __DIR__ . '/includes/widget/TitlesMultiselectWidget.php',
        'MediaWiki\\Widget\\UserInputWidget' => __DIR__ . '/includes/widget/UserInputWidget.php',
        'MediaWiki\\Widget\\UsersMultiselectWidget' => __DIR__ . '/includes/widget/UsersMultiselectWidget.php',
+       'MediumSpecificBagOStuff' => __DIR__ . '/includes/libs/objectcache/MediumSpecificBagOStuff.php',
        'MemcLockManager' => __DIR__ . '/includes/libs/lockmanager/MemcLockManager.php',
        'MemcachedBagOStuff' => __DIR__ . '/includes/libs/objectcache/MemcachedBagOStuff.php',
        'MemcachedClient' => __DIR__ . '/includes/libs/objectcache/MemcachedClient.php',
index 36e0891..8e274ed 100644 (file)
@@ -3200,6 +3200,10 @@ $request: WebRequest object for getting the value provided by the current user
 &$oldTitle: old title (object)
 &$newTitle: new title (object)
 
+'SpecialMuteModifyFormFields': Add more fields to Special:Mute
+$sp: SpecialPage object, for context
+&$fields: Current HTMLForm fields descriptors
+
 'SpecialNewpagesConditions': Called when building sql query for
 Special:NewPages.
 &$special: NewPagesPager object (subclass of ReverseChronologicalPager)
@@ -3565,14 +3569,6 @@ $props: (array|null) File properties, as returned by
   MessageSpecifier instance (you might want to use ApiMessage to provide machine
   -readable details for the API).
 
-'UploadVerification': DEPRECATED since 1.28! Use UploadVerifyFile instead.
-Additional chances to reject an uploaded file.
-$saveName: (string) destination file name
-$tempName: (string) filesystem path to the temporary file for checks
-&$error: (string) output: message key for message to show if upload canceled by
-  returning false. May also be an array, where the first element is the message
-  key and the remaining elements are used as parameters to the message.
-
 'UploadVerifyFile': extra file verification, based on MIME type, etc. Preferred
 in most cases over UploadVerification.
 $upload: (object) an instance of UploadBase, with all info about the upload
index 3bfc8f8..107c546 100644 (file)
@@ -5173,6 +5173,7 @@ $wgGroupPermissions['user']['minoredit'] = true;
 $wgGroupPermissions['user']['editmyusercss'] = true;
 $wgGroupPermissions['user']['editmyuserjson'] = true;
 $wgGroupPermissions['user']['editmyuserjs'] = true;
+$wgGroupPermissions['user']['editmyuserjsredirect'] = true;
 $wgGroupPermissions['user']['purge'] = true;
 $wgGroupPermissions['user']['sendemail'] = true;
 $wgGroupPermissions['user']['applychangetags'] = true;
index d2f26b3..dac9d65 100644 (file)
@@ -57,3 +57,6 @@ unset( $logDir );
 // Disable rate-limiting to allow integration tests to run unthrottled
 // in CI and for devs locally (T225796)
 $wgRateLimits = [];
+
+// Disable legacy javascript globals in CI and for devs (T72470)
+$wgLegacyJavaScriptGlobals = true;
index c6c386c..7b4b502 100644 (file)
@@ -2521,6 +2521,7 @@ function wfForeignMemcKey( $db, $prefix, ...$args ) {
  * @return string
  */
 function wfGlobalCacheKey( ...$args ) {
+       wfDeprecated( __METHOD__, '1.30' );
        return ObjectCache::getLocalClusterInstance()->makeGlobalKey( ...$args );
 }
 
index 5a3dae3..a04b29c 100644 (file)
@@ -23,6 +23,8 @@ use Action;
 use Exception;
 use Hooks;
 use MediaWiki\Linker\LinkTarget;
+use MediaWiki\Revision\RevisionLookup;
+use MediaWiki\Revision\RevisionRecord;
 use MediaWiki\Session\SessionManager;
 use MediaWiki\Special\SpecialPageFactory;
 use MediaWiki\User\UserIdentity;
@@ -55,6 +57,9 @@ class PermissionManager {
        /** @var SpecialPageFactory */
        private $specialPageFactory;
 
+       /** @var RevisionLookup */
+       private $revisionLookup;
+
        /** @var string[] List of pages names anonymous user may see */
        private $whitelistRead;
 
@@ -130,6 +135,7 @@ class PermissionManager {
                'editmyusercss',
                'editmyuserjson',
                'editmyuserjs',
+               'editmyuserjsredirect',
                'editmywatchlist',
                'editsemiprotected',
                'editsitecss',
@@ -184,6 +190,7 @@ class PermissionManager {
 
        /**
         * @param SpecialPageFactory $specialPageFactory
+        * @param RevisionLookup $revisionLookup
         * @param string[] $whitelistRead
         * @param string[] $whitelistReadRegexp
         * @param bool $emailConfirmToEdit
@@ -195,6 +202,7 @@ class PermissionManager {
         */
        public function __construct(
                SpecialPageFactory $specialPageFactory,
+               RevisionLookup $revisionLookup,
                $whitelistRead,
                $whitelistReadRegexp,
                $emailConfirmToEdit,
@@ -205,6 +213,7 @@ class PermissionManager {
                NamespaceInfo $nsInfo
        ) {
                $this->specialPageFactory = $specialPageFactory;
+               $this->revisionLookup = $revisionLookup;
                $this->whitelistRead = $whitelistRead;
                $this->whitelistReadRegexp = $whitelistReadRegexp;
                $this->emailConfirmToEdit = $emailConfirmToEdit;
@@ -1134,6 +1143,20 @@ class PermissionManager {
                                && !$user->isAllowedAny( 'editmyuserjs', 'edituserjs' )
                        ) {
                                $errors[] = [ 'mycustomjsprotected', $action ];
+                       } elseif (
+                               $page->isUserJsConfigPage()
+                               && !$user->isAllowedAny( 'edituserjs', 'editmyuserjsredirect' )
+                       ) {
+                               // T207750 - do not allow users to edit a redirect if they couldn't edit the target
+                               $rev = $this->revisionLookup->getRevisionByTitle( $page );
+                               $content = $rev ? $rev->getContent( 'main', RevisionRecord::RAW ) : null;
+                               $target = $content ? $content->getUltimateRedirectTarget() : null;
+                               if ( $target && (
+                                               !$target->inNamespace( NS_USER )
+                                               || !preg_match( '/^' . preg_quote( $user->getName(), '/' ) . '\//', $target->getText() )
+                               ) ) {
+                                       $errors[] = [ 'mycustomjsredirectprotected', $action ];
+                               }
                        }
                } else {
                        // Users need editmyuser* to edit their own CSS/JSON/JS subpages, except for
index 339c34c..1bb848f 100644 (file)
@@ -465,6 +465,7 @@ return [
                $config = $services->getMainConfig();
                return new PermissionManager(
                        $services->getSpecialPageFactory(),
+                       $services->getRevisionLookup(),
                        $config->get( 'WhitelistRead' ),
                        $config->get( 'WhitelistReadRegexp' ),
                        $config->get( 'EmailConfirmToEdit' ),
index ee60f7b..80fdf9d 100644 (file)
@@ -41,7 +41,7 @@ class TitleArrayFromResult extends TitleArray implements Countable {
        }
 
        /**
-        * @param bool|IResultWrapper $row
+        * @param bool|stdClass $row
         * @return void
         */
        protected function setCurrent( $row ) {
index f53d2b9..be2da34 100644 (file)
@@ -54,7 +54,7 @@ class ApiCSPReport extends ApiBase {
                        // XXX Is it ok to put untrusted data into log??
                        'csp-report' => $report,
                        'method' => __METHOD__,
-                       'user_id' => $this->getUser()->getId() || 'logged-out',
+                       'user_id' => $this->getUser()->getId() ?: 'logged-out',
                        'user-agent' => $userAgent,
                        'source' => $this->getParameter( 'source' ),
                ] );
index b0716b1..a8bcfc6 100644 (file)
@@ -525,9 +525,8 @@ class MessageCache {
                        __METHOD__ . "($code)-big"
                );
                foreach ( $res as $row ) {
-                       $name = $this->contLang->lcfirst( $row->page_title );
                        // Include entries/stubs for all keys in $mostused in adaptive mode
-                       if ( $wgAdaptiveMessageCache || $this->isMainCacheable( $name, $overridable ) ) {
+                       if ( $wgAdaptiveMessageCache || $this->isMainCacheable( $row->page_title, $overridable ) ) {
                                $cache[$row->page_title] = '!TOO BIG';
                        }
                        // At least include revision ID so page changes are reflected in the hash
@@ -549,9 +548,8 @@ class MessageCache {
                        $revQuery['joins']
                );
                foreach ( $res as $row ) {
-                       $name = $this->contLang->lcfirst( $row->page_title );
                        // Include entries/stubs for all keys in $mostused in adaptive mode
-                       if ( $wgAdaptiveMessageCache || $this->isMainCacheable( $name, $overridable ) ) {
+                       if ( $wgAdaptiveMessageCache || $this->isMainCacheable( $row->page_title, $overridable ) ) {
                                try {
                                        $rev = $revisionStore->newRevisionFromRow( $row );
                                        $content = $rev->getContent( MediaWiki\Revision\SlotRecord::MAIN );
@@ -592,14 +590,17 @@ class MessageCache {
        }
 
        /**
-        * @param string $name Message name with lowercase first letter
+        * @param string $name Message name (possibly with /code suffix)
         * @param array $overridable Map of (key => unused) for software-defined messages
         * @return bool
         */
        private function isMainCacheable( $name, array $overridable ) {
+               // Convert first letter to lowercase, and strip /code suffix
+               $name = $this->contLang->lcfirst( $name );
+               $msg = preg_replace( '/\/[a-z0-9-]{2,}$/', '', $name );
                // Include common conversion table pages. This also avoids problems with
                // Installer::parse() bailing out due to disallowed DB queries (T207979).
-               return ( isset( $overridable[$name] ) || strpos( $name, 'conversiontable/' ) === 0 );
+               return ( isset( $overridable[$msg] ) || strpos( $name, 'conversiontable/' ) === 0 );
        }
 
        /**
@@ -1069,8 +1070,7 @@ class MessageCache {
                        );
                } else {
                        // Message page either does not exist or does not override a software message
-                       $name = $this->contLang->lcfirst( $title );
-                       if ( !$this->isMainCacheable( $name, $this->overridable ) ) {
+                       if ( !$this->isMainCacheable( $title, $this->overridable ) ) {
                                // Message page does not override any software-defined message. A custom
                                // message might be defined to have content or settings specific to the wiki.
                                // Load the message page, utilizing the individual message cache as needed.
index c754cff..d43ffbc 100644 (file)
@@ -28,6 +28,7 @@ use MediaWiki\MediaWikiServices;
 use Wikimedia\Rdbms\LBFactory;
 use Wikimedia\Rdbms\ILBFactory;
 use Wikimedia\Rdbms\LoadBalancer;
+use Wikimedia\Rdbms\DBTransactionError;
 
 /**
  * Class for managing the deferred updates
@@ -352,28 +353,30 @@ class DeferredUpdates {
         * @since 1.34
         */
        public static function attemptUpdate( DeferrableUpdate $update, ILBFactory $lbFactory ) {
+               $ticket = $lbFactory->getEmptyTransactionTicket( __METHOD__ );
+               if ( !$ticket || $lbFactory->hasTransactionRound() ) {
+                       throw new DBTransactionError( null, "A database transaction round is pending." );
+               }
+
                if ( $update instanceof DataUpdate ) {
-                       $update->setTransactionTicket( $lbFactory->getEmptyTransactionTicket( __METHOD__ ) );
+                       $update->setTransactionTicket( $ticket );
                }
 
-               if (
+               $fnameTrxOwner = get_class( $update ) . '::doUpdate';
+               $useExplicitTrxRound = !(
                        $update instanceof TransactionRoundAwareUpdate &&
                        $update->getTransactionRoundRequirement() == $update::TRX_ROUND_ABSENT
-               ) {
-                       $fnameTrxOwner = null;
+               );
+               // Flush any pending changes left over from an implicit transaction round
+               if ( $useExplicitTrxRound ) {
+                       $lbFactory->beginMasterChanges( $fnameTrxOwner ); // new explicit round
                } else {
-                       $fnameTrxOwner = get_class( $update ) . '::doUpdate';
+                       $lbFactory->commitMasterChanges( $fnameTrxOwner ); // new implicit round
                }
-
-               if ( $fnameTrxOwner !== null ) {
-                       $lbFactory->beginMasterChanges( $fnameTrxOwner );
-               }
-
+               // Run the update after any stale master view snapshots have been flushed
                $update->doUpdate();
-
-               if ( $fnameTrxOwner !== null ) {
-                       $lbFactory->commitMasterChanges( $fnameTrxOwner );
-               }
+               // Commit any pending changes from the explicit or implicit transaction round
+               $lbFactory->commitMasterChanges( $fnameTrxOwner );
        }
 
        /**
index cbcd3ba..860a351 100644 (file)
@@ -28,7 +28,7 @@ class HTMLNamespacesMultiselectField extends HTMLSelectNamespace {
        }
 
        public function validate( $value, $alldata ) {
-               if ( !$this->mParams['exists'] ) {
+               if ( !$this->mParams['exists'] || $value === '' ) {
                        return true;
                }
 
index 40c9417..4be13b0 100644 (file)
@@ -139,9 +139,9 @@ class ImportableUploadRevisionImporter implements UploadRevisionImporter {
 
        /**
         * @deprecated DO NOT CALL ME.
-        * This method was introduced when factoring UploadImporter out of WikiRevision.
-        * It only has 1 use by the deprecated downloadSource method in WikiRevision.
-        * Do not use this in new code.
+        * This method was introduced when factoring (Importable)UploadRevisionImporter out of
+        * WikiRevision. It only has 1 use by the deprecated downloadSource method in WikiRevision.
+        * Do not use this in new code, it will be made private soon.
         *
         * @param ImportableUploadRevision $wikiRevision
         *
index c006874..cae9542 100644 (file)
@@ -636,7 +636,7 @@ class WikiRevision implements ImportableUploadRevision, ImportableOldRevision {
 
        /**
         * @since 1.12.2
-        * @deprecated in 1.31. Use UploadImporter::import
+        * @deprecated in 1.31. Use UploadRevisionImporter::import
         * @return bool
         */
        public function importUpload() {
@@ -647,7 +647,7 @@ class WikiRevision implements ImportableUploadRevision, ImportableOldRevision {
 
        /**
         * @since 1.12.2
-        * @deprecated in 1.31. Use UploadImporter::downloadSource
+        * @deprecated in 1.31. No replacement
         * @return bool|string
         */
        public function downloadSource() {
index 33d4fcc..f6a5c41 100644 (file)
@@ -1764,7 +1764,9 @@ abstract class Installer {
        public static function overrideConfig() {
                // Use PHP's built-in session handling, since MediaWiki's
                // SessionHandler can't work before we have an object cache set up.
-               define( 'MW_NO_SESSION_HANDLER', 1 );
+               if ( !defined( 'MW_NO_SESSION_HANDLER' ) ) {
+                       define( 'MW_NO_SESSION_HANDLER', 1 );
+               }
 
                // Don't access the database
                $GLOBALS['wgUseDatabaseMessages'] = false;
index 465fe82..0954ac8 100644 (file)
@@ -33,7 +33,7 @@
  *
  * @ingroup Cache
  */
-class APCBagOStuff extends BagOStuff {
+class APCBagOStuff extends MediumSpecificBagOStuff {
        /** @var bool Whether to trust the APC implementation to serialization */
        private $nativeSerialize;
 
index b14ac7c..021cdf7 100644 (file)
@@ -33,7 +33,7 @@
  *
  * @ingroup Cache
  */
-class APCUBagOStuff extends BagOStuff {
+class APCUBagOStuff extends MediumSpecificBagOStuff {
        /** @var bool Whether to trust the APC implementation to serialization */
        private $nativeSerialize;
 
index dce49c4..906e955 100644 (file)
@@ -30,7 +30,6 @@ use Psr\Log\LoggerAwareInterface;
 use Psr\Log\LoggerInterface;
 use Psr\Log\NullLogger;
 use Wikimedia\ScopedCallback;
-use Wikimedia\WaitConditionLoop;
 
 /**
  * Class representing a cache/ephemeral data store
@@ -62,86 +61,40 @@ use Wikimedia\WaitConditionLoop;
  * @ingroup Cache
  */
 abstract class BagOStuff implements IExpiringStore, IStoreKeyEncoder, LoggerAwareInterface {
-       /** @var array[] Lock tracking */
-       protected $locks = [];
-       /** @var int ERR_* class constant */
-       protected $lastError = self::ERR_NONE;
-       /** @var string */
-       protected $keyspace = 'local';
        /** @var LoggerInterface */
        protected $logger;
+
        /** @var callable|null */
        protected $asyncHandler;
-       /** @var int Seconds */
-       protected $syncTimeout;
-       /** @var int Bytes; chunk size of segmented cache values */
-       protected $segmentationSize;
-       /** @var int Bytes; maximum total size of a segmented cache value */
-       protected $segmentedValueMaxSize;
+       /** @var int[] Map of (ATTR_* class constant => QOS_* class constant) */
+       protected $attrMap = [];
 
        /** @var bool */
-       private $debugMode = false;
-       /** @var array */
-       private $duplicateKeyLookups = [];
-       /** @var bool */
-       private $reportDupes = false;
-       /** @var bool */
-       private $dupeTrackScheduled = false;
-
-       /** @var callable[] */
-       protected $busyCallbacks = [];
+       protected $debugMode = false;
 
        /** @var float|null */
        private $wallClockOverride;
 
-       /** @var int[] Map of (ATTR_* class constant => QOS_* class constant) */
-       protected $attrMap = [];
-
-       /** Bitfield constants for get()/getMulti() */
-       const READ_LATEST = 1; // use latest data for replicated stores
-       const READ_VERIFIED = 2; // promise that caller can tell when keys are stale
-       /** Bitfield constants for set()/merge() */
-       const WRITE_SYNC = 4; // synchronously write to all locations for replicated stores
-       const WRITE_CACHE_ONLY = 8; // Only change state of the in-memory cache
-       const WRITE_ALLOW_SEGMENTS = 16; // Allow partitioning of the value if it is large
-       const WRITE_PRUNE_SEGMENTS = 32; // Delete all partition segments of the value
-
-       /** @var string Component to use for key construction of blob segment keys */
-       const SEGMENT_COMPONENT = 'segment';
+       /** Bitfield constants for get()/getMulti(); these are only advisory */
+       const READ_LATEST = 1; // if supported, avoid reading stale data due to replication
+       const READ_VERIFIED = 2; // promise that the caller handles detection of staleness
+       /** Bitfield constants for set()/merge(); these are only advisory */
+       const WRITE_SYNC = 4; // if supported, block until the write is fully replicated
+       const WRITE_CACHE_ONLY = 8; // only change state of the in-memory cache
+       const WRITE_ALLOW_SEGMENTS = 16; // allow partitioning of the value if it is large
+       const WRITE_PRUNE_SEGMENTS = 32; // delete all the segments if the value is partitioned
+       const WRITE_BACKGROUND = 64; // if supported, do not block on completion until the next read
 
        /**
-        * $params include:
+        * Parameters include:
         *   - logger: Psr\Log\LoggerInterface instance
-        *   - keyspace: Default keyspace for $this->makeKey()
         *   - asyncHandler: Callable to use for scheduling tasks after the web request ends.
         *      In CLI mode, it should run the task immediately.
-        *   - reportDupes: Whether to emit warning log messages for all keys that were
-        *      requested more than once (requires an asyncHandler).
-        *   - syncTimeout: How long to wait with WRITE_SYNC in seconds.
-        *   - segmentationSize: The chunk size, in bytes, of segmented values. The value should
-        *      not exceed the maximum size of values in the storage backend, as configured by
-        *      the site administrator.
-        *   - segmentedValueMaxSize: The maximum total size, in bytes, of segmented values.
-        *      This should be configured to a reasonable size give the site traffic and the
-        *      amount of I/O between application and cache servers that the network can handle.
         * @param array $params
         */
        public function __construct( array $params = [] ) {
                $this->setLogger( $params['logger'] ?? new NullLogger() );
-
-               if ( isset( $params['keyspace'] ) ) {
-                       $this->keyspace = $params['keyspace'];
-               }
-
                $this->asyncHandler = $params['asyncHandler'] ?? null;
-
-               if ( !empty( $params['reportDupes'] ) && is_callable( $this->asyncHandler ) ) {
-                       $this->reportDupes = true;
-               }
-
-               $this->syncTimeout = $params['syncTimeout'] ?? 3;
-               $this->segmentationSize = $params['segmentationSize'] ?? 8388608; // 8MiB
-               $this->segmentedValueMaxSize = $params['segmentedValueMaxSize'] ?? 67108864; // 64MiB
        }
 
        /**
@@ -153,10 +106,10 @@ abstract class BagOStuff implements IExpiringStore, IStoreKeyEncoder, LoggerAwar
        }
 
        /**
-        * @param bool $bool
+        * @param bool $enabled
         */
-       public function setDebug( $bool ) {
-               $this->debugMode = $bool;
+       public function setDebug( $enabled ) {
+               $this->debugMode = $enabled;
        }
 
        /**
@@ -200,52 +153,7 @@ abstract class BagOStuff implements IExpiringStore, IStoreKeyEncoder, LoggerAwar
         * @param int $flags Bitfield of BagOStuff::READ_* constants [optional]
         * @return mixed Returns false on failure or if the item does not exist
         */
-       public function get( $key, $flags = 0 ) {
-               $this->trackDuplicateKeys( $key );
-
-               return $this->resolveSegments( $key, $this->doGet( $key, $flags ) );
-       }
-
-       /**
-        * Track the number of times that a given key has been used.
-        * @param string $key
-        */
-       private function trackDuplicateKeys( $key ) {
-               if ( !$this->reportDupes ) {
-                       return;
-               }
-
-               if ( !isset( $this->duplicateKeyLookups[$key] ) ) {
-                       // Track that we have seen this key. This N-1 counting style allows
-                       // easy filtering with array_filter() later.
-                       $this->duplicateKeyLookups[$key] = 0;
-               } else {
-                       $this->duplicateKeyLookups[$key] += 1;
-
-                       if ( $this->dupeTrackScheduled === false ) {
-                               $this->dupeTrackScheduled = true;
-                               // Schedule a callback that logs keys processed more than once by get().
-                               call_user_func( $this->asyncHandler, function () {
-                                       $dups = array_filter( $this->duplicateKeyLookups );
-                                       foreach ( $dups as $key => $count ) {
-                                               $this->logger->warning(
-                                                       'Duplicate get(): "{key}" fetched {count} times',
-                                                       // Count is N-1 of the actual lookup count
-                                                       [ 'key' => $key, 'count' => $count + 1, ]
-                                               );
-                                       }
-                               } );
-                       }
-               }
-       }
-
-       /**
-        * @param string $key
-        * @param int $flags Bitfield of BagOStuff::READ_* constants [optional]
-        * @param mixed|null &$casToken Token to use for check-and-set comparisons
-        * @return mixed Returns false on failure or if the item does not exist
-        */
-       abstract protected function doGet( $key, $flags = 0, &$casToken = null );
+       abstract public function get( $key, $flags = 0 );
 
        /**
         * Set an item
@@ -256,70 +164,7 @@ abstract class BagOStuff implements IExpiringStore, IStoreKeyEncoder, LoggerAwar
         * @param int $flags Bitfield of BagOStuff::WRITE_* constants
         * @return bool Success
         */
-       public function set( $key, $value, $exptime = 0, $flags = 0 ) {
-               if (
-                       is_int( $value ) || // avoid breaking incr()/decr()
-                       ( $flags & self::WRITE_ALLOW_SEGMENTS ) != self::WRITE_ALLOW_SEGMENTS ||
-                       is_infinite( $this->segmentationSize )
-               ) {
-                       return $this->doSet( $key, $value, $exptime, $flags );
-               }
-
-               $serialized = $this->serialize( $value );
-               $segmentSize = $this->getSegmentationSize();
-               $maxTotalSize = $this->getSegmentedValueMaxSize();
-
-               $size = strlen( $serialized );
-               if ( $size <= $segmentSize ) {
-                       // Since the work of serializing it was already done, just use it inline
-                       return $this->doSet(
-                               $key,
-                               SerializedValueContainer::newUnified( $serialized ),
-                               $exptime,
-                               $flags
-                       );
-               } elseif ( $size > $maxTotalSize ) {
-                       $this->setLastError( "Key $key exceeded $maxTotalSize bytes." );
-
-                       return false;
-               }
-
-               $chunksByKey = [];
-               $segmentHashes = [];
-               $count = intdiv( $size, $segmentSize ) + ( ( $size % $segmentSize ) ? 1 : 0 );
-               for ( $i = 0; $i < $count; ++$i ) {
-                       $segment = substr( $serialized, $i * $segmentSize, $segmentSize );
-                       $hash = sha1( $segment );
-                       $chunkKey = $this->makeGlobalKey( self::SEGMENT_COMPONENT, $key, $hash );
-                       $chunksByKey[$chunkKey] = $segment;
-                       $segmentHashes[] = $hash;
-               }
-
-               $flags &= ~self::WRITE_ALLOW_SEGMENTS; // sanity
-               $ok = $this->setMulti( $chunksByKey, $exptime, $flags );
-               if ( $ok ) {
-                       // Only when all segments are stored should the main key be changed
-                       $ok = $this->doSet(
-                               $key,
-                               SerializedValueContainer::newSegmented( $segmentHashes ),
-                               $exptime,
-                               $flags
-                       );
-               }
-
-               return $ok;
-       }
-
-       /**
-        * Set an item
-        *
-        * @param string $key
-        * @param mixed $value
-        * @param int $exptime Either an interval in seconds or a unix timestamp for expiry
-        * @param int $flags Bitfield of BagOStuff::WRITE_* constants
-        * @return bool Success
-        */
-       abstract protected function doSet( $key, $value, $exptime = 0, $flags = 0 );
+       abstract public function set( $key, $value, $exptime = 0, $flags = 0 );
 
        /**
         * Delete an item
@@ -332,38 +177,7 @@ abstract class BagOStuff implements IExpiringStore, IStoreKeyEncoder, LoggerAwar
         * @return bool True if the item was deleted or not found, false on failure
         * @param int $flags Bitfield of BagOStuff::WRITE_* constants
         */
-       public function delete( $key, $flags = 0 ) {
-               if ( ( $flags & self::WRITE_PRUNE_SEGMENTS ) != self::WRITE_PRUNE_SEGMENTS ) {
-                       return $this->doDelete( $key, $flags );
-               }
-
-               $mainValue = $this->doGet( $key, self::READ_LATEST );
-               if ( !$this->doDelete( $key, $flags ) ) {
-                       return false;
-               }
-
-               if ( !SerializedValueContainer::isSegmented( $mainValue ) ) {
-                       return true; // no segments to delete
-               }
-
-               $orderedKeys = array_map(
-                       function ( $segmentHash ) use ( $key ) {
-                               return $this->makeGlobalKey( self::SEGMENT_COMPONENT, $key, $segmentHash );
-                       },
-                       $mainValue->{SerializedValueContainer::SEGMENTED_HASHES}
-               );
-
-               return $this->deleteMulti( $orderedKeys, $flags );
-       }
-
-       /**
-        * Delete an item
-        *
-        * @param string $key
-        * @return bool True if the item was deleted or not found, false on failure
-        * @param int $flags Bitfield of BagOStuff::WRITE_* constants
-        */
-       abstract protected function doDelete( $key, $flags = 0 );
+       abstract public function delete( $key, $flags = 0 );
 
        /**
         * Insert an item if it does not already exist
@@ -393,99 +207,13 @@ abstract class BagOStuff implements IExpiringStore, IStoreKeyEncoder, LoggerAwar
         * @return bool Success
         * @throws InvalidArgumentException
         */
-       public function merge( $key, callable $callback, $exptime = 0, $attempts = 10, $flags = 0 ) {
-               return $this->mergeViaCas( $key, $callback, $exptime, $attempts, $flags );
-       }
-
-       /**
-        * @see BagOStuff::merge()
-        *
-        * @param string $key
-        * @param callable $callback Callback method to be executed
-        * @param int $exptime Either an interval in seconds or a unix timestamp for expiry
-        * @param int $attempts The amount of times to attempt a merge in case of failure
-        * @param int $flags Bitfield of BagOStuff::WRITE_* constants
-        * @return bool Success
-        */
-       final protected function mergeViaCas( $key, callable $callback, $exptime, $attempts, $flags ) {
-               do {
-                       $casToken = null; // passed by reference
-                       // Get the old value and CAS token from cache
-                       $this->clearLastError();
-                       $currentValue = $this->resolveSegments(
-                               $key,
-                               $this->doGet( $key, self::READ_LATEST, $casToken )
-                       );
-                       if ( $this->getLastError() ) {
-                               $this->logger->warning(
-                                       __METHOD__ . ' failed due to I/O error on get() for {key}.',
-                                       [ 'key' => $key ]
-                               );
-
-                               return false; // don't spam retries (retry only on races)
-                       }
-
-                       // Derive the new value from the old value
-                       $value = call_user_func( $callback, $this, $key, $currentValue, $exptime );
-                       $hadNoCurrentValue = ( $currentValue === false );
-                       unset( $currentValue ); // free RAM in case the value is large
-
-                       $this->clearLastError();
-                       if ( $value === false ) {
-                               $success = true; // do nothing
-                       } elseif ( $hadNoCurrentValue ) {
-                               // Try to create the key, failing if it gets created in the meantime
-                               $success = $this->add( $key, $value, $exptime, $flags );
-                       } else {
-                               // Try to update the key, failing if it gets changed in the meantime
-                               $success = $this->cas( $casToken, $key, $value, $exptime, $flags );
-                       }
-                       if ( $this->getLastError() ) {
-                               $this->logger->warning(
-                                       __METHOD__ . ' failed due to I/O error for {key}.',
-                                       [ 'key' => $key ]
-                               );
-
-                               return false; // IO error; don't spam retries
-                       }
-
-               } while ( !$success && --$attempts );
-
-               return $success;
-       }
-
-       /**
-        * Check and set an item
-        *
-        * @param mixed $casToken
-        * @param string $key
-        * @param mixed $value
-        * @param int $exptime Either an interval in seconds or a unix timestamp for expiry
-        * @param int $flags Bitfield of BagOStuff::WRITE_* constants
-        * @return bool Success
-        */
-       protected function cas( $casToken, $key, $value, $exptime = 0, $flags = 0 ) {
-               if ( !$this->lock( $key, 0 ) ) {
-                       return false; // non-blocking
-               }
-
-               $curCasToken = null; // passed by reference
-               $this->doGet( $key, self::READ_LATEST, $curCasToken );
-               if ( $casToken === $curCasToken ) {
-                       $success = $this->set( $key, $value, $exptime, $flags );
-               } else {
-                       $this->logger->info(
-                               __METHOD__ . ' failed due to race condition for {key}.',
-                               [ 'key' => $key ]
-                       );
-
-                       $success = false; // mismatched or failed
-               }
-
-               $this->unlock( $key );
-
-               return $success;
-       }
+       abstract public function merge(
+               $key,
+               callable $callback,
+               $exptime = 0,
+               $attempts = 10,
+               $flags = 0
+       );
 
        /**
         * Change the expiration on a key if it exists
@@ -504,39 +232,7 @@ abstract class BagOStuff implements IExpiringStore, IStoreKeyEncoder, LoggerAwar
         * @return bool Success Returns false on failure or if the item does not exist
         * @since 1.28
         */
-       public function changeTTL( $key, $exptime = 0, $flags = 0 ) {
-               return $this->doChangeTTL( $key, $exptime, $flags );
-       }
-
-       /**
-        * @param string $key
-        * @param int $exptime
-        * @param int $flags
-        * @return bool
-        */
-       protected function doChangeTTL( $key, $exptime, $flags ) {
-               $expiry = $this->convertToExpiry( $exptime );
-               $delete = ( $expiry != 0 && $expiry < $this->getCurrentTime() );
-
-               if ( !$this->lock( $key, 0 ) ) {
-                       return false;
-               }
-               // Use doGet() to avoid having to trigger resolveSegments()
-               $blob = $this->doGet( $key, self::READ_LATEST );
-               if ( $blob ) {
-                       if ( $delete ) {
-                               $ok = $this->doDelete( $key, $flags );
-                       } else {
-                               $ok = $this->doSet( $key, $blob, $exptime, $flags );
-                       }
-               } else {
-                       $ok = false;
-               }
-
-               $this->unlock( $key );
-
-               return $ok;
-       }
+       abstract public function changeTTL( $key, $exptime = 0, $flags = 0 );
 
        /**
         * Acquire an advisory lock on a key string
@@ -549,51 +245,7 @@ abstract class BagOStuff implements IExpiringStore, IStoreKeyEncoder, LoggerAwar
         * @param string $rclass Allow reentry if set and the current lock used this value
         * @return bool Success
         */
-       public function lock( $key, $timeout = 6, $expiry = 6, $rclass = '' ) {
-               // Avoid deadlocks and allow lock reentry if specified
-               if ( isset( $this->locks[$key] ) ) {
-                       if ( $rclass != '' && $this->locks[$key]['class'] === $rclass ) {
-                               ++$this->locks[$key]['depth'];
-                               return true;
-                       } else {
-                               return false;
-                       }
-               }
-
-               $fname = __METHOD__;
-               $expiry = min( $expiry ?: INF, self::TTL_DAY );
-               $loop = new WaitConditionLoop(
-                       function () use ( $key, $expiry, $fname ) {
-                               $this->clearLastError();
-                               if ( $this->add( "{$key}:lock", 1, $expiry ) ) {
-                                       return WaitConditionLoop::CONDITION_REACHED; // locked!
-                               } elseif ( $this->getLastError() ) {
-                                       $this->logger->warning(
-                                               $fname . ' failed due to I/O error for {key}.',
-                                               [ 'key' => $key ]
-                                       );
-
-                                       return WaitConditionLoop::CONDITION_ABORTED; // network partition?
-                               }
-
-                               return WaitConditionLoop::CONDITION_CONTINUE;
-                       },
-                       $timeout
-               );
-
-               $code = $loop->invoke();
-               $locked = ( $code === $loop::CONDITION_REACHED );
-               if ( $locked ) {
-                       $this->locks[$key] = [ 'class' => $rclass, 'depth' => 1 ];
-               } elseif ( $code === $loop::CONDITION_TIMED_OUT ) {
-                       $this->logger->warning(
-                               "$fname failed due to timeout for {key}.",
-                               [ 'key' => $key, 'timeout' => $timeout ]
-                       );
-               }
-
-               return $locked;
-       }
+       abstract public function lock( $key, $timeout = 6, $expiry = 6, $rclass = '' );
 
        /**
         * Release an advisory lock on a key string
@@ -601,27 +253,7 @@ abstract class BagOStuff implements IExpiringStore, IStoreKeyEncoder, LoggerAwar
         * @param string $key
         * @return bool Success
         */
-       public function unlock( $key ) {
-               if ( !isset( $this->locks[$key] ) ) {
-                       return false;
-               }
-
-               if ( --$this->locks[$key]['depth'] <= 0 ) {
-                       unset( $this->locks[$key] );
-
-                       $ok = $this->doDelete( "{$key}:lock" );
-                       if ( !$ok ) {
-                               $this->logger->warning(
-                                       __METHOD__ . ' failed to release lock for {key}.',
-                                       [ 'key' => $key ]
-                               );
-                       }
-
-                       return $ok;
-               }
-
-               return true;
-       }
+       abstract public function unlock( $key );
 
        /**
         * Get a lightweight exclusive self-unlocking lock
@@ -672,37 +304,11 @@ abstract class BagOStuff implements IExpiringStore, IStoreKeyEncoder, LoggerAwar
         *
         * @return bool Success; false if unimplemented
         */
-       public function deleteObjectsExpiringBefore(
+       abstract public function deleteObjectsExpiringBefore(
                $timestamp,
                callable $progress = null,
                $limit = INF
-       ) {
-               return false;
-       }
-
-       /**
-        * Get an associative array containing the item for each of the keys that have items.
-        * @param string[] $keys List of keys; can be a map of (unused => key) for convenience
-        * @param int $flags Bitfield; supports READ_LATEST [optional]
-        * @return mixed[] Map of (key => value) for existing keys; preserves the order of $keys
-        */
-       public function getMulti( array $keys, $flags = 0 ) {
-               $foundByKey = $this->doGetMulti( $keys, $flags );
-
-               $res = [];
-               foreach ( $keys as $key ) {
-                       // Resolve one blob at a time (avoids too much I/O at once)
-                       if ( array_key_exists( $key, $foundByKey ) ) {
-                               // A value should not appear in the key if a segment is missing
-                               $value = $this->resolveSegments( $key, $foundByKey[$key] );
-                               if ( $value !== false ) {
-                                       $res[$key] = $value;
-                               }
-                       }
-               }
-
-               return $res;
-       }
+       );
 
        /**
         * Get an associative array containing the item for each of the keys that have items.
@@ -710,79 +316,36 @@ abstract class BagOStuff implements IExpiringStore, IStoreKeyEncoder, LoggerAwar
         * @param int $flags Bitfield; supports READ_LATEST [optional]
         * @return mixed[] Map of (key => value) for existing keys
         */
-       protected function doGetMulti( array $keys, $flags = 0 ) {
-               $res = [];
-               foreach ( $keys as $key ) {
-                       $val = $this->doGet( $key, $flags );
-                       if ( $val !== false ) {
-                               $res[$key] = $val;
-                       }
-               }
-
-               return $res;
-       }
+       abstract public function getMulti( array $keys, $flags = 0 );
 
        /**
         * Batch insertion/replace
         *
         * This does not support WRITE_ALLOW_SEGMENTS to avoid excessive read I/O
         *
+        * WRITE_BACKGROUND can be used for bulk insertion where the response is not vital
+        *
         * @param mixed[] $data Map of (key => value)
         * @param int $exptime Either an interval in seconds or a unix timestamp for expiry
         * @param int $flags Bitfield of BagOStuff::WRITE_* constants (since 1.33)
         * @return bool Success
         * @since 1.24
         */
-       public function setMulti( array $data, $exptime = 0, $flags = 0 ) {
-               if ( ( $flags & self::WRITE_ALLOW_SEGMENTS ) === self::WRITE_ALLOW_SEGMENTS ) {
-                       throw new InvalidArgumentException( __METHOD__ . ' got WRITE_ALLOW_SEGMENTS' );
-               }
-               return $this->doSetMulti( $data, $exptime, $flags );
-       }
-
-       /**
-        * @param mixed[] $data Map of (key => value)
-        * @param int $exptime Either an interval in seconds or a unix timestamp for expiry
-        * @param int $flags Bitfield of BagOStuff::WRITE_* constants
-        * @return bool Success
-        */
-       protected function doSetMulti( array $data, $exptime = 0, $flags = 0 ) {
-               $res = true;
-               foreach ( $data as $key => $value ) {
-                       $res = $this->doSet( $key, $value, $exptime, $flags ) && $res;
-               }
-               return $res;
-       }
+       abstract public function setMulti( array $data, $exptime = 0, $flags = 0 );
 
        /**
         * Batch deletion
         *
         * This does not support WRITE_ALLOW_SEGMENTS to avoid excessive read I/O
         *
+        * WRITE_BACKGROUND can be used for bulk deletion where the response is not vital
+        *
         * @param string[] $keys List of keys
         * @param int $flags Bitfield of BagOStuff::WRITE_* constants
         * @return bool Success
         * @since 1.33
         */
-       public function deleteMulti( array $keys, $flags = 0 ) {
-               if ( ( $flags & self::WRITE_ALLOW_SEGMENTS ) === self::WRITE_ALLOW_SEGMENTS ) {
-                       throw new InvalidArgumentException( __METHOD__ . ' got WRITE_ALLOW_SEGMENTS' );
-               }
-               return $this->doDeleteMulti( $keys, $flags );
-       }
-
-       /**
-        * @param string[] $keys List of keys
-        * @param int $flags Bitfield of BagOStuff::WRITE_* constants
-        * @return bool Success
-        */
-       protected function doDeleteMulti( array $keys, $flags = 0 ) {
-               $res = true;
-               foreach ( $keys as $key ) {
-                       $res = $this->doDelete( $key, $flags ) && $res;
-               }
-               return $res;
-       }
+       abstract public function deleteMulti( array $keys, $flags = 0 );
 
        /**
         * Change the expiration of multiple keys that exist
@@ -795,14 +358,7 @@ abstract class BagOStuff implements IExpiringStore, IStoreKeyEncoder, LoggerAwar
         * @return bool Success
         * @since 1.34
         */
-       public function changeTTLMulti( array $keys, $exptime, $flags = 0 ) {
-               $res = true;
-               foreach ( $keys as $key ) {
-                       $res = $this->doChangeTTL( $key, $exptime, $flags ) && $res;
-               }
-
-               return $res;
-       }
+       abstract public function changeTTLMulti( array $keys, $exptime, $flags = 0 );
 
        /**
         * Increase stored value of $key by $value while preserving its TTL
@@ -818,9 +374,7 @@ abstract class BagOStuff implements IExpiringStore, IStoreKeyEncoder, LoggerAwar
         * @param int $value Value to subtract from $key (default: 1) [optional]
         * @return int|bool New value or false on failure
         */
-       public function decr( $key, $value = 1 ) {
-               return $this->incr( $key, - $value );
-       }
+       abstract public function decr( $key, $value = 1 );
 
        /**
         * Increase stored value of $key by $value while preserving its TTL
@@ -834,83 +388,20 @@ abstract class BagOStuff implements IExpiringStore, IStoreKeyEncoder, LoggerAwar
         * @return int|bool New value or false on failure
         * @since 1.24
         */
-       public function incrWithInit( $key, $ttl, $value = 1, $init = 1 ) {
-               $this->clearLastError();
-               $newValue = $this->incr( $key, $value );
-               if ( $newValue === false && !$this->getLastError() ) {
-                       // No key set; initialize
-                       $newValue = $this->add( $key, (int)$init, $ttl ) ? $init : false;
-                       if ( $newValue === false && !$this->getLastError() ) {
-                               // Raced out initializing; increment
-                               $newValue = $this->incr( $key, $value );
-                       }
-               }
-
-               return $newValue;
-       }
-
-       /**
-        * Get and reassemble the chunks of blob at the given key
-        *
-        * @param string $key
-        * @param mixed $mainValue
-        * @return string|null|bool The combined string, false if missing, null on error
-        */
-       final protected function resolveSegments( $key, $mainValue ) {
-               if ( SerializedValueContainer::isUnified( $mainValue ) ) {
-                       return $this->unserialize( $mainValue->{SerializedValueContainer::UNIFIED_DATA} );
-               }
-
-               if ( SerializedValueContainer::isSegmented( $mainValue ) ) {
-                       $orderedKeys = array_map(
-                               function ( $segmentHash ) use ( $key ) {
-                                       return $this->makeGlobalKey( self::SEGMENT_COMPONENT, $key, $segmentHash );
-                               },
-                               $mainValue->{SerializedValueContainer::SEGMENTED_HASHES}
-                       );
-
-                       $segmentsByKey = $this->doGetMulti( $orderedKeys );
-
-                       $parts = [];
-                       foreach ( $orderedKeys as $segmentKey ) {
-                               if ( isset( $segmentsByKey[$segmentKey] ) ) {
-                                       $parts[] = $segmentsByKey[$segmentKey];
-                               } else {
-                                       return false; // missing segment
-                               }
-                       }
-
-                       return $this->unserialize( implode( '', $parts ) );
-               }
-
-               return $mainValue;
-       }
+       abstract public function incrWithInit( $key, $ttl, $value = 1, $init = 1 );
 
        /**
         * Get the "last error" registered; clearLastError() should be called manually
         * @return int ERR_* constant for the "last error" registry
         * @since 1.23
         */
-       public function getLastError() {
-               return $this->lastError;
-       }
+       abstract public function getLastError();
 
        /**
         * Clear the "last error" registry
         * @since 1.23
         */
-       public function clearLastError() {
-               $this->lastError = self::ERR_NONE;
-       }
-
-       /**
-        * Set the "last error" registry
-        * @param int $err ERR_* constant
-        * @since 1.23
-        */
-       protected function setLastError( $err ) {
-               $this->lastError = $err;
-       }
+       abstract public function clearLastError();
 
        /**
         * Let a callback be run to avoid wasting time on special blocking calls
@@ -932,75 +423,7 @@ abstract class BagOStuff implements IExpiringStore, IStoreKeyEncoder, LoggerAwar
         * @param callable $workCallback
         * @since 1.28
         */
-       final public function addBusyCallback( callable $workCallback ) {
-               $this->busyCallbacks[] = $workCallback;
-       }
-
-       /**
-        * @param string $text
-        */
-       protected function debug( $text ) {
-               if ( $this->debugMode ) {
-                       $this->logger->debug( "{class} debug: $text", [ 'class' => static::class ] );
-               }
-       }
-
-       /**
-        * @param int $exptime
-        * @return bool
-        */
-       final protected function expiryIsRelative( $exptime ) {
-               return ( $exptime != 0 && $exptime < ( 10 * self::TTL_YEAR ) );
-       }
-
-       /**
-        * Convert an optionally relative timestamp to an absolute time
-        *
-        * The input value will be cast to an integer and interpreted as follows:
-        *   - zero: no expiry; return zero (e.g. TTL_INDEFINITE)
-        *   - negative: relative TTL; return UNIX timestamp offset by this value
-        *   - positive (< 10 years): relative TTL; return UNIX timestamp offset by this value
-        *   - positive (>= 10 years): absolute UNIX timestamp; return this value
-        *
-        * @param int $exptime Absolute TTL or 0 for indefinite
-        * @return int
-        */
-       final protected function convertToExpiry( $exptime ) {
-               return $this->expiryIsRelative( $exptime )
-                       ? (int)$this->getCurrentTime() + $exptime
-                       : $exptime;
-       }
-
-       /**
-        * Convert an optionally absolute expiry time to a relative time. If an
-        * absolute time is specified which is in the past, use a short expiry time.
-        *
-        * @param int $exptime
-        * @return int
-        */
-       final protected function convertToRelative( $exptime ) {
-               return $this->expiryIsRelative( $exptime )
-                       ? (int)$exptime
-                       : max( $exptime - (int)$this->getCurrentTime(), 1 );
-       }
-
-       /**
-        * Check if a value is an integer
-        *
-        * @param mixed $value
-        * @return bool
-        */
-       final protected function isInteger( $value ) {
-               if ( is_int( $value ) ) {
-                       return true;
-               } elseif ( !is_string( $value ) ) {
-                       return false;
-               }
-
-               $integer = (int)$value;
-
-               return ( $value === (string)$integer );
-       }
+       abstract public function addBusyCallback( callable $workCallback );
 
        /**
         * Construct a cache key.
@@ -1010,13 +433,7 @@ abstract class BagOStuff implements IExpiringStore, IStoreKeyEncoder, LoggerAwar
         * @param array $args
         * @return string Colon-delimited list of $keyspace followed by escaped components of $args
         */
-       public function makeKeyInternal( $keyspace, $args ) {
-               $key = $keyspace;
-               foreach ( $args as $arg ) {
-                       $key .= ':' . str_replace( ':', '%3A', $arg );
-               }
-               return strtr( $key, ' ', '_' );
-       }
+       abstract public function makeKeyInternal( $keyspace, $args );
 
        /**
         * Make a global cache key.
@@ -1026,9 +443,7 @@ abstract class BagOStuff implements IExpiringStore, IStoreKeyEncoder, LoggerAwar
         * @param string|null $component [optional] Key component (starting with a key collection name)
         * @return string Colon-delimited list of $keyspace followed by escaped components of $args
         */
-       public function makeGlobalKey( $class, $component = null ) {
-               return $this->makeKeyInternal( 'global', func_get_args() );
-       }
+       abstract public function makeGlobalKey( $class, $component = null );
 
        /**
         * Make a cache key, scoped to this instance's keyspace.
@@ -1038,9 +453,7 @@ abstract class BagOStuff implements IExpiringStore, IStoreKeyEncoder, LoggerAwar
         * @param string|null $component [optional] Key component (starting with a key collection name)
         * @return string Colon-delimited list of $keyspace followed by escaped components of $args
         */
-       public function makeKey( $class, $component = null ) {
-               return $this->makeKeyInternal( $this->keyspace, func_get_args() );
-       }
+       abstract public function makeKey( $class, $component = null );
 
        /**
         * @param int $flag ATTR_* class constant
@@ -1056,7 +469,7 @@ abstract class BagOStuff implements IExpiringStore, IStoreKeyEncoder, LoggerAwar
         * @since 1.34
         */
        public function getSegmentationSize() {
-               return $this->segmentationSize;
+               return INF;
        }
 
        /**
@@ -1064,7 +477,7 @@ abstract class BagOStuff implements IExpiringStore, IStoreKeyEncoder, LoggerAwar
         * @since 1.34
         */
        public function getSegmentedValueMaxSize() {
-               return $this->segmentedValueMaxSize;
+               return INF;
        }
 
        /**
@@ -1105,22 +518,4 @@ abstract class BagOStuff implements IExpiringStore, IStoreKeyEncoder, LoggerAwar
        public function setMockTime( &$time ) {
                $this->wallClockOverride =& $time;
        }
-
-       /**
-        * @param mixed $value
-        * @return string|int String/integer representation
-        * @note Special handling is usually needed for integers so incr()/decr() work
-        */
-       protected function serialize( $value ) {
-               return is_int( $value ) ? $value : serialize( $value );
-       }
-
-       /**
-        * @param string|int $value
-        * @return mixed Original value or false on error
-        * @note Special handling is usually needed for integers so incr()/decr() work
-        */
-       protected function unserialize( $value ) {
-               return $this->isInteger( $value ) ? (int)$value : unserialize( $value );
-       }
 }
index ea434e0..c97f56e 100644 (file)
@@ -44,8 +44,6 @@ class CachedBagOStuff extends BagOStuff {
         * @param array $params Parameters for HashBagOStuff
         */
        public function __construct( BagOStuff $backend, $params = [] ) {
-               unset( $params['reportDupes'] ); // useless here
-
                parent::__construct( $params );
 
                $this->backend = $backend;
@@ -53,17 +51,41 @@ class CachedBagOStuff extends BagOStuff {
                $this->attrMap = $backend->attrMap;
        }
 
-       protected function doGet( $key, $flags = 0, &$casToken = null ) {
-               $ret = $this->procCache->get( $key, $flags );
-               if ( $ret === false && !$this->procCache->hasKey( $key ) ) {
-                       $ret = $this->backend->get( $key, $flags );
-                       $this->set( $key, $ret, self::TTL_INDEFINITE, self::WRITE_CACHE_ONLY );
+       public function setDebug( $enabled ) {
+               parent::setDebug( $enabled );
+               $this->backend->setDebug( $enabled );
+       }
+
+       public function get( $key, $flags = 0 ) {
+               $value = $this->procCache->get( $key, $flags );
+               if ( $value === false && !$this->procCache->hasKey( $key ) ) {
+                       $value = $this->backend->get( $key, $flags );
+                       $this->set( $key, $value, self::TTL_INDEFINITE, self::WRITE_CACHE_ONLY );
+               }
+
+               return $value;
+       }
+
+       public function getMulti( array $keys, $flags = 0 ) {
+               $valuesByKeyCached = [];
+
+               $keysMissing = [];
+               foreach ( $keys as $key ) {
+                       $value = $this->procCache->get( $key, $flags );
+                       if ( $value === false && !$this->procCache->hasKey( $key ) ) {
+                               $keysMissing[] = $key;
+                       } else {
+                               $valuesByKeyCached[$key] = $value;
+                       }
                }
 
-               return $ret;
+               $valuesByKeyFetched = $this->backend->getMulti( $keys, $flags );
+               $this->setMulti( $valuesByKeyFetched, self::TTL_INDEFINITE, self::WRITE_CACHE_ONLY );
+
+               return $valuesByKeyCached + $valuesByKeyFetched;
        }
 
-       protected function doSet( $key, $value, $exptime = 0, $flags = 0 ) {
+       public function set( $key, $value, $exptime = 0, $flags = 0 ) {
                $this->procCache->set( $key, $value, $exptime, $flags );
                if ( ( $flags & self::WRITE_CACHE_ONLY ) != self::WRITE_CACHE_ONLY ) {
                        $this->backend->set( $key, $value, $exptime, $flags );
@@ -72,7 +94,7 @@ class CachedBagOStuff extends BagOStuff {
                return true;
        }
 
-       protected function doDelete( $key, $flags = 0 ) {
+       public function delete( $key, $flags = 0 ) {
                $this->procCache->delete( $key, $flags );
                if ( ( $flags & self::WRITE_CACHE_ONLY ) != self::WRITE_CACHE_ONLY ) {
                        $this->backend->delete( $key, $flags );
@@ -81,19 +103,6 @@ class CachedBagOStuff extends BagOStuff {
                return true;
        }
 
-       public function deleteObjectsExpiringBefore(
-               $timestamp,
-               callable $progress = null,
-               $limit = INF
-       ) {
-               $this->procCache->deleteObjectsExpiringBefore( $timestamp, $progress, $limit );
-
-               return $this->backend->deleteObjectsExpiringBefore( $timestamp, $progress, $limit );
-       }
-
-       // These just call the backend (tested elsewhere)
-       // @codeCoverageIgnoreStart
-
        public function add( $key, $value, $exptime = 0, $flags = 0 ) {
                if ( $this->get( $key ) === false ) {
                        return $this->set( $key, $value, $exptime, $flags );
@@ -102,12 +111,19 @@ class CachedBagOStuff extends BagOStuff {
                return false; // key already set
        }
 
-       public function incr( $key, $value = 1 ) {
-               $n = $this->backend->incr( $key, $value );
+       // These just call the backend (tested elsewhere)
+       // @codeCoverageIgnoreStart
 
+       public function merge( $key, callable $callback, $exptime = 0, $attempts = 10, $flags = 0 ) {
                $this->procCache->delete( $key );
 
-               return $n;
+               return $this->backend->merge( $key, $callback, $exptime, $attempts, $flags );
+       }
+
+       public function changeTTL( $key, $exptime = 0, $flags = 0 ) {
+               $this->procCache->delete( $key );
+
+               return $this->backend->changeTTL( $key, $exptime, $flags );
        }
 
        public function lock( $key, $timeout = 6, $expiry = 6, $rclass = '' ) {
@@ -118,6 +134,16 @@ class CachedBagOStuff extends BagOStuff {
                return $this->backend->unlock( $key );
        }
 
+       public function deleteObjectsExpiringBefore(
+               $timestamp,
+               callable $progress = null,
+               $limit = INF
+       ) {
+               $this->procCache->deleteObjectsExpiringBefore( $timestamp, $progress, $limit );
+
+               return $this->backend->deleteObjectsExpiringBefore( $timestamp, $progress, $limit );
+       }
+
        public function makeKeyInternal( $keyspace, $args ) {
                return $this->backend->makeKeyInternal( ...func_get_args() );
        }
@@ -130,11 +156,6 @@ class CachedBagOStuff extends BagOStuff {
                return $this->backend->makeGlobalKey( ...func_get_args() );
        }
 
-       public function setDebug( $bool ) {
-               parent::setDebug( $bool );
-               $this->backend->setDebug( $bool );
-       }
-
        public function getLastError() {
                return $this->backend->getLastError();
        }
@@ -143,5 +164,60 @@ class CachedBagOStuff extends BagOStuff {
                return $this->backend->clearLastError();
        }
 
+       public function setMulti( array $data, $exptime = 0, $flags = 0 ) {
+               $this->procCache->setMulti( $data, $exptime, $flags );
+               if ( ( $flags & self::WRITE_CACHE_ONLY ) != self::WRITE_CACHE_ONLY ) {
+                       return $this->backend->setMulti( $data, $exptime, $flags );
+               }
+
+               return true;
+       }
+
+       public function deleteMulti( array $keys, $flags = 0 ) {
+               $this->procCache->deleteMulti( $keys, $flags );
+               if ( ( $flags & self::WRITE_CACHE_ONLY ) != self::WRITE_CACHE_ONLY ) {
+                       return $this->backend->deleteMulti( $keys, $flags );
+               }
+
+               return true;
+       }
+
+       public function changeTTLMulti( array $keys, $exptime, $flags = 0 ) {
+               $this->procCache->changeTTLMulti( $keys, $exptime, $flags );
+               if ( ( $flags & self::WRITE_CACHE_ONLY ) != self::WRITE_CACHE_ONLY ) {
+                       return $this->backend->changeTTLMulti( $keys, $exptime, $flags );
+               }
+
+               return true;
+       }
+
+       public function incr( $key, $value = 1 ) {
+               $this->procCache->delete( $key );
+
+               return $this->backend->incr( $key, $value );
+       }
+
+       public function decr( $key, $value = 1 ) {
+               $this->procCache->delete( $key );
+
+               return $this->backend->decr( $key, $value );
+       }
+
+       public function incrWithInit( $key, $ttl, $value = 1, $init = 1 ) {
+               $this->procCache->delete( $key );
+
+               return $this->backend->incrWithInit( $key, $ttl, $value, $init );
+       }
+
+       public function addBusyCallback( callable $workCallback ) {
+               $this->backend->addBusyCallback( $workCallback );
+       }
+
+       public function setMockTime( &$time ) {
+               parent::setMockTime( $time );
+               $this->procCache->setMockTime( $time );
+               $this->backend->setMockTime( $time );
+       }
+
        // @codeCoverageIgnoreEnd
 }
index 6dc1363..dab8ba1 100644 (file)
@@ -26,7 +26,7 @@
  *
  * @ingroup Cache
  */
-class EmptyBagOStuff extends BagOStuff {
+class EmptyBagOStuff extends MediumSpecificBagOStuff {
        protected function doGet( $key, $flags = 0, &$casToken = null ) {
                $casToken = null;
 
index c74bb6e..83c8004 100644 (file)
@@ -28,7 +28,7 @@
  *
  * @ingroup Cache
  */
-class HashBagOStuff extends BagOStuff {
+class HashBagOStuff extends MediumSpecificBagOStuff {
        /** @var mixed[] */
        protected $bag = [];
        /** @var int Max entries allowed */
diff --git a/includes/libs/objectcache/MediumSpecificBagOStuff.php b/includes/libs/objectcache/MediumSpecificBagOStuff.php
new file mode 100644 (file)
index 0000000..fb088c8
--- /dev/null
@@ -0,0 +1,932 @@
+<?php
+/**
+ * Storage medium specific cache for storing items.
+ *
+ * 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
+ * @ingroup Cache
+ */
+
+use Wikimedia\WaitConditionLoop;
+
+/**
+ * Storage medium specific cache for storing items (e.g. redis, memcached, ...)
+ *
+ * This should not be used for proxy classes that simply wrap other cache instances
+ *
+ * @ingroup Cache
+ * @since 1.34
+ */
+abstract class MediumSpecificBagOStuff extends BagOStuff {
+       /** @var array[] Lock tracking */
+       protected $locks = [];
+       /** @var int ERR_* class constant */
+       protected $lastError = self::ERR_NONE;
+       /** @var string */
+       protected $keyspace = 'local';
+       /** @var int Seconds */
+       protected $syncTimeout;
+       /** @var int Bytes; chunk size of segmented cache values */
+       protected $segmentationSize;
+       /** @var int Bytes; maximum total size of a segmented cache value */
+       protected $segmentedValueMaxSize;
+
+       /** @var array */
+       private $duplicateKeyLookups = [];
+       /** @var bool */
+       private $reportDupes = false;
+       /** @var bool */
+       private $dupeTrackScheduled = false;
+
+       /** @var callable[] */
+       protected $busyCallbacks = [];
+
+       /** @var string Component to use for key construction of blob segment keys */
+       const SEGMENT_COMPONENT = 'segment';
+
+       /**
+        * @see BagOStuff::__construct()
+        * Additional $params options include:
+        *   - logger: Psr\Log\LoggerInterface instance
+        *   - keyspace: Default keyspace for $this->makeKey()
+        *   - reportDupes: Whether to emit warning log messages for all keys that were
+        *      requested more than once (requires an asyncHandler).
+        *   - syncTimeout: How long to wait with WRITE_SYNC in seconds.
+        *   - segmentationSize: The chunk size, in bytes, of segmented values. The value should
+        *      not exceed the maximum size of values in the storage backend, as configured by
+        *      the site administrator.
+        *   - segmentedValueMaxSize: The maximum total size, in bytes, of segmented values.
+        *      This should be configured to a reasonable size give the site traffic and the
+        *      amount of I/O between application and cache servers that the network can handle.
+        * @param array $params
+        */
+       public function __construct( array $params = [] ) {
+               parent::__construct( $params );
+
+               if ( isset( $params['keyspace'] ) ) {
+                       $this->keyspace = $params['keyspace'];
+               }
+
+               if ( !empty( $params['reportDupes'] ) && is_callable( $this->asyncHandler ) ) {
+                       $this->reportDupes = true;
+               }
+
+               $this->syncTimeout = $params['syncTimeout'] ?? 3;
+               $this->segmentationSize = $params['segmentationSize'] ?? 8388608; // 8MiB
+               $this->segmentedValueMaxSize = $params['segmentedValueMaxSize'] ?? 67108864; // 64MiB
+       }
+
+       /**
+        * Get an item with the given key
+        *
+        * If the key includes a deterministic input hash (e.g. the key can only have
+        * the correct value) or complete staleness checks are handled by the caller
+        * (e.g. nothing relies on the TTL), then the READ_VERIFIED flag should be set.
+        * This lets tiered backends know they can safely upgrade a cached value to
+        * higher tiers using standard TTLs.
+        *
+        * @param string $key
+        * @param int $flags Bitfield of BagOStuff::READ_* constants [optional]
+        * @return mixed Returns false on failure or if the item does not exist
+        */
+       public function get( $key, $flags = 0 ) {
+               $this->trackDuplicateKeys( $key );
+
+               return $this->resolveSegments( $key, $this->doGet( $key, $flags ) );
+       }
+
+       /**
+        * Track the number of times that a given key has been used.
+        * @param string $key
+        */
+       private function trackDuplicateKeys( $key ) {
+               if ( !$this->reportDupes ) {
+                       return;
+               }
+
+               if ( !isset( $this->duplicateKeyLookups[$key] ) ) {
+                       // Track that we have seen this key. This N-1 counting style allows
+                       // easy filtering with array_filter() later.
+                       $this->duplicateKeyLookups[$key] = 0;
+               } else {
+                       $this->duplicateKeyLookups[$key] += 1;
+
+                       if ( $this->dupeTrackScheduled === false ) {
+                               $this->dupeTrackScheduled = true;
+                               // Schedule a callback that logs keys processed more than once by get().
+                               call_user_func( $this->asyncHandler, function () {
+                                       $dups = array_filter( $this->duplicateKeyLookups );
+                                       foreach ( $dups as $key => $count ) {
+                                               $this->logger->warning(
+                                                       'Duplicate get(): "{key}" fetched {count} times',
+                                                       // Count is N-1 of the actual lookup count
+                                                       [ 'key' => $key, 'count' => $count + 1, ]
+                                               );
+                                       }
+                               } );
+                       }
+               }
+       }
+
+       /**
+        * @param string $key
+        * @param int $flags Bitfield of BagOStuff::READ_* constants [optional]
+        * @param mixed|null &$casToken Token to use for check-and-set comparisons
+        * @return mixed Returns false on failure or if the item does not exist
+        */
+       abstract protected function doGet( $key, $flags = 0, &$casToken = null );
+
+       /**
+        * Set an item
+        *
+        * @param string $key
+        * @param mixed $value
+        * @param int $exptime Either an interval in seconds or a unix timestamp for expiry
+        * @param int $flags Bitfield of BagOStuff::WRITE_* constants
+        * @return bool Success
+        */
+       public function set( $key, $value, $exptime = 0, $flags = 0 ) {
+               if (
+                       is_int( $value ) || // avoid breaking incr()/decr()
+                       ( $flags & self::WRITE_ALLOW_SEGMENTS ) != self::WRITE_ALLOW_SEGMENTS ||
+                       is_infinite( $this->segmentationSize )
+               ) {
+                       return $this->doSet( $key, $value, $exptime, $flags );
+               }
+
+               $serialized = $this->serialize( $value );
+               $segmentSize = $this->getSegmentationSize();
+               $maxTotalSize = $this->getSegmentedValueMaxSize();
+
+               $size = strlen( $serialized );
+               if ( $size <= $segmentSize ) {
+                       // Since the work of serializing it was already done, just use it inline
+                       return $this->doSet(
+                               $key,
+                               SerializedValueContainer::newUnified( $serialized ),
+                               $exptime,
+                               $flags
+                       );
+               } elseif ( $size > $maxTotalSize ) {
+                       $this->setLastError( "Key $key exceeded $maxTotalSize bytes." );
+
+                       return false;
+               }
+
+               $chunksByKey = [];
+               $segmentHashes = [];
+               $count = intdiv( $size, $segmentSize ) + ( ( $size % $segmentSize ) ? 1 : 0 );
+               for ( $i = 0; $i < $count; ++$i ) {
+                       $segment = substr( $serialized, $i * $segmentSize, $segmentSize );
+                       $hash = sha1( $segment );
+                       $chunkKey = $this->makeGlobalKey( self::SEGMENT_COMPONENT, $key, $hash );
+                       $chunksByKey[$chunkKey] = $segment;
+                       $segmentHashes[] = $hash;
+               }
+
+               $flags &= ~self::WRITE_ALLOW_SEGMENTS; // sanity
+               $ok = $this->setMulti( $chunksByKey, $exptime, $flags );
+               if ( $ok ) {
+                       // Only when all segments are stored should the main key be changed
+                       $ok = $this->doSet(
+                               $key,
+                               SerializedValueContainer::newSegmented( $segmentHashes ),
+                               $exptime,
+                               $flags
+                       );
+               }
+
+               return $ok;
+       }
+
+       /**
+        * Set an item
+        *
+        * @param string $key
+        * @param mixed $value
+        * @param int $exptime Either an interval in seconds or a unix timestamp for expiry
+        * @param int $flags Bitfield of BagOStuff::WRITE_* constants
+        * @return bool Success
+        */
+       abstract protected function doSet( $key, $value, $exptime = 0, $flags = 0 );
+
+       /**
+        * Delete an item
+        *
+        * For large values written using WRITE_ALLOW_SEGMENTS, this only deletes the main
+        * segment list key unless WRITE_PRUNE_SEGMENTS is in the flags. While deleting the segment
+        * list key has the effect of functionally deleting the key, it leaves unused blobs in cache.
+        *
+        * @param string $key
+        * @param int $flags Bitfield of BagOStuff::WRITE_* constants
+        * @return bool True if the item was deleted or not found, false on failure
+        */
+       public function delete( $key, $flags = 0 ) {
+               if ( ( $flags & self::WRITE_PRUNE_SEGMENTS ) != self::WRITE_PRUNE_SEGMENTS ) {
+                       return $this->doDelete( $key, $flags );
+               }
+
+               $mainValue = $this->doGet( $key, self::READ_LATEST );
+               if ( !$this->doDelete( $key, $flags ) ) {
+                       return false;
+               }
+
+               if ( !SerializedValueContainer::isSegmented( $mainValue ) ) {
+                       return true; // no segments to delete
+               }
+
+               $orderedKeys = array_map(
+                       function ( $segmentHash ) use ( $key ) {
+                               return $this->makeGlobalKey( self::SEGMENT_COMPONENT, $key, $segmentHash );
+                       },
+                       $mainValue->{SerializedValueContainer::SEGMENTED_HASHES}
+               );
+
+               return $this->deleteMulti( $orderedKeys, $flags );
+       }
+
+       /**
+        * Delete an item
+        *
+        * @param string $key
+        * @param int $flags Bitfield of BagOStuff::WRITE_* constants
+        * @return bool True if the item was deleted or not found, false on failure
+        */
+       abstract protected function doDelete( $key, $flags = 0 );
+
+       /**
+        * Merge changes into the existing cache value (possibly creating a new one)
+        *
+        * The callback function returns the new value given the current value
+        * (which will be false if not present), and takes the arguments:
+        * (this BagOStuff, cache key, current value, TTL).
+        * The TTL parameter is reference set to $exptime. It can be overriden in the callback.
+        * Nothing is stored nor deleted if the callback returns false.
+        *
+        * @param string $key
+        * @param callable $callback Callback method to be executed
+        * @param int $exptime Either an interval in seconds or a unix timestamp for expiry
+        * @param int $attempts The amount of times to attempt a merge in case of failure
+        * @param int $flags Bitfield of BagOStuff::WRITE_* constants
+        * @return bool Success
+        * @throws InvalidArgumentException
+        */
+       public function merge( $key, callable $callback, $exptime = 0, $attempts = 10, $flags = 0 ) {
+               return $this->mergeViaCas( $key, $callback, $exptime, $attempts, $flags );
+       }
+
+       /**
+        * @param string $key
+        * @param callable $callback Callback method to be executed
+        * @param int $exptime Either an interval in seconds or a unix timestamp for expiry
+        * @param int $attempts The amount of times to attempt a merge in case of failure
+        * @param int $flags Bitfield of BagOStuff::WRITE_* constants
+        * @return bool Success
+        * @see BagOStuff::merge()
+        *
+        */
+       final protected function mergeViaCas( $key, callable $callback, $exptime, $attempts, $flags ) {
+               do {
+                       $casToken = null; // passed by reference
+                       // Get the old value and CAS token from cache
+                       $this->clearLastError();
+                       $currentValue = $this->resolveSegments(
+                               $key,
+                               $this->doGet( $key, self::READ_LATEST, $casToken )
+                       );
+                       if ( $this->getLastError() ) {
+                               $this->logger->warning(
+                                       __METHOD__ . ' failed due to I/O error on get() for {key}.',
+                                       [ 'key' => $key ]
+                               );
+
+                               return false; // don't spam retries (retry only on races)
+                       }
+
+                       // Derive the new value from the old value
+                       $value = call_user_func( $callback, $this, $key, $currentValue, $exptime );
+                       $hadNoCurrentValue = ( $currentValue === false );
+                       unset( $currentValue ); // free RAM in case the value is large
+
+                       $this->clearLastError();
+                       if ( $value === false ) {
+                               $success = true; // do nothing
+                       } elseif ( $hadNoCurrentValue ) {
+                               // Try to create the key, failing if it gets created in the meantime
+                               $success = $this->add( $key, $value, $exptime, $flags );
+                       } else {
+                               // Try to update the key, failing if it gets changed in the meantime
+                               $success = $this->cas( $casToken, $key, $value, $exptime, $flags );
+                       }
+                       if ( $this->getLastError() ) {
+                               $this->logger->warning(
+                                       __METHOD__ . ' failed due to I/O error for {key}.',
+                                       [ 'key' => $key ]
+                               );
+
+                               return false; // IO error; don't spam retries
+                       }
+
+               } while ( !$success && --$attempts );
+
+               return $success;
+       }
+
+       /**
+        * Check and set an item
+        *
+        * @param mixed $casToken
+        * @param string $key
+        * @param mixed $value
+        * @param int $exptime Either an interval in seconds or a unix timestamp for expiry
+        * @param int $flags Bitfield of BagOStuff::WRITE_* constants
+        * @return bool Success
+        */
+       protected function cas( $casToken, $key, $value, $exptime = 0, $flags = 0 ) {
+               if ( !$this->lock( $key, 0 ) ) {
+                       return false; // non-blocking
+               }
+
+               $curCasToken = null; // passed by reference
+               $this->doGet( $key, self::READ_LATEST, $curCasToken );
+               if ( $casToken === $curCasToken ) {
+                       $success = $this->set( $key, $value, $exptime, $flags );
+               } else {
+                       $this->logger->info(
+                               __METHOD__ . ' failed due to race condition for {key}.',
+                               [ 'key' => $key ]
+                       );
+
+                       $success = false; // mismatched or failed
+               }
+
+               $this->unlock( $key );
+
+               return $success;
+       }
+
+       /**
+        * Change the expiration on a key if it exists
+        *
+        * If an expiry in the past is given then the key will immediately be expired
+        *
+        * For large values written using WRITE_ALLOW_SEGMENTS, this only changes the TTL of the
+        * main segment list key. While lowering the TTL of the segment list key has the effect of
+        * functionally lowering the TTL of the key, it might leave unused blobs in cache for longer.
+        * Raising the TTL of such keys is not effective, since the expiration of a single segment
+        * key effectively expires the entire value.
+        *
+        * @param string $key
+        * @param int $exptime TTL or UNIX timestamp
+        * @param int $flags Bitfield of BagOStuff::WRITE_* constants (since 1.33)
+        * @return bool Success Returns false on failure or if the item does not exist
+        * @since 1.28
+        */
+       public function changeTTL( $key, $exptime = 0, $flags = 0 ) {
+               return $this->doChangeTTL( $key, $exptime, $flags );
+       }
+
+       /**
+        * @param string $key
+        * @param int $exptime
+        * @param int $flags
+        * @return bool
+        */
+       protected function doChangeTTL( $key, $exptime, $flags ) {
+               $expiry = $this->convertToExpiry( $exptime );
+               $delete = ( $expiry != 0 && $expiry < $this->getCurrentTime() );
+
+               if ( !$this->lock( $key, 0 ) ) {
+                       return false;
+               }
+               // Use doGet() to avoid having to trigger resolveSegments()
+               $blob = $this->doGet( $key, self::READ_LATEST );
+               if ( $blob ) {
+                       if ( $delete ) {
+                               $ok = $this->doDelete( $key, $flags );
+                       } else {
+                               $ok = $this->doSet( $key, $blob, $exptime, $flags );
+                       }
+               } else {
+                       $ok = false;
+               }
+
+               $this->unlock( $key );
+
+               return $ok;
+       }
+
+       /**
+        * Acquire an advisory lock on a key string
+        *
+        * Note that if reentry is enabled, duplicate calls ignore $expiry
+        *
+        * @param string $key
+        * @param int $timeout Lock wait timeout; 0 for non-blocking [optional]
+        * @param int $expiry Lock expiry [optional]; 1 day maximum
+        * @param string $rclass Allow reentry if set and the current lock used this value
+        * @return bool Success
+        */
+       public function lock( $key, $timeout = 6, $expiry = 6, $rclass = '' ) {
+               // Avoid deadlocks and allow lock reentry if specified
+               if ( isset( $this->locks[$key] ) ) {
+                       if ( $rclass != '' && $this->locks[$key]['class'] === $rclass ) {
+                               ++$this->locks[$key]['depth'];
+                               return true;
+                       } else {
+                               return false;
+                       }
+               }
+
+               $fname = __METHOD__;
+               $expiry = min( $expiry ?: INF, self::TTL_DAY );
+               $loop = new WaitConditionLoop(
+                       function () use ( $key, $expiry, $fname ) {
+                               $this->clearLastError();
+                               if ( $this->add( "{$key}:lock", 1, $expiry ) ) {
+                                       return WaitConditionLoop::CONDITION_REACHED; // locked!
+                               } elseif ( $this->getLastError() ) {
+                                       $this->logger->warning(
+                                               $fname . ' failed due to I/O error for {key}.',
+                                               [ 'key' => $key ]
+                                       );
+
+                                       return WaitConditionLoop::CONDITION_ABORTED; // network partition?
+                               }
+
+                               return WaitConditionLoop::CONDITION_CONTINUE;
+                       },
+                       $timeout
+               );
+
+               $code = $loop->invoke();
+               $locked = ( $code === $loop::CONDITION_REACHED );
+               if ( $locked ) {
+                       $this->locks[$key] = [ 'class' => $rclass, 'depth' => 1 ];
+               } elseif ( $code === $loop::CONDITION_TIMED_OUT ) {
+                       $this->logger->warning(
+                               "$fname failed due to timeout for {key}.",
+                               [ 'key' => $key, 'timeout' => $timeout ]
+                       );
+               }
+
+               return $locked;
+       }
+
+       /**
+        * Release an advisory lock on a key string
+        *
+        * @param string $key
+        * @return bool Success
+        */
+       public function unlock( $key ) {
+               if ( !isset( $this->locks[$key] ) ) {
+                       return false;
+               }
+
+               if ( --$this->locks[$key]['depth'] <= 0 ) {
+                       unset( $this->locks[$key] );
+
+                       $ok = $this->doDelete( "{$key}:lock" );
+                       if ( !$ok ) {
+                               $this->logger->warning(
+                                       __METHOD__ . ' failed to release lock for {key}.',
+                                       [ 'key' => $key ]
+                               );
+                       }
+
+                       return $ok;
+               }
+
+               return true;
+       }
+
+       /**
+        * Delete all objects expiring before a certain date.
+        * @param string|int $timestamp The reference date in MW or TS_UNIX format
+        * @param callable|null $progress Optional, a function which will be called
+        *     regularly during long-running operations with the percentage progress
+        *     as the first parameter. [optional]
+        * @param int $limit Maximum number of keys to delete [default: INF]
+        *
+        * @return bool Success; false if unimplemented
+        */
+       public function deleteObjectsExpiringBefore(
+               $timestamp,
+               callable $progress = null,
+               $limit = INF
+       ) {
+               return false;
+       }
+
+       /**
+        * Get an associative array containing the item for each of the keys that have items.
+        * @param string[] $keys List of keys; can be a map of (unused => key) for convenience
+        * @param int $flags Bitfield; supports READ_LATEST [optional]
+        * @return mixed[] Map of (key => value) for existing keys; preserves the order of $keys
+        */
+       public function getMulti( array $keys, $flags = 0 ) {
+               $foundByKey = $this->doGetMulti( $keys, $flags );
+
+               $res = [];
+               foreach ( $keys as $key ) {
+                       // Resolve one blob at a time (avoids too much I/O at once)
+                       if ( array_key_exists( $key, $foundByKey ) ) {
+                               // A value should not appear in the key if a segment is missing
+                               $value = $this->resolveSegments( $key, $foundByKey[$key] );
+                               if ( $value !== false ) {
+                                       $res[$key] = $value;
+                               }
+                       }
+               }
+
+               return $res;
+       }
+
+       /**
+        * Get an associative array containing the item for each of the keys that have items.
+        * @param string[] $keys List of keys
+        * @param int $flags Bitfield; supports READ_LATEST [optional]
+        * @return array Map of (key => value) for existing keys
+        */
+       protected function doGetMulti( array $keys, $flags = 0 ) {
+               $res = [];
+               foreach ( $keys as $key ) {
+                       $val = $this->doGet( $key, $flags );
+                       if ( $val !== false ) {
+                               $res[$key] = $val;
+                       }
+               }
+
+               return $res;
+       }
+
+       /**
+        * Batch insertion/replace
+        *
+        * This does not support WRITE_ALLOW_SEGMENTS to avoid excessive read I/O
+        *
+        * @param mixed[] $data Map of (key => value)
+        * @param int $exptime Either an interval in seconds or a unix timestamp for expiry
+        * @param int $flags Bitfield of BagOStuff::WRITE_* constants (since 1.33)
+        * @return bool Success
+        * @since 1.24
+        */
+       public function setMulti( array $data, $exptime = 0, $flags = 0 ) {
+               if ( ( $flags & self::WRITE_ALLOW_SEGMENTS ) === self::WRITE_ALLOW_SEGMENTS ) {
+                       throw new InvalidArgumentException( __METHOD__ . ' got WRITE_ALLOW_SEGMENTS' );
+               }
+               return $this->doSetMulti( $data, $exptime, $flags );
+       }
+
+       /**
+        * @param mixed[] $data Map of (key => value)
+        * @param int $exptime Either an interval in seconds or a unix timestamp for expiry
+        * @param int $flags Bitfield of BagOStuff::WRITE_* constants
+        * @return bool Success
+        */
+       protected function doSetMulti( array $data, $exptime = 0, $flags = 0 ) {
+               $res = true;
+               foreach ( $data as $key => $value ) {
+                       $res = $this->doSet( $key, $value, $exptime, $flags ) && $res;
+               }
+               return $res;
+       }
+
+       /**
+        * Batch deletion
+        *
+        * This does not support WRITE_ALLOW_SEGMENTS to avoid excessive read I/O
+        *
+        * @param string[] $keys List of keys
+        * @param int $flags Bitfield of BagOStuff::WRITE_* constants
+        * @return bool Success
+        * @since 1.33
+        */
+       public function deleteMulti( array $keys, $flags = 0 ) {
+               if ( ( $flags & self::WRITE_ALLOW_SEGMENTS ) === self::WRITE_ALLOW_SEGMENTS ) {
+                       throw new InvalidArgumentException( __METHOD__ . ' got WRITE_ALLOW_SEGMENTS' );
+               }
+               return $this->doDeleteMulti( $keys, $flags );
+       }
+
+       /**
+        * @param string[] $keys List of keys
+        * @param int $flags Bitfield of BagOStuff::WRITE_* constants
+        * @return bool Success
+        */
+       protected function doDeleteMulti( array $keys, $flags = 0 ) {
+               $res = true;
+               foreach ( $keys as $key ) {
+                       $res = $this->doDelete( $key, $flags ) && $res;
+               }
+               return $res;
+       }
+
+       /**
+        * Change the expiration of multiple keys that exist
+        *
+        * @param string[] $keys List of keys
+        * @param int $exptime TTL or UNIX timestamp
+        * @param int $flags Bitfield of BagOStuff::WRITE_* constants (since 1.33)
+        * @return bool Success
+        * @see BagOStuff::changeTTL()
+        *
+        * @since 1.34
+        */
+       public function changeTTLMulti( array $keys, $exptime, $flags = 0 ) {
+               $res = true;
+               foreach ( $keys as $key ) {
+                       $res = $this->doChangeTTL( $key, $exptime, $flags ) && $res;
+               }
+
+               return $res;
+       }
+
+       /**
+        * Decrease stored value of $key by $value while preserving its TTL
+        * @param string $key
+        * @param int $value Value to subtract from $key (default: 1) [optional]
+        * @return int|bool New value or false on failure
+        */
+       public function decr( $key, $value = 1 ) {
+               return $this->incr( $key, -$value );
+       }
+
+       /**
+        * Increase stored value of $key by $value while preserving its TTL
+        *
+        * This will create the key with value $init and TTL $ttl instead if not present
+        *
+        * @param string $key
+        * @param int $ttl
+        * @param int $value
+        * @param int $init
+        * @return int|bool New value or false on failure
+        * @since 1.24
+        */
+       public function incrWithInit( $key, $ttl, $value = 1, $init = 1 ) {
+               $this->clearLastError();
+               $newValue = $this->incr( $key, $value );
+               if ( $newValue === false && !$this->getLastError() ) {
+                       // No key set; initialize
+                       $newValue = $this->add( $key, (int)$init, $ttl ) ? $init : false;
+                       if ( $newValue === false && !$this->getLastError() ) {
+                               // Raced out initializing; increment
+                               $newValue = $this->incr( $key, $value );
+                       }
+               }
+
+               return $newValue;
+       }
+
+       /**
+        * Get and reassemble the chunks of blob at the given key
+        *
+        * @param string $key
+        * @param mixed $mainValue
+        * @return string|null|bool The combined string, false if missing, null on error
+        */
+       final protected function resolveSegments( $key, $mainValue ) {
+               if ( SerializedValueContainer::isUnified( $mainValue ) ) {
+                       return $this->unserialize( $mainValue->{SerializedValueContainer::UNIFIED_DATA} );
+               }
+
+               if ( SerializedValueContainer::isSegmented( $mainValue ) ) {
+                       $orderedKeys = array_map(
+                               function ( $segmentHash ) use ( $key ) {
+                                       return $this->makeGlobalKey( self::SEGMENT_COMPONENT, $key, $segmentHash );
+                               },
+                               $mainValue->{SerializedValueContainer::SEGMENTED_HASHES}
+                       );
+
+                       $segmentsByKey = $this->doGetMulti( $orderedKeys );
+
+                       $parts = [];
+                       foreach ( $orderedKeys as $segmentKey ) {
+                               if ( isset( $segmentsByKey[$segmentKey] ) ) {
+                                       $parts[] = $segmentsByKey[$segmentKey];
+                               } else {
+                                       return false; // missing segment
+                               }
+                       }
+
+                       return $this->unserialize( implode( '', $parts ) );
+               }
+
+               return $mainValue;
+       }
+
+       /**
+        * Get the "last error" registered; clearLastError() should be called manually
+        * @return int ERR_* constant for the "last error" registry
+        * @since 1.23
+        */
+       public function getLastError() {
+               return $this->lastError;
+       }
+
+       /**
+        * Clear the "last error" registry
+        * @since 1.23
+        */
+       public function clearLastError() {
+               $this->lastError = self::ERR_NONE;
+       }
+
+       /**
+        * Set the "last error" registry
+        * @param int $err ERR_* constant
+        * @since 1.23
+        */
+       protected function setLastError( $err ) {
+               $this->lastError = $err;
+       }
+
+       /**
+        * Let a callback be run to avoid wasting time on special blocking calls
+        *
+        * The callbacks may or may not be called ever, in any particular order.
+        * They are likely to be invoked when something WRITE_SYNC is used used.
+        * They should follow a caching pattern as shown below, so that any code
+        * using the work will get it's result no matter what happens.
+        * @code
+        *     $result = null;
+        *     $workCallback = function () use ( &$result ) {
+        *         if ( !$result ) {
+        *             $result = ....
+        *         }
+        *         return $result;
+        *     }
+        * @endcode
+        *
+        * @param callable $workCallback
+        * @since 1.28
+        */
+       final public function addBusyCallback( callable $workCallback ) {
+               $this->busyCallbacks[] = $workCallback;
+       }
+
+       /**
+        * @param int $exptime
+        * @return bool
+        */
+       final protected function expiryIsRelative( $exptime ) {
+               return ( $exptime != 0 && $exptime < ( 10 * self::TTL_YEAR ) );
+       }
+
+       /**
+        * Convert an optionally relative timestamp to an absolute time
+        *
+        * The input value will be cast to an integer and interpreted as follows:
+        *   - zero: no expiry; return zero (e.g. TTL_INDEFINITE)
+        *   - negative: relative TTL; return UNIX timestamp offset by this value
+        *   - positive (< 10 years): relative TTL; return UNIX timestamp offset by this value
+        *   - positive (>= 10 years): absolute UNIX timestamp; return this value
+        *
+        * @param int $exptime Absolute TTL or 0 for indefinite
+        * @return int
+        */
+       final protected function convertToExpiry( $exptime ) {
+               return $this->expiryIsRelative( $exptime )
+                       ? (int)$this->getCurrentTime() + $exptime
+                       : $exptime;
+       }
+
+       /**
+        * Convert an optionally absolute expiry time to a relative time. If an
+        * absolute time is specified which is in the past, use a short expiry time.
+        *
+        * @param int $exptime
+        * @return int
+        */
+       final protected function convertToRelative( $exptime ) {
+               return $this->expiryIsRelative( $exptime )
+                       ? (int)$exptime
+                       : max( $exptime - (int)$this->getCurrentTime(), 1 );
+       }
+
+       /**
+        * Check if a value is an integer
+        *
+        * @param mixed $value
+        * @return bool
+        */
+       final protected function isInteger( $value ) {
+               if ( is_int( $value ) ) {
+                       return true;
+               } elseif ( !is_string( $value ) ) {
+                       return false;
+               }
+
+               $integer = (int)$value;
+
+               return ( $value === (string)$integer );
+       }
+
+       /**
+        * Construct a cache key.
+        *
+        * @param string $keyspace
+        * @param array $args
+        * @return string Colon-delimited list of $keyspace followed by escaped components of $args
+        * @since 1.27
+        */
+       public function makeKeyInternal( $keyspace, $args ) {
+               $key = $keyspace;
+               foreach ( $args as $arg ) {
+                       $key .= ':' . str_replace( ':', '%3A', $arg );
+               }
+               return strtr( $key, ' ', '_' );
+       }
+
+       /**
+        * Make a global cache key.
+        *
+        * @param string $class Key class
+        * @param string|null $component [optional] Key component (starting with a key collection name)
+        * @return string Colon-delimited list of $keyspace followed by escaped components of $args
+        * @since 1.27
+        */
+       public function makeGlobalKey( $class, $component = null ) {
+               return $this->makeKeyInternal( 'global', func_get_args() );
+       }
+
+       /**
+        * Make a cache key, scoped to this instance's keyspace.
+        *
+        * @param string $class Key class
+        * @param string|null $component [optional] Key component (starting with a key collection name)
+        * @return string Colon-delimited list of $keyspace followed by escaped components of $args
+        * @since 1.27
+        */
+       public function makeKey( $class, $component = null ) {
+               return $this->makeKeyInternal( $this->keyspace, func_get_args() );
+       }
+
+       /**
+        * @param int $flag ATTR_* class constant
+        * @return int QOS_* class constant
+        * @since 1.28
+        */
+       public function getQoS( $flag ) {
+               return $this->attrMap[$flag] ?? self::QOS_UNKNOWN;
+       }
+
+       /**
+        * @return int|float The chunk size, in bytes, of segmented objects (INF for no limit)
+        * @since 1.34
+        */
+       public function getSegmentationSize() {
+               return $this->segmentationSize;
+       }
+
+       /**
+        * @return int|float Maximum total segmented object size in bytes (INF for no limit)
+        * @since 1.34
+        */
+       public function getSegmentedValueMaxSize() {
+               return $this->segmentedValueMaxSize;
+       }
+
+       /**
+        * @param mixed $value
+        * @return string|int String/integer representation
+        * @note Special handling is usually needed for integers so incr()/decr() work
+        */
+       protected function serialize( $value ) {
+               return is_int( $value ) ? $value : serialize( $value );
+       }
+
+       /**
+        * @param string|int $value
+        * @return mixed Original value or false on error
+        * @note Special handling is usually needed for integers so incr()/decr() work
+        */
+       protected function unserialize( $value ) {
+               return $this->isInteger( $value ) ? (int)$value : unserialize( $value );
+       }
+
+       /**
+        * @param string $text
+        */
+       protected function debug( $text ) {
+               if ( $this->debugMode ) {
+                       $this->logger->debug( "{class} debug: $text", [ 'class' => static::class ] );
+               }
+       }
+}
index f75e780..9f1c98a 100644 (file)
@@ -26,7 +26,7 @@
  *
  * @ingroup Cache
  */
-abstract class MemcachedBagOStuff extends BagOStuff {
+abstract class MemcachedBagOStuff extends MediumSpecificBagOStuff {
        function __construct( array $params ) {
                parent::__construct( $params );
 
index 221bc82..cc7ee2a 100644 (file)
  */
 class MemcachedPeclBagOStuff extends MemcachedBagOStuff {
        /** @var Memcached */
-       protected $client;
+       protected $syncClient;
+       /** @var Memcached|null */
+       protected $asyncClient;
+
+       /** @var bool Whether the non-buffering client is locked from use */
+       protected $syncClientIsBuffering = false;
+       /** @var bool Whether the non-buffering client should be flushed before use */
+       protected $hasUnflushedChanges = false;
+
+       /** @var array Memcached options */
+       private static $OPTS_SYNC_WRITES = [
+               Memcached::OPT_NO_BLOCK => false, // async I/O (using TCP buffers)
+               Memcached::OPT_BUFFER_WRITES => false // libmemcached buffers
+       ];
+       /** @var array Memcached options */
+       private static $OPTS_ASYNC_WRITES = [
+               Memcached::OPT_NO_BLOCK => true, // async I/O (using TCP buffers)
+               Memcached::OPT_BUFFER_WRITES => true // libmemcached buffers
+       ];
 
        /**
         * Available parameters are:
@@ -63,15 +81,22 @@ class MemcachedPeclBagOStuff extends MemcachedBagOStuff {
                        // The Memcached object is essentially shared for each pool ID.
                        // We can only reuse a pool ID if we keep the config consistent.
                        $connectionPoolId = md5( serialize( $params ) );
-                       $client = new Memcached( $connectionPoolId );
-                       $this->initializeClient( $client, $params );
+                       $syncClient = new Memcached( "$connectionPoolId-sync" );
+                       // Avoid clobbering the main thread-shared Memcached instance
+                       $asyncClient = new Memcached( "$connectionPoolId-async" );
                } else {
-                       $client = new Memcached;
-                       $this->initializeClient( $client, $params );
+                       $syncClient = new Memcached();
+                       $asyncClient = null;
                }
 
-               $this->client = $client;
+               $this->initializeClient( $syncClient, $params, self::$OPTS_SYNC_WRITES );
+               if ( $asyncClient ) {
+                       $this->initializeClient( $asyncClient, $params, self::$OPTS_ASYNC_WRITES );
+               }
 
+               // Set the main client and any dedicated one for buffered writes
+               $this->syncClient = $syncClient;
+               $this->asyncClient = $asyncClient;
                // The compression threshold is an undocumented php.ini option for some
                // reason. There's probably not much harm in setting it globally, for
                // compatibility with the settings for the PHP client.
@@ -84,9 +109,10 @@ class MemcachedPeclBagOStuff extends MemcachedBagOStuff {
         *
         * @param Memcached $client
         * @param array $params
+        * @param array $options Base options for Memcached::setOptions()
         * @throws RuntimeException
         */
-       private function initializeClient( Memcached $client, array $params ) {
+       private function initializeClient( Memcached $client, array $params, array $options ) {
                if ( $client->getServerList() ) {
                        $this->logger->debug( __METHOD__ . ": pre-initialized client instance." );
 
@@ -95,7 +121,9 @@ class MemcachedPeclBagOStuff extends MemcachedBagOStuff {
 
                $this->logger->debug( __METHOD__ . ": initializing new client instance." );
 
-               $options = [
+               $options += [
+                       Memcached::OPT_NO_BLOCK => false,
+                       Memcached::OPT_BUFFER_WRITES => false,
                        // Network protocol (ASCII or binary)
                        Memcached::OPT_BINARY_PROTOCOL => $params['use_binary_protocol'],
                        // Set various network timeouts
@@ -150,10 +178,12 @@ class MemcachedPeclBagOStuff extends MemcachedBagOStuff {
 
        protected function doGet( $key, $flags = 0, &$casToken = null ) {
                $this->debug( "get($key)" );
+
+               $client = $this->acquireSyncClient();
                if ( defined( Memcached::class . '::GET_EXTENDED' ) ) { // v3.0.0
                        /** @noinspection PhpUndefinedClassConstantInspection */
                        $flags = Memcached::GET_EXTENDED;
-                       $res = $this->client->get( $this->validateKeyEncoding( $key ), null, $flags );
+                       $res = $client->get( $this->validateKeyEncoding( $key ), null, $flags );
                        if ( is_array( $res ) ) {
                                $result = $res['value'];
                                $casToken = $res['cas'];
@@ -162,62 +192,77 @@ class MemcachedPeclBagOStuff extends MemcachedBagOStuff {
                                $casToken = null;
                        }
                } else {
-                       $result = $this->client->get( $this->validateKeyEncoding( $key ), null, $casToken );
+                       $result = $client->get( $this->validateKeyEncoding( $key ), null, $casToken );
                }
-               $result = $this->checkResult( $key, $result );
-               return $result;
+
+               return $this->checkResult( $key, $result );
        }
 
        protected function doSet( $key, $value, $exptime = 0, $flags = 0 ) {
                $this->debug( "set($key)" );
-               $result = $this->client->set(
+
+               $client = $this->acquireSyncClient();
+               $result = $client->set(
                        $this->validateKeyEncoding( $key ),
                        $value,
                        $this->fixExpiry( $exptime )
                );
-               if ( $result === false && $this->client->getResultCode() === Memcached::RES_NOTSTORED ) {
+
+               return ( $result === false && $client->getResultCode() === Memcached::RES_NOTSTORED )
                        // "Not stored" is always used as the mcrouter response with AllAsyncRoute
-                       return true;
-               }
-               return $this->checkResult( $key, $result );
+                       ? true
+                       : $this->checkResult( $key, $result );
        }
 
        protected function cas( $casToken, $key, $value, $exptime = 0, $flags = 0 ) {
                $this->debug( "cas($key)" );
-               $result = $this->client->cas( $casToken, $this->validateKeyEncoding( $key ),
-                       $value, $this->fixExpiry( $exptime ) );
+
+               $result = $this->acquireSyncClient()->cas(
+                       $casToken,
+                       $this->validateKeyEncoding( $key ),
+                       $value, $this->fixExpiry( $exptime )
+               );
+
                return $this->checkResult( $key, $result );
        }
 
        protected function doDelete( $key, $flags = 0 ) {
                $this->debug( "delete($key)" );
-               $result = $this->client->delete( $this->validateKeyEncoding( $key ) );
-               if ( $result === false && $this->client->getResultCode() === Memcached::RES_NOTFOUND ) {
+
+               $client = $this->acquireSyncClient();
+               $result = $client->delete( $this->validateKeyEncoding( $key ) );
+
+               return ( $result === false && $client->getResultCode() === Memcached::RES_NOTFOUND )
                        // "Not found" is counted as success in our interface
-                       return true;
-               }
-               return $this->checkResult( $key, $result );
+                       ? true
+                       : $this->checkResult( $key, $result );
        }
 
        public function add( $key, $value, $exptime = 0, $flags = 0 ) {
                $this->debug( "add($key)" );
-               $result = $this->client->add(
+
+               $result = $this->acquireSyncClient()->add(
                        $this->validateKeyEncoding( $key ),
                        $value,
                        $this->fixExpiry( $exptime )
                );
+
                return $this->checkResult( $key, $result );
        }
 
        public function incr( $key, $value = 1 ) {
                $this->debug( "incr($key)" );
-               $result = $this->client->increment( $key, $value );
+
+               $result = $this->acquireSyncClient()->increment( $key, $value );
+
                return $this->checkResult( $key, $result );
        }
 
        public function decr( $key, $value = 1 ) {
                $this->debug( "decr($key)" );
-               $result = $this->client->decrement( $key, $value );
+
+               $result = $this->acquireSyncClient()->decrement( $key, $value );
+
                return $this->checkResult( $key, $result );
        }
 
@@ -236,22 +281,25 @@ class MemcachedPeclBagOStuff extends MemcachedBagOStuff {
                if ( $result !== false ) {
                        return $result;
                }
-               switch ( $this->client->getResultCode() ) {
+
+               $client = $this->syncClient;
+               switch ( $client->getResultCode() ) {
                        case Memcached::RES_SUCCESS:
                                break;
                        case Memcached::RES_DATA_EXISTS:
                        case Memcached::RES_NOTSTORED:
                        case Memcached::RES_NOTFOUND:
-                               $this->debug( "result: " . $this->client->getResultMessage() );
+                               $this->debug( "result: " . $client->getResultMessage() );
                                break;
                        default:
-                               $msg = $this->client->getResultMessage();
+                               $msg = $client->getResultMessage();
                                $logCtx = [];
                                if ( $key !== false ) {
-                                       $server = $this->client->getServerByKey( $key );
+                                       $server = $client->getServerByKey( $key );
                                        $logCtx['memcached-server'] = "{$server['host']}:{$server['port']}";
                                        $logCtx['memcached-key'] = $key;
-                                       $msg = "Memcached error for key \"{memcached-key}\" on server \"{memcached-server}\": $msg";
+                                       $msg = "Memcached error for key \"{memcached-key}\" " .
+                                               "on server \"{memcached-server}\": $msg";
                                } else {
                                        $msg = "Memcached error: $msg";
                                }
@@ -263,41 +311,71 @@ class MemcachedPeclBagOStuff extends MemcachedBagOStuff {
 
        protected function doGetMulti( array $keys, $flags = 0 ) {
                $this->debug( 'getMulti(' . implode( ', ', $keys ) . ')' );
+
                foreach ( $keys as $key ) {
                        $this->validateKeyEncoding( $key );
                }
-               $result = $this->client->getMulti( $keys ) ?: [];
+
+               // The PECL implementation uses "gets" which works as well as a pipeline
+               $result = $this->acquireSyncClient()->getMulti( $keys ) ?: [];
+
                return $this->checkResult( false, $result );
        }
 
        protected function doSetMulti( array $data, $exptime = 0, $flags = 0 ) {
                $this->debug( 'setMulti(' . implode( ', ', array_keys( $data ) ) . ')' );
+
+               $exptime = $this->fixExpiry( $exptime );
                foreach ( array_keys( $data ) as $key ) {
                        $this->validateKeyEncoding( $key );
                }
-               $result = $this->client->setMulti( $data, $this->fixExpiry( $exptime ) );
+
+               // The PECL implementation is a naïve for-loop so use async I/O to pipeline;
+               // https://github.com/php-memcached-dev/php-memcached/blob/master/php_memcached.c#L1852
+               if ( ( $flags & self::WRITE_BACKGROUND ) == self::WRITE_BACKGROUND ) {
+                       $client = $this->acquireAsyncClient();
+                       $result = $client->setMulti( $data, $exptime );
+                       $this->releaseAsyncClient( $client );
+               } else {
+                       $result = $this->acquireSyncClient()->setMulti( $data, $exptime );
+               }
+
                return $this->checkResult( false, $result );
        }
 
        protected function doDeleteMulti( array $keys, $flags = 0 ) {
                $this->debug( 'deleteMulti(' . implode( ', ', $keys ) . ')' );
+
                foreach ( $keys as $key ) {
                        $this->validateKeyEncoding( $key );
                }
-               $result = $this->client->deleteMulti( $keys ) ?: [];
-               $ok = true;
-               foreach ( $result as $code ) {
+
+               // The PECL implementation is a naïve for-loop so use async I/O to pipeline;
+               // https://github.com/php-memcached-dev/php-memcached/blob/7443d16d02fb73cdba2e90ae282446f80969229c/php_memcached.c#L1852
+               if ( ( $flags & self::WRITE_BACKGROUND ) == self::WRITE_BACKGROUND ) {
+                       $client = $this->acquireAsyncClient();
+                       $resultArray = $client->deleteMulti( $keys ) ?: [];
+                       $this->releaseAsyncClient( $client );
+               } else {
+                       $resultArray = $this->acquireSyncClient()->deleteMulti( $keys ) ?: [];
+               }
+
+               $result = true;
+               foreach ( $resultArray as $code ) {
                        if ( !in_array( $code, [ true, Memcached::RES_NOTFOUND ], true ) ) {
                                // "Not found" is counted as success in our interface
-                               $ok = false;
+                               $result = false;
                        }
                }
-               return $this->checkResult( false, $ok );
+
+               return $this->checkResult( false, $result );
        }
 
        protected function doChangeTTL( $key, $exptime, $flags ) {
                $this->debug( "touch($key)" );
-               $result = $this->client->touch( $key, $exptime );
+
+               $result = $this->acquireSyncClient()->touch( $key, $this->fixExpiry( $exptime ) );
+
                return $this->checkResult( $key, $result );
        }
 
@@ -306,7 +384,7 @@ class MemcachedPeclBagOStuff extends MemcachedBagOStuff {
                        return $value;
                }
 
-               $serializer = $this->client->getOption( Memcached::OPT_SERIALIZER );
+               $serializer = $this->syncClient->getOption( Memcached::OPT_SERIALIZER );
                if ( $serializer === Memcached::SERIALIZER_PHP ) {
                        return serialize( $value );
                } elseif ( $serializer === Memcached::SERIALIZER_IGBINARY ) {
@@ -321,7 +399,7 @@ class MemcachedPeclBagOStuff extends MemcachedBagOStuff {
                        return (int)$value;
                }
 
-               $serializer = $this->client->getOption( Memcached::OPT_SERIALIZER );
+               $serializer = $this->syncClient->getOption( Memcached::OPT_SERIALIZER );
                if ( $serializer === Memcached::SERIALIZER_PHP ) {
                        return unserialize( $value );
                } elseif ( $serializer === Memcached::SERIALIZER_IGBINARY ) {
@@ -330,4 +408,52 @@ class MemcachedPeclBagOStuff extends MemcachedBagOStuff {
 
                throw new UnexpectedValueException( __METHOD__ . ": got serializer '$serializer'." );
        }
+
+       /**
+        * @return Memcached
+        */
+       private function acquireSyncClient() {
+               if ( $this->syncClientIsBuffering ) {
+                       throw new RuntimeException( "The main (unbuffered I/O) client is locked" );
+               }
+
+               if ( $this->hasUnflushedChanges ) {
+                       // Force a synchronous flush of async writes so that their changes are visible
+                       $this->syncClient->fetch();
+                       if ( $this->asyncClient ) {
+                               $this->asyncClient->fetch();
+                       }
+                       $this->hasUnflushedChanges = false;
+               }
+
+               return $this->syncClient;
+       }
+
+       /**
+        * @return Memcached
+        */
+       private function acquireAsyncClient() {
+               if ( $this->asyncClient ) {
+                       return $this->asyncClient; // dedicated buffering instance
+               }
+
+               // Modify the main instance to temporarily buffer writes
+               $this->syncClientIsBuffering = true;
+               $this->syncClient->setOptions( self::$OPTS_ASYNC_WRITES );
+
+               return $this->syncClient;
+       }
+
+       /**
+        * @param Memcached $client
+        */
+       private function releaseAsyncClient( $client ) {
+               $this->hasUnflushedChanges = true;
+
+               if ( !$this->asyncClient ) {
+                       // This is the main instance; make it stop buffering writes again
+                       $client->setOptions( self::$OPTS_SYNC_WRITES );
+                       $this->syncClientIsBuffering = false;
+               }
+       }
 }
index f8b91bc..b1d5d29 100644 (file)
@@ -53,8 +53,9 @@ class MemcachedPhpBagOStuff extends MemcachedBagOStuff {
                $this->client->set_servers( $params['servers'] );
        }
 
-       public function setDebug( $debug ) {
-               $this->client->set_debug( $debug );
+       public function setDebug( $enabled ) {
+               parent::debug( $enabled );
+               $this->client->set_debug( $enabled );
        }
 
        protected function doGet( $key, $flags = 0, &$casToken = null ) {
index 8e791ba..6e9f387 100644 (file)
@@ -40,7 +40,8 @@ class MultiWriteBagOStuff extends BagOStuff {
        /** @var int[] List of all backing cache indexes */
        protected $cacheIndexes = [];
 
-       const UPGRADE_TTL = 3600; // TTL when a key is copied to a higher cache tier
+       /** @var int TTL when a key is copied to a higher cache tier */
+       private static $UPGRADE_TTL = 3600;
 
        /**
         * $params include:
@@ -97,9 +98,10 @@ class MultiWriteBagOStuff extends BagOStuff {
                $this->cacheIndexes = array_keys( $this->caches );
        }
 
-       public function setDebug( $debug ) {
+       public function setDebug( $enabled ) {
+               parent::setDebug( $enabled );
                foreach ( $this->caches as $cache ) {
-                       $cache->setDebug( $debug );
+                       $cache->setDebug( $enabled );
                }
        }
 
@@ -131,7 +133,7 @@ class MultiWriteBagOStuff extends BagOStuff {
                                $this->asyncWrites,
                                'set',
                                // @TODO: consider using self::WRITE_ALLOW_SEGMENTS here?
-                               [ $key, $value, self::UPGRADE_TTL ]
+                               [ $key, $value, self::$UPGRADE_TTL ]
                        );
                }
 
@@ -359,39 +361,15 @@ class MultiWriteBagOStuff extends BagOStuff {
                return $this->caches[0]->makeGlobalKey( ...func_get_args() );
        }
 
-       protected function doGet( $key, $flags = 0, &$casToken = null ) {
-               throw new LogicException( __METHOD__ . ': proxy class does not need this method.' );
+       public function addBusyCallback( callable $workCallback ) {
+               $this->caches[0]->addBusyCallback( $workCallback );
        }
 
-       protected function doSet( $key, $value, $exptime = 0, $flags = 0 ) {
-               throw new LogicException( __METHOD__ . ': proxy class does not need this method.' );
-       }
-
-       protected function doDelete( $key, $flags = 0 ) {
-               throw new LogicException( __METHOD__ . ': proxy class does not need this method.' );
-       }
-
-       protected function doChangeTTL( $key, $exptime, $flags ) {
-               throw new LogicException( __METHOD__ . ': proxy class does not need this method.' );
-       }
-
-       protected function doGetMulti( array $keys, $flags = 0 ) {
-               throw new LogicException( __METHOD__ . ': proxy class does not need this method.' );
-       }
-
-       protected function doSetMulti( array $keys, $exptime = 0, $flags = 0 ) {
-               throw new LogicException( __METHOD__ . ': proxy class does not need this method.' );
-       }
-
-       protected function doDeleteMulti( array $keys, $flags = 0 ) {
-               throw new LogicException( __METHOD__ . ': proxy class does not need this method.' );
-       }
-
-       protected function serialize( $value ) {
-               throw new LogicException( __METHOD__ . ': proxy class does not need this method.' );
-       }
-
-       protected function unserialize( $blob ) {
-               throw new LogicException( __METHOD__ . ': proxy class does not need this method.' );
+       public function setMockTime( &$time ) {
+               parent::setMockTime( $time );
+               foreach ( $this->caches as $cache ) {
+                       $cache->setMockTime( $time );
+                       $cache->setMockTime( $time );
+               }
        }
 }
index 2a12689..aa4a9b3 100644 (file)
@@ -44,7 +44,7 @@ use Psr\Log\LoggerInterface;
  * $wgSessionCacheType = 'sessions';
  * @endcode
  */
-class RESTBagOStuff extends BagOStuff {
+class RESTBagOStuff extends MediumSpecificBagOStuff {
        /**
         * Default connection timeout in seconds. The kernel retransmits the SYN
         * packet after 1 second, so 1.2 seconds allows for 1 retransmit without
index 2d1ed05..87d26ef 100644 (file)
@@ -28,7 +28,7 @@
  * @ingroup Cache
  * @ingroup Redis
  */
-class RedisBagOStuff extends BagOStuff {
+class RedisBagOStuff extends MediumSpecificBagOStuff {
        /** @var RedisConnectionPool */
        protected $redisPool;
        /** @var array List of server names */
@@ -368,54 +368,11 @@ class RedisBagOStuff extends BagOStuff {
                }
 
                try {
-                       $conn->watch( $key );
-                       if ( $conn->exists( $key ) ) {
-                               $conn->multi( Redis::MULTI );
-                               $conn->incrBy( $key, $value );
-                               $batchResult = $conn->exec();
-                               if ( $batchResult === false ) {
-                                       $result = false;
-                               } else {
-                                       $result = end( $batchResult );
-                               }
-                       } else {
-                               $result = false;
-                               $conn->unwatch();
-                       }
-               } catch ( RedisException $e ) {
-                       try {
-                               $conn->unwatch(); // sanity
-                       } catch ( RedisException $ex ) {
-                               // already errored
-                       }
-                       $result = false;
-                       $this->handleException( $conn, $e );
-               }
-
-               $this->logRequest( 'incr', $key, $conn->getServer(), $result );
-
-               return $result;
-       }
-
-       public function incrWithInit( $key, $exptime, $value = 1, $init = 1 ) {
-               $conn = $this->getConnection( $key );
-               if ( !$conn ) {
-                       return false;
-               }
-
-               $ttl = $this->convertToRelative( $exptime );
-               $preIncrInit = $init - $value;
-               try {
-                       $conn->multi( Redis::MULTI );
-                       $conn->set( $key, $preIncrInit, $ttl ? [ 'nx', 'ex' => $ttl ] : [ 'nx' ] );
-                       $conn->incrBy( $key, $value );
-                       $batchResult = $conn->exec();
-                       if ( $batchResult === false ) {
-                               $result = false;
-                               $this->debug( "incrWithInit request to {$conn->getServer()} failed" );
-                       } else {
-                               $result = end( $batchResult );
+                       if ( !$conn->exists( $key ) ) {
+                               return false;
                        }
+                       // @FIXME: on races, the key may have a 0 TTL
+                       $result = $conn->incrBy( $key, $value );
                } catch ( RedisException $e ) {
                        $result = false;
                        $this->handleException( $conn, $e );
index 295ec30..e49fa10 100644 (file)
@@ -69,9 +69,10 @@ class ReplicatedBagOStuff extends BagOStuff {
                $this->attrMap = $this->mergeFlagMaps( [ $this->readStore, $this->writeStore ] );
        }
 
-       public function setDebug( $debug ) {
-               $this->writeStore->setDebug( $debug );
-               $this->readStore->setDebug( $debug );
+       public function setDebug( $enabled ) {
+               parent::setDebug( $enabled );
+               $this->writeStore->setDebug( $enabled );
+               $this->readStore->setDebug( $enabled );
        }
 
        public function get( $key, $flags = 0 ) {
@@ -169,39 +170,13 @@ class ReplicatedBagOStuff extends BagOStuff {
                return $this->writeStore->makeGlobalKey( ...func_get_args() );
        }
 
-       protected function doGet( $key, $flags = 0, &$casToken = null ) {
-               throw new LogicException( __METHOD__ . ': proxy class does not need this method.' );
+       public function addBusyCallback( callable $workCallback ) {
+               $this->writeStore->addBusyCallback( $workCallback );
        }
 
-       protected function doSet( $key, $value, $exptime = 0, $flags = 0 ) {
-               throw new LogicException( __METHOD__ . ': proxy class does not need this method.' );
-       }
-
-       protected function doDelete( $key, $flags = 0 ) {
-               throw new LogicException( __METHOD__ . ': proxy class does not need this method.' );
-       }
-
-       protected function doChangeTTL( $key, $exptime, $flags ) {
-               throw new LogicException( __METHOD__ . ': proxy class does not need this method.' );
-       }
-
-       protected function doGetMulti( array $keys, $flags = 0 ) {
-               throw new LogicException( __METHOD__ . ': proxy class does not need this method.' );
-       }
-
-       protected function doSetMulti( array $keys, $exptime = 0, $flags = 0 ) {
-               throw new LogicException( __METHOD__ . ': proxy class does not need this method.' );
-       }
-
-       protected function doDeleteMulti( array $keys, $flags = 0 ) {
-               throw new LogicException( __METHOD__ . ': proxy class does not need this method.' );
-       }
-
-       protected function serialize( $value ) {
-               throw new LogicException( __METHOD__ . ': proxy class does not need this method.' );
-       }
-
-       protected function unserialize( $blob ) {
-               throw new LogicException( __METHOD__ . ': proxy class does not need this method.' );
+       public function setMockTime( &$time ) {
+               parent::setMockTime( $time );
+               $this->writeStore->setMockTime( $time );
+               $this->readStore->setMockTime( $time );
        }
 }
index 65059c8..2a8da89 100644 (file)
@@ -118,20 +118,21 @@ class WANObjectCache implements IExpiringStore, IStoreKeyEncoder, LoggerAwareInt
        protected $cache;
        /** @var MapCacheLRU[] Map of group PHP instance caches */
        protected $processCaches = [];
+       /** @var LoggerInterface */
+       protected $logger;
+       /** @var StatsdDataFactoryInterface */
+       protected $stats;
+       /** @var callable|null Function that takes a WAN cache callback and runs it later */
+       protected $asyncHandler;
+
        /** @bar bool Whether to use mcrouter key prefixing for routing */
        protected $mcrouterAware;
        /** @var string Physical region for mcrouter use */
        protected $region;
        /** @var string Cache cluster name for mcrouter use */
        protected $cluster;
-       /** @var LoggerInterface */
-       protected $logger;
-       /** @var StatsdDataFactoryInterface */
-       protected $stats;
        /** @var bool Whether to use "interim" caching while keys are tombstoned */
        protected $useInterimHoldOffCaching = true;
-       /** @var callable|null Function that takes a WAN cache callback and runs it later */
-       protected $asyncHandler;
        /** @var float Unix timestamp of the oldest possible valid values */
        protected $epoch;
        /** @var string Stable secret used for hasing long strings into key components */
@@ -147,93 +148,104 @@ class WANObjectCache implements IExpiringStore, IStoreKeyEncoder, LoggerAwareInt
        /** @var float|null */
        private $wallClockOverride;
 
-       /** Max time expected to pass between delete() and DB commit finishing */
+       /** @var int Max expected seconds to pass between delete() and DB commit finishing */
        const MAX_COMMIT_DELAY = 3;
-       /** Max replication+snapshot lag before applying TTL_LAGGED or disallowing set() */
+       /** @var int Max expected seconds of combined lag from replication and view snapshots */
        const MAX_READ_LAG = 7;
-       /** Seconds to tombstone keys on delete() */
-       const HOLDOFF_TTL = 11; // MAX_COMMIT_DELAY + MAX_READ_LAG + 1
-
-       /** Seconds to keep dependency purge keys around */
-       const CHECK_KEY_TTL = self::TTL_YEAR;
-       /** Seconds to keep interim value keys for tombstoned keys around */
-       const INTERIM_KEY_TTL = 1;
-
-       /** Seconds to keep lock keys around */
-       const LOCK_TTL = 10;
-       /** Seconds to no-op key set() calls to avoid large blob I/O stampedes */
-       const COOLOFF_TTL = 1;
-       /** Default remaining TTL at which to consider pre-emptive regeneration */
+       /** @var int Seconds to tombstone keys on delete() and treat as volatile after invalidation */
+       const HOLDOFF_TTL = self::MAX_COMMIT_DELAY + self::MAX_READ_LAG + 1;
+
+       /** @var int Idiom for getWithSetCallback() meaning "do not store the callback result" */
+       const TTL_UNCACHEABLE = -1;
+
+       /** @var int Consider regeneration if the key will expire within this many seconds */
        const LOW_TTL = 30;
-       /** Max TTL to store keys when a data sourced is lagged */
+       /** @var int Max TTL, in seconds, to store keys when a data sourced is lagged */
        const TTL_LAGGED = 30;
 
-       /** Never consider performing "popularity" refreshes until a key reaches this age */
-       const AGE_NEW = 60;
-       /** The time length of the "popularity" refresh window for hot keys */
+       /** @var int Expected time-till-refresh, in seconds, if the key is accessed once per second */
        const HOT_TTR = 900;
-       /** Hits/second for a refresh to be expected within the "popularity" window */
-       const HIT_RATE_HIGH = 1;
-       /** Seconds to ramp up to the "popularity" refresh chance after a key is no longer new */
-       const RAMPUP_TTL = 30;
+       /** @var int Minimum key age, in seconds, for expected time-till-refresh to be considered */
+       const AGE_NEW = 60;
 
-       /** Idiom for getWithSetCallback() meaning "do not store the callback result" */
-       const TTL_UNCACHEABLE = -1;
-       /** Idiom for getWithSetCallback() meaning "no regeneration mutex based on key hotness" */
+       /** @var int Idiom for getWithSetCallback() meaning "no cache stampede mutex required" */
        const TSE_NONE = -1;
-       /** Idiom for set()/getWithSetCallback() meaning "no post-expiration persistence" */
+
+       /** @var int Idiom for set()/getWithSetCallback() meaning "no post-expiration persistence" */
        const STALE_TTL_NONE = 0;
-       /** Idiom for set()/getWithSetCallback() meaning "no post-expiration grace period" */
+       /** @var int Idiom for set()/getWithSetCallback() meaning "no post-expiration grace period" */
        const GRACE_TTL_NONE = 0;
-       /** Idiom for delete()/touchCheckKey() meaning "no hold-off period for cache writes" */
-       const HOLDOFF_NONE = 0;
+       /** @var int Idiom for delete()/touchCheckKey() meaning "no hold-off period" */
+       const HOLDOFF_TTL_NONE = 0;
+       /** @var int Alias for HOLDOFF_TTL_NONE (b/c) (deprecated since 1.34) */
+       const HOLDOFF_NONE = self::HOLDOFF_TTL_NONE;
 
-       /** Idiom for getWithSetCallback() meaning "no minimum required as-of timestamp" */
+       /** @var float Idiom for getWithSetCallback() meaning "no minimum required as-of timestamp" */
        const MIN_TIMESTAMP_NONE = 0.0;
-       /** @var int One second into the UNIX timestamp epoch */
-       const EPOCH_UNIX_ONE_SECOND = 1.0;
-
-       /** Tiny negative float to use when CTL comes up >= 0 due to clock skew */
-       const TINY_NEGATIVE = -0.000001;
-       /** Tiny positive float to use when using "minTime" to assert an inequality */
-       const TINY_POSTIVE = 0.000001;
 
-       /** Milliseconds of delay after get() where set() storms are a consideration with "lockTSE" */
-       const SET_DELAY_HIGH_MS = 50;
-       /** Min millisecond set() backoff for keys in hold-off (far less than INTERIM_KEY_TTL) */
-       const RECENT_SET_LOW_MS = 50;
-       /** Max millisecond set() backoff for keys in hold-off (far less than INTERIM_KEY_TTL) */
-       const RECENT_SET_HIGH_MS = 100;
+       /** @var string Default process cache name and max key count */
+       const PC_PRIMARY = 'primary:1000';
 
-       /** @var int Seconds needed for value generation considered slow */
-       const GENERATION_SLOW_SEC = 3;
-
-       /** Parameter to get()/getMulti() to return extra information by reference */
+       /** @var int Idion for get()/getMulti() to return extra information by reference */
        const PASS_BY_REF = -1;
 
-       /** Cache format version number */
-       const VERSION = 1;
-
-       const FLD_FORMAT_VERSION = 0; // key to WAN cache version number
-       const FLD_VALUE = 1; // key to the cached value
-       const FLD_TTL = 2; // key to the original TTL
-       const FLD_TIME = 3; // key to the cache timestamp
-       const FLD_FLAGS = 4; // key to the flags bitfield (reserved number)
-       const FLD_VALUE_VERSION = 5; // key to collection cache version number
-       const FLD_GENERATION_TIME = 6; // key to how long it took to generate the value
-
-       const PURGE_TIME = 0; // key to the tombstone entry timestamp
-       const PURGE_HOLDOFF = 1; // key to the tombstone entry hold-off TTL
-
-       const VALUE_KEY_PREFIX = 'WANCache:v:';
-       const INTERIM_KEY_PREFIX = 'WANCache:i:';
-       const TIME_KEY_PREFIX = 'WANCache:t:';
-       const MUTEX_KEY_PREFIX = 'WANCache:m:';
-       const COOLOFF_KEY_PREFIX = 'WANCache:c:';
-
-       const PURGE_VAL_PREFIX = 'PURGED:';
-
-       const PC_PRIMARY = 'primary:1000'; // process cache name and max key count
+       /** @var int Seconds to keep dependency purge keys around */
+       private static $CHECK_KEY_TTL = self::TTL_YEAR;
+       /** @var int Seconds to keep interim value keys for tombstoned keys around */
+       private static $INTERIM_KEY_TTL = 1;
+
+       /** @var int Seconds to keep lock keys around */
+       private static $LOCK_TTL = 10;
+       /** @var int Seconds to no-op key set() calls to avoid large blob I/O stampedes */
+       private static $COOLOFF_TTL = 1;
+       /** @var int Seconds to ramp up the chance of regeneration due to expected time-till-refresh */
+       private static $RAMPUP_TTL = 30;
+
+       /** @var float Tiny negative float to use when CTL comes up >= 0 due to clock skew */
+       private static $TINY_NEGATIVE = -0.000001;
+       /** @var float Tiny positive float to use when using "minTime" to assert an inequality */
+       private static $TINY_POSTIVE = 0.000001;
+
+       /** @var int Milliseconds of key fetch/validate/regenerate delay prone to set() stampedes */
+       private static $SET_DELAY_HIGH_MS = 50;
+       /** @var int Min millisecond set() backoff during hold-off (far less than INTERIM_KEY_TTL) */
+       private static $RECENT_SET_LOW_MS = 50;
+       /** @var int Max millisecond set() backoff during hold-off (far less than INTERIM_KEY_TTL) */
+       private static $RECENT_SET_HIGH_MS = 100;
+
+       /** @var int Consider value generation slow if it takes more than this many seconds */
+       private static $GENERATION_SLOW_SEC = 3;
+
+       /** @var int Key to the tombstone entry timestamp */
+       private static $PURGE_TIME = 0;
+       /** @var int Key to the tombstone entry hold-off TTL */
+       private static $PURGE_HOLDOFF = 1;
+
+       /** @var int Cache format version number */
+       private static $VERSION = 1;
+
+       /** @var int Key to WAN cache version number */
+       private static $FLD_FORMAT_VERSION = 0;
+       /** @var int Key to the cached value */
+       private static $FLD_VALUE = 1;
+       /** @var int Key to the original TTL */
+       private static $FLD_TTL = 2;
+       /** @var int Key to the cache timestamp */
+       private static $FLD_TIME = 3;
+       /** @var int Key to the flags bit field (reserved number) */
+       private static /** @noinspection PhpUnusedPrivateFieldInspection */ $FLD_FLAGS = 4;
+       /** @var int Key to collection cache version number */
+       private static $FLD_VALUE_VERSION = 5;
+       /** @var int Key to how long it took to generate the value */
+       private static $FLD_GENERATION_TIME = 6;
+
+       private static $VALUE_KEY_PREFIX = 'WANCache:v:';
+       private static $INTERIM_KEY_PREFIX = 'WANCache:i:';
+       private static $TIME_KEY_PREFIX = 'WANCache:t:';
+       private static $MUTEX_KEY_PREFIX = 'WANCache:m:';
+       private static $COOLOFF_KEY_PREFIX = 'WANCache:c:';
+
+       private static $PURGE_VAL_PREFIX = 'PURGED:';
 
        /**
         * @param array $params
@@ -265,7 +277,7 @@ class WANObjectCache implements IExpiringStore, IStoreKeyEncoder, LoggerAwareInt
                $this->region = $params['region'] ?? 'main';
                $this->cluster = $params['cluster'] ?? 'wan-main';
                $this->mcrouterAware = !empty( $params['mcrouterAware'] );
-               $this->epoch = $params['epoch'] ?? self::EPOCH_UNIX_ONE_SECOND;
+               $this->epoch = $params['epoch'] ?? 0;
                $this->secret = $params['secret'] ?? (string)$this->epoch;
 
                $this->setLogger( $params['logger'] ?? new NullLogger() );
@@ -392,14 +404,14 @@ class WANObjectCache implements IExpiringStore, IStoreKeyEncoder, LoggerAwareInt
                $curTTLs = [];
                $infoByKey = [];
 
-               $vPrefixLen = strlen( self::VALUE_KEY_PREFIX );
-               $valueKeys = self::prefixCacheKeys( $keys, self::VALUE_KEY_PREFIX );
+               $vPrefixLen = strlen( self::$VALUE_KEY_PREFIX );
+               $valueKeys = self::prefixCacheKeys( $keys, self::$VALUE_KEY_PREFIX );
 
                $checkKeysForAll = [];
                $checkKeysByKey = [];
                $checkKeysFlat = [];
                foreach ( $checkKeys as $i => $checkKeyGroup ) {
-                       $prefixed = self::prefixCacheKeys( (array)$checkKeyGroup, self::TIME_KEY_PREFIX );
+                       $prefixed = self::prefixCacheKeys( (array)$checkKeyGroup, self::$TIME_KEY_PREFIX );
                        $checkKeysFlat = array_merge( $checkKeysFlat, $prefixed );
                        // Are these check keys for a specific cache key, or for all keys being fetched?
                        if ( is_int( $i ) ) {
@@ -445,11 +457,11 @@ class WANObjectCache implements IExpiringStore, IStoreKeyEncoder, LoggerAwareInt
 
                        $lastCKPurge = null; // timestamp of the highest check key
                        foreach ( $purgeValues as $purge ) {
-                               $lastCKPurge = max( $purge[self::PURGE_TIME], $lastCKPurge );
-                               $safeTimestamp = $purge[self::PURGE_TIME] + $purge[self::PURGE_HOLDOFF];
+                               $lastCKPurge = max( $purge[self::$PURGE_TIME], $lastCKPurge );
+                               $safeTimestamp = $purge[self::$PURGE_TIME] + $purge[self::$PURGE_HOLDOFF];
                                if ( $value !== false && $safeTimestamp >= $keyInfo['asOf'] ) {
                                        // How long ago this value was invalidated by *this* check key
-                                       $ago = min( $purge[self::PURGE_TIME] - $now, self::TINY_NEGATIVE );
+                                       $ago = min( $purge[self::$PURGE_TIME] - $now, self::$TINY_NEGATIVE );
                                        // How long ago this value was invalidated by *any* known check key
                                        $keyInfo['curTTL'] = min( $keyInfo['curTTL'], $ago );
                                }
@@ -489,7 +501,7 @@ class WANObjectCache implements IExpiringStore, IStoreKeyEncoder, LoggerAwareInt
                        if ( $purge === false ) {
                                // Key is not set or malformed; regenerate
                                $newVal = $this->makePurgeValue( $now, self::HOLDOFF_TTL );
-                               $this->cache->add( $timeKey, $newVal, self::CHECK_KEY_TTL );
+                               $this->cache->add( $timeKey, $newVal, self::$CHECK_KEY_TTL );
                                $purge = $this->parsePurgeValue( $newVal );
                        }
                        $purgeValues[] = $purge;
@@ -649,10 +661,10 @@ class WANObjectCache implements IExpiringStore, IStoreKeyEncoder, LoggerAwareInt
                $storeTTL = $ttl + $staleTTL;
 
                if ( $creating ) {
-                       $ok = $this->cache->add( self::VALUE_KEY_PREFIX . $key, $wrapped, $storeTTL );
+                       $ok = $this->cache->add( self::$VALUE_KEY_PREFIX . $key, $wrapped, $storeTTL );
                } else {
                        $ok = $this->cache->merge(
-                               self::VALUE_KEY_PREFIX . $key,
+                               self::$VALUE_KEY_PREFIX . $key,
                                function ( $cache, $key, $cWrapped ) use ( $wrapped ) {
                                        // A string value means that it is a tombstone; do nothing in that case
                                        return ( is_string( $cWrapped ) ) ? false : $wrapped;
@@ -716,7 +728,7 @@ class WANObjectCache implements IExpiringStore, IStoreKeyEncoder, LoggerAwareInt
         *
         * The $ttl parameter can be used when purging values that have not actually changed
         * recently. For example, a cleanup script to purge cache entries does not really need
-        * a hold-off period, so it can use HOLDOFF_NONE. Likewise for user-requested purge.
+        * a hold-off period, so it can use HOLDOFF_TTL_NONE. Likewise for user-requested purge.
         * Note that $ttl limits the effective range of 'lockTSE' for getWithSetCallback().
         *
         * If called twice on the same key, then the last hold-off TTL takes precedence. For
@@ -729,10 +741,10 @@ class WANObjectCache implements IExpiringStore, IStoreKeyEncoder, LoggerAwareInt
        final public function delete( $key, $ttl = self::HOLDOFF_TTL ) {
                if ( $ttl <= 0 ) {
                        // Publish the purge to all datacenters
-                       $ok = $this->relayDelete( self::VALUE_KEY_PREFIX . $key );
+                       $ok = $this->relayDelete( self::$VALUE_KEY_PREFIX . $key );
                } else {
                        // Publish the purge to all datacenters
-                       $ok = $this->relayPurge( self::VALUE_KEY_PREFIX . $key, $ttl, self::HOLDOFF_NONE );
+                       $ok = $this->relayPurge( self::$VALUE_KEY_PREFIX . $key, $ttl, self::HOLDOFF_TTL_NONE );
                }
 
                $kClass = $this->determineKeyClassForStats( $key );
@@ -828,7 +840,7 @@ class WANObjectCache implements IExpiringStore, IStoreKeyEncoder, LoggerAwareInt
        final public function getMultiCheckKeyTime( array $keys ) {
                $rawKeys = [];
                foreach ( $keys as $key ) {
-                       $rawKeys[$key] = self::TIME_KEY_PREFIX . $key;
+                       $rawKeys[$key] = self::$TIME_KEY_PREFIX . $key;
                }
 
                $rawValues = $this->cache->getMulti( $rawKeys );
@@ -838,14 +850,14 @@ class WANObjectCache implements IExpiringStore, IStoreKeyEncoder, LoggerAwareInt
                foreach ( $rawKeys as $key => $rawKey ) {
                        $purge = $this->parsePurgeValue( $rawValues[$rawKey] );
                        if ( $purge !== false ) {
-                               $time = $purge[self::PURGE_TIME];
+                               $time = $purge[self::$PURGE_TIME];
                        } else {
                                // Casting assures identical floats for the next getCheckKeyTime() calls
                                $now = (string)$this->getCurrentTime();
                                $this->cache->add(
                                        $rawKey,
                                        $this->makePurgeValue( $now, self::HOLDOFF_TTL ),
-                                       self::CHECK_KEY_TTL
+                                       self::$CHECK_KEY_TTL
                                );
                                $time = (float)$now;
                        }
@@ -887,12 +899,12 @@ class WANObjectCache implements IExpiringStore, IStoreKeyEncoder, LoggerAwareInt
         * @see WANObjectCache::resetCheckKey()
         *
         * @param string $key Cache key
-        * @param int $holdoff HOLDOFF_TTL or HOLDOFF_NONE constant
+        * @param int $holdoff HOLDOFF_TTL or HOLDOFF_TTL_NONE constant
         * @return bool True if the item was purged or not found, false on failure
         */
        final public function touchCheckKey( $key, $holdoff = self::HOLDOFF_TTL ) {
                // Publish the purge to all datacenters
-               $ok = $this->relayPurge( self::TIME_KEY_PREFIX . $key, self::CHECK_KEY_TTL, $holdoff );
+               $ok = $this->relayPurge( self::$TIME_KEY_PREFIX . $key, self::$CHECK_KEY_TTL, $holdoff );
 
                $kClass = $this->determineKeyClassForStats( $key );
                $this->stats->increment( "wanobjectcache.$kClass.ck_touch." . ( $ok ? 'ok' : 'error' ) );
@@ -929,7 +941,7 @@ class WANObjectCache implements IExpiringStore, IStoreKeyEncoder, LoggerAwareInt
         */
        final public function resetCheckKey( $key ) {
                // Publish the purge to all datacenters
-               $ok = $this->relayDelete( self::TIME_KEY_PREFIX . $key );
+               $ok = $this->relayDelete( self::$TIME_KEY_PREFIX . $key );
 
                $kClass = $this->determineKeyClassForStats( $key );
                $this->stats->increment( "wanobjectcache.$kClass.ck_reset." . ( $ok ? 'ok' : 'error' ) );
@@ -1374,30 +1386,25 @@ class WANObjectCache implements IExpiringStore, IStoreKeyEncoder, LoggerAwareInt
                        // This avoids stampedes on eviction or preemptive regeneration taking too long.
                        ( $busyValue !== null && $possValue === false );
 
-               // If a regeneration lock is required, threads that do not get the lock will use any
-               // available stale or volatile value. If there is none, then the cheap/placeholder
-               // value from $busyValue will be used if provided; failing that, all threads will try
-               // to regenerate the value and ignore the lock.
-               if ( $useRegenerationLock ) {
-                       $hasLock = $this->cache->add( self::MUTEX_KEY_PREFIX . $key, 1, self::LOCK_TTL );
-                       if ( !$hasLock ) {
-                               if ( $this->isValid( $possValue, $possInfo['asOf'], $minAsOf ) ) {
-                                       $this->stats->increment( "wanobjectcache.$kClass.hit.stale" );
-
-                                       return [ $possValue, $possInfo['version'], $curInfo['asOf'] ];
-                               } elseif ( $busyValue !== null ) {
-                                       $miss = is_infinite( $minAsOf ) ? 'renew' : 'miss';
-                                       $this->stats->increment( "wanobjectcache.$kClass.$miss.busy" );
-
-                                       return [
-                                               is_callable( $busyValue ) ? $busyValue() : $busyValue,
-                                               $version,
-                                               $curInfo['asOf']
-                                       ];
-                               }
+               // If a regeneration lock is required, threads that do not get the lock will try to use
+               // the stale value, the interim value, or the $busyValue placeholder, in that order. If
+               // none of those are set then all threads will bypass the lock and regenerate the value.
+               $hasLock = $useRegenerationLock && $this->claimStampedeLock( $key );
+               if ( $useRegenerationLock && !$hasLock ) {
+                       if ( $this->isValid( $possValue, $possInfo['asOf'], $minAsOf ) ) {
+                               $this->stats->increment( "wanobjectcache.$kClass.hit.stale" );
+
+                               return [ $possValue, $possInfo['version'], $curInfo['asOf'] ];
+                       } elseif ( $busyValue !== null ) {
+                               $miss = is_infinite( $minAsOf ) ? 'renew' : 'miss';
+                               $this->stats->increment( "wanobjectcache.$kClass.$miss.busy" );
+
+                               return [
+                                       is_callable( $busyValue ) ? $busyValue() : $busyValue,
+                                       $version,
+                                       $curInfo['asOf']
+                               ];
                        }
-               } else {
-                       $hasLock = false;
                }
 
                // Generate the new value given any prior value with a matching version
@@ -1447,9 +1454,7 @@ class WANObjectCache implements IExpiringStore, IStoreKeyEncoder, LoggerAwareInt
                        }
                }
 
-               if ( $hasLock ) {
-                       $this->cache->changeTTL( self::MUTEX_KEY_PREFIX . $key, (int)$initialTime - 60 );
-               }
+               $this->yieldStampedeLock( $key, $hasLock );
 
                $miss = is_infinite( $minAsOf ) ? 'renew' : 'miss';
                $this->stats->increment( "wanobjectcache.$kClass.$miss.compute" );
@@ -1457,12 +1462,34 @@ class WANObjectCache implements IExpiringStore, IStoreKeyEncoder, LoggerAwareInt
                return [ $value, $version, $curInfo['asOf'] ];
        }
 
+       /**
+        * @param string $key
+        * @return bool Success
+        */
+       private function claimStampedeLock( $key ) {
+               // Note that locking is not bypassed due to I/O errors; this avoids stampedes
+               return $this->cache->add( self::$MUTEX_KEY_PREFIX . $key, 1, self::$LOCK_TTL );
+       }
+
+       /**
+        * @param string $key
+        * @param bool $hasLock
+        */
+       private function yieldStampedeLock( $key, $hasLock ) {
+               if ( $hasLock ) {
+                       // The backend might be a mcrouter proxy set to broadcast DELETE to *all* the local
+                       // datacenter cache servers via OperationSelectorRoute (for increased consistency).
+                       // Since that would be excessive for these locks, use TOUCH to expire the key.
+                       $this->cache->changeTTL( self::$MUTEX_KEY_PREFIX . $key, $this->getCurrentTime() - 60 );
+               }
+       }
+
        /**
         * @param float $age Age of volatile/interim key in seconds
         * @return bool Whether the age of a volatile value is negligible
         */
        private function isVolatileValueAgeNegligible( $age ) {
-               return ( $age < mt_rand( self::RECENT_SET_LOW_MS, self::RECENT_SET_HIGH_MS ) / 1e3 );
+               return ( $age < mt_rand( self::$RECENT_SET_LOW_MS, self::$RECENT_SET_HIGH_MS ) / 1e3 );
        }
 
        /**
@@ -1484,13 +1511,13 @@ class WANObjectCache implements IExpiringStore, IStoreKeyEncoder, LoggerAwareInt
                // consistent hashing).
                if ( $lockTSE < 0 || $hasLock ) {
                        return true; // either not a priori hot or thread has the lock
-               } elseif ( $elapsed <= self::SET_DELAY_HIGH_MS * 1e3 ) {
+               } elseif ( $elapsed <= self::$SET_DELAY_HIGH_MS * 1e3 ) {
                        return true; // not enough time for threads to pile up
                }
 
                $this->cache->clearLastError();
                if (
-                       !$this->cache->add( self::COOLOFF_KEY_PREFIX . $key, 1, self::COOLOFF_TTL ) &&
+                       !$this->cache->add( self::$COOLOFF_KEY_PREFIX . $key, 1, self::$COOLOFF_TTL ) &&
                        // Don't treat failures due to I/O errors as the key being in cooloff
                        $this->cache->getLastError() === BagOStuff::ERR_NONE
                ) {
@@ -1517,7 +1544,7 @@ class WANObjectCache implements IExpiringStore, IStoreKeyEncoder, LoggerAwareInt
 
                $touched = $touchedCallback( $value );
                if ( $touched !== null && $touched >= $curInfo['asOf'] ) {
-                       $curTTL = min( $curTTL, self::TINY_NEGATIVE, $curInfo['asOf'] - $touched );
+                       $curTTL = min( $curTTL, self::$TINY_NEGATIVE, $curInfo['asOf'] - $touched );
                }
 
                return [ $curTTL, max( $curInfo['tombAsOf'], $curInfo['lastCKPurge'], $touched ) ];
@@ -1545,7 +1572,7 @@ class WANObjectCache implements IExpiringStore, IStoreKeyEncoder, LoggerAwareInt
                $now = $this->getCurrentTime();
 
                if ( $this->useInterimHoldOffCaching ) {
-                       $wrapped = $this->cache->get( self::INTERIM_KEY_PREFIX . $key );
+                       $wrapped = $this->cache->get( self::$INTERIM_KEY_PREFIX . $key );
 
                        list( $value, $keyInfo ) = $this->unwrap( $wrapped, $now );
                        if ( $this->isValid( $value, $keyInfo['asOf'], $minAsOf ) ) {
@@ -1564,11 +1591,11 @@ class WANObjectCache implements IExpiringStore, IStoreKeyEncoder, LoggerAwareInt
         * @param float $walltime How long it took to generate the value in seconds
         */
        private function setInterimValue( $key, $value, $ttl, $version, $walltime ) {
-               $ttl = max( self::INTERIM_KEY_TTL, (int)$ttl );
+               $ttl = max( self::$INTERIM_KEY_TTL, (int)$ttl );
 
                $wrapped = $this->wrap( $value, $ttl, $version, $this->getCurrentTime(), $walltime );
                $this->cache->merge(
-                       self::INTERIM_KEY_PREFIX . $key,
+                       self::$INTERIM_KEY_PREFIX . $key,
                        function () use ( $wrapped ) {
                                return $wrapped;
                        },
@@ -1810,12 +1837,12 @@ class WANObjectCache implements IExpiringStore, IStoreKeyEncoder, LoggerAwareInt
         */
        final public function reap( $key, $purgeTimestamp, &$isStale = false ) {
                $minAsOf = $purgeTimestamp + self::HOLDOFF_TTL;
-               $wrapped = $this->cache->get( self::VALUE_KEY_PREFIX . $key );
-               if ( is_array( $wrapped ) && $wrapped[self::FLD_TIME] < $minAsOf ) {
+               $wrapped = $this->cache->get( self::$VALUE_KEY_PREFIX . $key );
+               if ( is_array( $wrapped ) && $wrapped[self::$FLD_TIME] < $minAsOf ) {
                        $isStale = true;
                        $this->logger->warning( "Reaping stale value key '$key'." );
                        $ttlReap = self::HOLDOFF_TTL; // avoids races with tombstone creation
-                       $ok = $this->cache->changeTTL( self::VALUE_KEY_PREFIX . $key, $ttlReap );
+                       $ok = $this->cache->changeTTL( self::$VALUE_KEY_PREFIX . $key, $ttlReap );
                        if ( !$ok ) {
                                $this->logger->error( "Could not complete reap of key '$key'." );
                        }
@@ -1838,11 +1865,11 @@ class WANObjectCache implements IExpiringStore, IStoreKeyEncoder, LoggerAwareInt
         * @since 1.28
         */
        final public function reapCheckKey( $key, $purgeTimestamp, &$isStale = false ) {
-               $purge = $this->parsePurgeValue( $this->cache->get( self::TIME_KEY_PREFIX . $key ) );
-               if ( $purge && $purge[self::PURGE_TIME] < $purgeTimestamp ) {
+               $purge = $this->parsePurgeValue( $this->cache->get( self::$TIME_KEY_PREFIX . $key ) );
+               if ( $purge && $purge[self::$PURGE_TIME] < $purgeTimestamp ) {
                        $isStale = true;
                        $this->logger->warning( "Reaping stale check key '$key'." );
-                       $ok = $this->cache->changeTTL( self::TIME_KEY_PREFIX . $key, self::TTL_SECOND );
+                       $ok = $this->cache->changeTTL( self::$TIME_KEY_PREFIX . $key, self::TTL_SECOND );
                        if ( !$ok ) {
                                $this->logger->error( "Could not complete reap of check key '$key'." );
                        }
@@ -2175,14 +2202,14 @@ class WANObjectCache implements IExpiringStore, IStoreKeyEncoder, LoggerAwareInt
                        // Wildcards select all matching routes, e.g. the WAN cluster on all DCs
                        $ok = $this->cache->set(
                                "/*/{$this->cluster}/{$key}",
-                               $this->makePurgeValue( $this->getCurrentTime(), self::HOLDOFF_NONE ),
+                               $this->makePurgeValue( $this->getCurrentTime(), self::HOLDOFF_TTL_NONE ),
                                $ttl
                        );
                } else {
                        // This handles the mcrouter and the single-DC case
                        $ok = $this->cache->set(
                                $key,
-                               $this->makePurgeValue( $this->getCurrentTime(), self::HOLDOFF_NONE ),
+                               $this->makePurgeValue( $this->getCurrentTime(), self::HOLDOFF_TTL_NONE ),
                                $ttl
                        );
                }
@@ -2314,17 +2341,18 @@ class WANObjectCache implements IExpiringStore, IStoreKeyEncoder, LoggerAwareInt
                        return false;
                }
 
+               $popularHitsPerSec = 1;
                // Lifecycle is: new, ramp-up refresh chance, full refresh chance.
-               // Note that the "expected # of refreshes" for the ramp-up time range is half of what it
-               // would be if P(refresh) was at its full value during that time range.
-               $refreshWindowSec = max( $timeTillRefresh - $ageNew - self::RAMPUP_TTL / 2, 1 );
+               // Note that the "expected # of refreshes" for the ramp-up time range is half
+               // of what it would be if P(refresh) was at its full value during that time range.
+               $refreshWindowSec = max( $timeTillRefresh - $ageNew - self::$RAMPUP_TTL / 2, 1 );
                // P(refresh) * (# hits in $refreshWindowSec) = (expected # of refreshes)
-               // P(refresh) * ($refreshWindowSec * $popularHitsPerSec) = 1
+               // P(refresh) * ($refreshWindowSec * $popularHitsPerSec) = 1 (by definition)
                // P(refresh) = 1/($refreshWindowSec * $popularHitsPerSec)
-               $chance = 1 / ( self::HIT_RATE_HIGH * $refreshWindowSec );
+               $chance = 1 / ( $popularHitsPerSec * $refreshWindowSec );
 
                // Ramp up $chance from 0 to its nominal value over RAMPUP_TTL seconds to avoid stampedes
-               $chance *= ( $timeOld <= self::RAMPUP_TTL ) ? $timeOld / self::RAMPUP_TTL : 1;
+               $chance *= ( $timeOld <= self::$RAMPUP_TTL ) ? $timeOld / self::$RAMPUP_TTL : 1;
 
                return mt_rand( 1, 1e9 ) <= 1e9 * $chance;
        }
@@ -2340,7 +2368,7 @@ class WANObjectCache implements IExpiringStore, IStoreKeyEncoder, LoggerAwareInt
         */
        protected function isValid( $value, $asOf, $minAsOf, $purgeTime = null ) {
                // Avoid reading any key not generated after the latest delete() or touch
-               $safeMinAsOf = max( $minAsOf, $purgeTime + self::TINY_POSTIVE );
+               $safeMinAsOf = max( $minAsOf, $purgeTime + self::$TINY_POSTIVE );
 
                if ( $value === false ) {
                        return false;
@@ -2363,16 +2391,16 @@ class WANObjectCache implements IExpiringStore, IStoreKeyEncoder, LoggerAwareInt
                // Returns keys in ascending integer order for PHP7 array packing:
                // https://nikic.github.io/2014/12/22/PHPs-new-hashtable-implementation.html
                $wrapped = [
-                       self::FLD_FORMAT_VERSION => self::VERSION,
-                       self::FLD_VALUE => $value,
-                       self::FLD_TTL => $ttl,
-                       self::FLD_TIME => $now
+                       self::$FLD_FORMAT_VERSION => self::$VERSION,
+                       self::$FLD_VALUE => $value,
+                       self::$FLD_TTL => $ttl,
+                       self::$FLD_TIME => $now
                ];
                if ( $version !== null ) {
-                       $wrapped[self::FLD_VALUE_VERSION] = $version;
+                       $wrapped[self::$FLD_VALUE_VERSION] = $version;
                }
-               if ( $walltime >= self::GENERATION_SLOW_SEC ) {
-                       $wrapped[self::FLD_GENERATION_TIME] = $walltime;
+               if ( $walltime >= self::$GENERATION_SLOW_SEC ) {
+                       $wrapped[self::$FLD_GENERATION_TIME] = $walltime;
                }
 
                return $wrapped;
@@ -2395,20 +2423,20 @@ class WANObjectCache implements IExpiringStore, IStoreKeyEncoder, LoggerAwareInt
                if ( is_array( $wrapped ) ) {
                        // Entry expected to be a cached value; validate it
                        if (
-                               ( $wrapped[self::FLD_FORMAT_VERSION] ?? null ) === self::VERSION &&
-                               $wrapped[self::FLD_TIME] >= $this->epoch
+                               ( $wrapped[self::$FLD_FORMAT_VERSION] ?? null ) === self::$VERSION &&
+                               $wrapped[self::$FLD_TIME] >= $this->epoch
                        ) {
-                               if ( $wrapped[self::FLD_TTL] > 0 ) {
+                               if ( $wrapped[self::$FLD_TTL] > 0 ) {
                                        // Get the approximate time left on the key
-                                       $age = $now - $wrapped[self::FLD_TIME];
-                                       $curTTL = max( $wrapped[self::FLD_TTL] - $age, 0.0 );
+                                       $age = $now - $wrapped[self::$FLD_TIME];
+                                       $curTTL = max( $wrapped[self::$FLD_TTL] - $age, 0.0 );
                                } else {
                                        // Key had no TTL, so the time left is unbounded
                                        $curTTL = INF;
                                }
-                               $value = $wrapped[self::FLD_VALUE];
-                               $info['version'] = $wrapped[self::FLD_VALUE_VERSION] ?? null;
-                               $info['asOf'] = $wrapped[self::FLD_TIME];
+                               $value = $wrapped[self::$FLD_VALUE];
+                               $info['version'] = $wrapped[self::$FLD_VALUE_VERSION] ?? null;
+                               $info['asOf'] = $wrapped[self::$FLD_TIME];
                                $info['curTTL'] = $curTTL;
                        }
                } else {
@@ -2416,8 +2444,8 @@ class WANObjectCache implements IExpiringStore, IStoreKeyEncoder, LoggerAwareInt
                        $purge = $this->parsePurgeValue( $wrapped );
                        if ( $purge !== false ) {
                                // Tombstoned keys should always have a negative current $ttl
-                               $info['curTTL'] = min( $purge[self::PURGE_TIME] - $now, self::TINY_NEGATIVE );
-                               $info['tombAsOf'] = $purge[self::PURGE_TIME];
+                               $info['curTTL'] = min( $purge[self::$PURGE_TIME] - $now, self::$TINY_NEGATIVE );
+                               $info['tombAsOf'] = $purge[self::$PURGE_TIME];
                        }
                }
 
@@ -2459,8 +2487,10 @@ class WANObjectCache implements IExpiringStore, IStoreKeyEncoder, LoggerAwareInt
                }
 
                $segments = explode( ':', $value, 3 );
-               if ( !isset( $segments[0] ) || !isset( $segments[1] )
-                       || "{$segments[0]}:" !== self::PURGE_VAL_PREFIX
+               if (
+                       !isset( $segments[0] ) ||
+                       !isset( $segments[1] ) ||
+                       "{$segments[0]}:" !== self::$PURGE_VAL_PREFIX
                ) {
                        return false;
                }
@@ -2476,8 +2506,8 @@ class WANObjectCache implements IExpiringStore, IStoreKeyEncoder, LoggerAwareInt
                }
 
                return [
-                       self::PURGE_TIME => (float)$segments[1],
-                       self::PURGE_HOLDOFF => (int)$segments[2],
+                       self::$PURGE_TIME => (float)$segments[1],
+                       self::$PURGE_HOLDOFF => (int)$segments[2],
                ];
        }
 
@@ -2487,7 +2517,7 @@ class WANObjectCache implements IExpiringStore, IStoreKeyEncoder, LoggerAwareInt
         * @return string Wrapped purge value
         */
        private function makePurgeValue( $timestamp, $holdoff ) {
-               return self::PURGE_VAL_PREFIX . (float)$timestamp . ':' . (int)$holdoff;
+               return self::$PURGE_VAL_PREFIX . (float)$timestamp . ':' . (int)$holdoff;
        }
 
        /**
@@ -2547,18 +2577,18 @@ class WANObjectCache implements IExpiringStore, IStoreKeyEncoder, LoggerAwareInt
                $keysWarmUp = [];
                // Get all the value keys to fetch...
                foreach ( $keys as $key ) {
-                       $keysWarmUp[] = self::VALUE_KEY_PREFIX . $key;
+                       $keysWarmUp[] = self::$VALUE_KEY_PREFIX . $key;
                }
                // Get all the check keys to fetch...
                foreach ( $checkKeys as $i => $checkKeyOrKeys ) {
                        if ( is_int( $i ) ) {
                                // Single check key that applies to all value keys
-                               $keysWarmUp[] = self::TIME_KEY_PREFIX . $checkKeyOrKeys;
+                               $keysWarmUp[] = self::$TIME_KEY_PREFIX . $checkKeyOrKeys;
                        } else {
                                // List of check keys that apply to value key $i
                                $keysWarmUp = array_merge(
                                        $keysWarmUp,
-                                       self::prefixCacheKeys( $checkKeyOrKeys, self::TIME_KEY_PREFIX )
+                                       self::prefixCacheKeys( $checkKeyOrKeys, self::$TIME_KEY_PREFIX )
                                );
                        }
                }
index 23da0bb..0e4e3fb 100644 (file)
@@ -27,7 +27,7 @@
  *
  * @ingroup Cache
  */
-class WinCacheBagOStuff extends BagOStuff {
+class WinCacheBagOStuff extends MediumSpecificBagOStuff {
        protected function doGet( $key, $flags = 0, &$casToken = null ) {
                $casToken = null;
 
index 7e5a8a4..9226875 100644 (file)
@@ -36,7 +36,7 @@ use Wikimedia\WaitConditionLoop;
  *
  * @ingroup Cache
  */
-class SqlBagOStuff extends BagOStuff {
+class SqlBagOStuff extends MediumSpecificBagOStuff {
        /** @var array[] (server index => server config) */
        protected $serverInfos;
        /** @var string[] (server index => tag/host name) */
@@ -55,8 +55,6 @@ class SqlBagOStuff extends BagOStuff {
        protected $tableName = 'objectcache';
        /** @var bool */
        protected $replicaOnly = false;
-       /** @var int */
-       protected $syncTimeout = 3;
 
        /** @var LoadBalancer|null */
        protected $separateMainLB;
@@ -159,9 +157,6 @@ class SqlBagOStuff extends BagOStuff {
                if ( isset( $params['shards'] ) ) {
                        $this->shards = intval( $params['shards'] );
                }
-               if ( isset( $params['syncTimeout'] ) ) {
-                       $this->syncTimeout = $params['syncTimeout'];
-               }
                // Backwards-compatibility for < 1.34
                $this->replicaOnly = $params['replicaOnly'] ?? ( $params['slaveOnly'] ?? false );
        }
index 457648a..fca06c9 100644 (file)
@@ -177,7 +177,7 @@ class MessageBlobStore implements LoggerAwareInterface {
                // - This global check key invalidates message blobs for all modules for all wikis
                //   in cache contexts (e.g. languages, skins). Setting a hold-off on this key could
                //   cause a cache stampede since no values would be stored for several seconds.
-               $cache->touchCheckKey( $cache->makeGlobalKey( __CLASS__ ), $cache::HOLDOFF_NONE );
+               $cache->touchCheckKey( $cache->makeGlobalKey( __CLASS__ ), $cache::HOLDOFF_TTL_NONE );
        }
 
        /**
index fbc59fe..af30313 100644 (file)
@@ -1159,11 +1159,12 @@ class ResourceLoaderFileModule extends ResourceLoaderModule {
                                                throw new MWException( __METHOD__ . ": invalid versionCallback for file" .
                                                        " \"{$fileInfo['name']}\" in module \"{$this->getName()}\"" );
                                        }
-                                       $expanded['definitionSummary'] = ( $fileInfo['versionCallback'] )( $context );
+                                       $expanded['definitionSummary'] =
+                                               ( $fileInfo['versionCallback'] )( $context, $this->getConfig() );
                                        // Don't invoke 'callback' here as it may be expensive (T223260).
                                        $expanded['callback'] = $fileInfo['callback'];
                                } else {
-                                       $expanded['content'] = ( $fileInfo['callback'] )( $context );
+                                       $expanded['content'] = ( $fileInfo['callback'] )( $context, $this->getConfig() );
                                }
                        } elseif ( isset( $fileInfo['config'] ) ) {
                                if ( $type !== 'data' ) {
@@ -1240,7 +1241,7 @@ class ResourceLoaderFileModule extends ResourceLoaderModule {
                                $fileInfo['content'] = $content;
                                unset( $fileInfo['filePath'] );
                        } elseif ( isset( $fileInfo['callback'] ) ) {
-                               $fileInfo['content'] = ( $fileInfo['callback'] )( $context );
+                               $fileInfo['content'] = ( $fileInfo['callback'] )( $context, $this->getConfig() );
                                unset( $fileInfo['callback'] );
                        }
 
index 101570f..65cd2d2 100644 (file)
@@ -429,7 +429,7 @@ abstract class AuthManagerSpecialPage extends SpecialPage {
                                // accidentally returning it so best check and fix
                                $status = Status::wrap( $status );
                        } elseif ( is_string( $status ) ) {
-                               $status = Status::newFatal( new RawMessage( '$1', $status ) );
+                               $status = Status::newFatal( new RawMessage( '$1', [ $status ] ) );
                        } elseif ( is_array( $status ) ) {
                                if ( is_string( reset( $status ) ) ) {
                                        $status = Status::newFatal( ...$status );
index 4f34785..f3ae31a 100644 (file)
@@ -28,6 +28,8 @@ use MediaWiki\Preferences\MultiUsernameFilter;
  */
 class SpecialMute extends FormSpecialPage {
 
+       const PAGE_NAME = 'Mute';
+
        /** @var User */
        private $target;
 
@@ -51,7 +53,7 @@ class SpecialMute extends FormSpecialPage {
 
                $this->centralIdLookup = CentralIdLookup::factory();
 
-               parent::__construct( 'Mute', '', false );
+               parent::__construct( self::PAGE_NAME, '', false );
        }
 
        /**
@@ -66,7 +68,7 @@ class SpecialMute extends FormSpecialPage {
                parent::execute( $par );
 
                $out = $this->getOutput();
-               $out->addModules( 'mediawiki.special.pageLanguage' );
+               $out->addModules( 'mediawiki.misc-authed-ooui' );
        }
 
        /**
@@ -97,10 +99,12 @@ class SpecialMute extends FormSpecialPage {
         * @return bool
         */
        public function onSubmit( array $data, HTMLForm $form = null ) {
-               if ( !empty( $data['MuteEmail'] ) ) {
-                       $this->muteEmailsFromTarget();
-               } else {
-                       $this->unmuteEmailsFromTarget();
+               foreach ( $data as $userOption => $value ) {
+                       if ( $value ) {
+                               $this->muteTarget( $userOption );
+                       } else {
+                               $this->unmuteTarget( $userOption );
+                       }
                }
 
                return true;
@@ -114,10 +118,12 @@ class SpecialMute extends FormSpecialPage {
        }
 
        /**
-        * Un-mute emails from target
+        * Un-mute target
+        *
+        * @param string $userOption up_property key that holds the blacklist
         */
-       private function unmuteEmailsFromTarget() {
-               $blacklist = $this->getBlacklist();
+       private function unmuteTarget( $userOption ) {
+               $blacklist = $this->getBlacklist( $userOption );
 
                $key = array_search( $this->targetCentralId, $blacklist );
                if ( $key !== false ) {
@@ -125,24 +131,25 @@ class SpecialMute extends FormSpecialPage {
                        $blacklist = implode( "\n", $blacklist );
 
                        $user = $this->getUser();
-                       $user->setOption( 'email-blacklist', $blacklist );
+                       $user->setOption( $userOption, $blacklist );
                        $user->saveSettings();
                }
        }
 
        /**
-        * Mute emails from target
+        * Mute target
+        * @param string $userOption up_property key that holds the blacklist
         */
-       private function muteEmailsFromTarget() {
+       private function muteTarget( $userOption ) {
                // avoid duplicates just in case
-               if ( !$this->isTargetBlacklisted() ) {
-                       $blacklist = $this->getBlacklist();
+               if ( !$this->isTargetBlacklisted( $userOption ) ) {
+                       $blacklist = $this->getBlacklist( $userOption );
 
                        $blacklist[] = $this->targetCentralId;
                        $blacklist = implode( "\n", $blacklist );
 
                        $user = $this->getUser();
-                       $user->setOption( 'email-blacklist', $blacklist );
+                       $user->setOption( $userOption, $blacklist );
                        $user->saveSettings();
                }
        }
@@ -150,30 +157,38 @@ class SpecialMute extends FormSpecialPage {
        /**
         * @inheritDoc
         */
-       protected function alterForm( HTMLForm $form ) {
+       protected function getForm() {
+               $form = parent::getForm();
                $form->setId( 'mw-specialmute-form' );
                $form->setHeaderText( $this->msg( 'specialmute-header', $this->target )->parse() );
                $form->setSubmitTextMsg( 'specialmute-submit' );
                $form->setSubmitID( 'save' );
+
+               return $form;
        }
 
        /**
         * @inheritDoc
         */
        protected function getFormFields() {
-               if ( !$this->enableUserEmailBlacklist || !$this->enableUserEmail ) {
-                       throw new ErrorPageError( 'specialmute', 'specialmute-error-email-blacklist-disabled' );
+               $fields = [];
+               if (
+                       $this->enableUserEmailBlacklist &&
+                       $this->enableUserEmail &&
+                       $this->getUser()->getEmailAuthenticationTimestamp()
+               ) {
+                       $fields['email-blacklist'] = [
+                               'type' => 'check',
+                               'label-message' => 'specialmute-label-mute-email',
+                               'default' => $this->isTargetBlacklisted( 'email-blacklist' ),
+                       ];
                }
 
-               if ( !$this->getUser()->getEmailAuthenticationTimestamp() ) {
-                       throw new ErrorPageError( 'specialmute', 'specialmute-error-email-preferences' );
-               }
+               Hooks::run( 'SpecialMuteModifyFormFields', [ $this, &$fields ] );
 
-               $fields['MuteEmail'] = [
-                       'type' => 'check',
-                       'label-message' => 'specialmute-label-mute-email',
-                       'default' => $this->isTargetBlacklisted(),
-               ];
+               if ( count( $fields ) == 0 ) {
+                       throw new ErrorPageError( 'specialmute', 'specialmute-error-no-options' );
+               }
 
                return $fields;
        }
@@ -192,18 +207,20 @@ class SpecialMute extends FormSpecialPage {
        }
 
        /**
+        * @param string $userOption
         * @return bool
         */
-       private function isTargetBlacklisted() {
-               $blacklist = $this->getBlacklist();
-               return in_array( $this->targetCentralId, $blacklist );
+       public function isTargetBlacklisted( $userOption ) {
+               $blacklist = $this->getBlacklist( $userOption );
+               return in_array( $this->targetCentralId, $blacklist, true );
        }
 
        /**
+        * @param string $userOption
         * @return array
         */
-       private function getBlacklist() {
-               $blacklist = $this->getUser()->getOption( 'email-blacklist' );
+       private function getBlacklist( $userOption ) {
+               $blacklist = $this->getUser()->getOption( $userOption );
                if ( !$blacklist ) {
                        return [];
                }
index 41c42ce..5b15e82 100644 (file)
@@ -379,13 +379,6 @@ abstract class UploadBase {
                        return $result;
                }
 
-               $error = '';
-               if ( !Hooks::run( 'UploadVerification',
-                       [ $this->mDestName, $this->mTempPath, &$error ], '1.28' )
-               ) {
-                       return [ 'status' => self::HOOK_ABORTED, 'error' => $error ];
-               }
-
                return [ 'status' => self::OK ];
        }
 
@@ -1129,6 +1122,8 @@ abstract class UploadBase {
         * @throws UploadStashNotLoggedInException
         */
        public function stashFile( User $user = null ) {
+               wfDeprecated( __METHOD__, '1.28' );
+
                return $this->doStashFile( $user );
        }
 
@@ -1146,29 +1141,6 @@ abstract class UploadBase {
                return $file;
        }
 
-       /**
-        * Stash a file in a temporary directory, returning a key which can be used
-        * to find the file again. See stashFile().
-        *
-        * @deprecated since 1.28
-        * @return string File key
-        */
-       public function stashFileGetKey() {
-               wfDeprecated( __METHOD__, '1.28' );
-               return $this->doStashFile()->getFileKey();
-       }
-
-       /**
-        * alias for stashFileGetKey, for backwards compatibility
-        *
-        * @deprecated since 1.28
-        * @return string File key
-        */
-       public function stashSession() {
-               wfDeprecated( __METHOD__, '1.28' );
-               return $this->doStashFile()->getFileKey();
-       }
-
        /**
         * If we've modified the upload file we need to manually remove it
         * on exit to clean up.
index 1bd99c1..cc527e7 100644 (file)
@@ -86,30 +86,9 @@ class UploadFromChunks extends UploadFromFile {
         */
        public function stashFile( User $user = null ) {
                wfDeprecated( __METHOD__, '1.28' );
-               $this->verifyChunk();
-               return parent::stashFile( $user );
-       }
 
-       /**
-        * @inheritDoc
-        * @throws UploadChunkVerificationException
-        * @deprecated since 1.28
-        */
-       public function stashFileGetKey() {
-               wfDeprecated( __METHOD__, '1.28' );
                $this->verifyChunk();
-               return parent::stashFileGetKey();
-       }
-
-       /**
-        * @inheritDoc
-        * @throws UploadChunkVerificationException
-        * @deprecated since 1.28
-        */
-       public function stashSession() {
-               wfDeprecated( __METHOD__, '1.28' );
-               $this->verifyChunk();
-               return parent::stashSession();
+               return parent::stashFile( $user );
        }
 
        /**
index 7e40594..c6d0e90 100644 (file)
        "right-editmyusercss": "Edit your own user CSS files",
        "right-editmyuserjson": "Edit your own user JSON files",
        "right-editmyuserjs": "Edit your own user JavaScript files",
+       "right-editmyuserjsredirect": "Edit your own user JavaScript files that are redirects",
        "right-viewmywatchlist": "View your own watchlist",
        "right-editmywatchlist": "Edit your own watchlist. Note some actions will still add pages even without this right.",
        "right-viewmyprivateinfo": "View your own private data (e.g. email address, real name)",
        "action-editmyusercss": "edit your own user CSS files",
        "action-editmyuserjson": "edit your own user JSON files",
        "action-editmyuserjs": "edit your own user JavaScript files",
+       "action-editmyuserjsredirect": "edit your own user JavaScript files that are redirects",
        "action-viewsuppressed": "view revisions hidden from any user",
        "action-hideuser": "block a username, hiding it from the public",
        "action-ipblock-exempt": "bypass IP blocks, auto-blocks and range blocks",
        "specialmute-success": "Your mute preferences have been updated. See all muted users in [[Special:Preferences|your preferences]].",
        "specialmute-submit": "Confirm",
        "specialmute-label-mute-email": "Mute emails from this user",
-       "specialmute-header": "Please select your mute preferences for {{BIDI:[[User:$1]]}}.",
+       "specialmute-header": "Please select your mute preferences for user <b>{{BIDI:[[User:$1|$1]]}}</b>.",
        "specialmute-error-invalid-user": "The username requested could not be found.",
-       "specialmute-error-email-blacklist-disabled": "Muting users from sending you emails is not enabled.",
-       "specialmute-error-email-preferences": "You must confirm your email address before you can mute a user. You may do so from [[Special:Preferences]].",
-       "specialmute-email-footer": "To manage email preferences for {{BIDI:$2}} please visit <$1>.",
+       "specialmute-error-no-options": "Mute features are unavailable. This might be because: you haven't confirmed your email address or the wiki administrator has disabled email features and/or email blacklist for this wiki.",
+       "specialmute-email-footer": "To manage email preferences for user {{BIDI:$2}} please visit <$1>.",
        "specialmute-login-required": "Please log in to change your mute preferences.",
        "mute-preferences": "Mute preferences",
        "revid": "revision $1",
        "passwordpolicies-policy-passwordnotinlargeblacklist": "Password cannot be in the list of 100,000 most commonly used passwords.",
        "passwordpolicies-policyflag-forcechange": "must change on login",
        "passwordpolicies-policyflag-suggestchangeonlogin": "suggest change on login",
+       "mycustomjsredirectprotected": "You do not have permission to edit this JavaScript page because it is a redirect and it does not point inside your userspace.",
        "easydeflate-invaliddeflate": "Content provided is not properly deflated",
        "unprotected-js": "For security reasons JavaScript cannot be loaded from unprotected pages. Please only create javascript in the MediaWiki: namespace or as a User subpage",
        "userlogout-continue": "Do you want to log out?"
index a58010e..76d969d 100644 (file)
        "right-editsitejs": "{{doc-right|editsitejs}}",
        "right-editmyusercss": "{{doc-right|editmyusercss}}\nSee also:\n* {{msg-mw|Right-editusercss}}",
        "right-editmyuserjson": "{{doc-right|editmyuserjson}}\nSee also:\n* {{msg-mw|Right-edituserjson}}",
-       "right-editmyuserjs": "{{doc-right|editmyuserjs}}\nSee also:\n* {{msg-mw|Right-edituserjs}}",
+       "right-editmyuserjs": "{{doc-right|editmyuserjs}}\nSee also:\n* {{msg-mw|Right-edituserjs}}\n* {{msg-mw|Right-editmyuserjsredirect}}",
+       "right-editmyuserjsredirect": "{{doc-right|editmyuserjsredirect}}\nSame as {{msg-mw|Right-editmyuserjs}} except if page is a redirect.\n\nSee also:\n* {{msg-mw|Right-edituserjs}}",
        "right-viewmywatchlist": "{{doc-right|viewmywatchlist}}",
        "right-editmywatchlist": "{{doc-right|editmywatchlist}}",
        "right-viewmyprivateinfo": "{{doc-right|viewmyprivateinfo}}",
        "action-editmyusercss": "{{doc-action|editmyusercss}}",
        "action-editmyuserjson": "{{doc-action|editmyuserjson}}",
        "action-editmyuserjs": "{{doc-action|editmyuserjs}}",
+       "action-editmyuserjsredirect": "{{doc-action|editmyuserjsredirect}}",
        "action-viewsuppressed": "{{doc-action|viewsuppressed}}",
        "action-hideuser": "{{doc-action|hideuser}}",
        "action-ipblock-exempt": "{{doc-action|ipblock-exempt}}",
        "specialmute-label-mute-email": "Label for the checkbox that mutes/unmutes emails from the specified user.",
        "specialmute-header": "Used as header text on [[Special:Mute]]. Shown before the form with the muting options.\n* $1 - User selected for muting",
        "specialmute-error-invalid-user": "Error displayed when the username cannot be found.",
-       "specialmute-error-email-blacklist-disabled": "Error displayed when email blacklist is not enabled.",
-       "specialmute-error-email-preferences": "Error displayed when the user has not confirmed their email address.",
+       "specialmute-error-no-options": "Error displayed when there are no options available to mute on [[Special:Mute]].",
        "specialmute-email-footer": "Email footer in plain text linking to [[Special:Mute]] preselecting the sender to manage muting options.\n* $1 - Url linking to [[Special:Mute]].\n* $2 - The user sending the email.",
        "specialmute-login-required": "Error displayed when a user tries to access [[Special:Mute]] before logging in.",
        "mute-preferences": "Link in the sidebar to manage muting preferences for a user. It links to [[Special:Mute]] with the user in context as the subpage.",
        "passwordpolicies-policy-passwordnotinlargeblacklist": "Password policy that enforces that a password is not in a list of 100,000 number of \"popular\" passwords.",
        "passwordpolicies-policyflag-forcechange": "Password policy flag that enforces changing invalid passwords on login.",
        "passwordpolicies-policyflag-suggestchangeonlogin": "Password policy flag that suggests changing invalid passwords on login.",
+       "mycustomjsredirectprotected": "Error message shown when user tries to edit their own JS page that is a foreign redirect without the 'mycustomjsredirectprotected' right. See also {{mw-msg|mycustomjsprotected}}.",
        "easydeflate-invaliddeflate": "Error message if the content passed to easydeflate was not deflated (compressed) properly",
        "unprotected-js": "Error message shown when trying to load javascript via action=raw that is not protected",
        "userlogout-continue": "Shown if user attempted to log out without a token specified. Probably the user clicked on an old link that hasn't been updated to use the new system. $1 - url that user should click on in order to log out."
index 513edf3..9548d6b 100644 (file)
@@ -37,6 +37,7 @@ class McTest extends Maintenance {
                        . " memcached server and shows a report" );
                $this->addOption( 'i', 'Number of iterations', false, true );
                $this->addOption( 'cache', 'Use servers from this $wgObjectCaches store', false, true );
+               $this->addOption( 'driver', 'Either "php" or "pecl"', false, true );
                $this->addArg( 'server[:port]', 'Memcached server to test, with optional port', false );
        }
 
@@ -66,41 +67,177 @@ class McTest extends Maintenance {
                # find out the longest server string to nicely align output later on
                $maxSrvLen = $servers ? max( array_map( 'strlen', $servers ) ) : 0;
 
+               $type = $this->getOption( 'driver', 'php' );
+               if ( $type === 'php' ) {
+                       $class = MemcachedPhpBagOStuff::class;
+               } elseif ( $type === 'pecl' ) {
+                       $class = MemcachedPeclBagOStuff::class;
+               } else {
+                       $this->fatalError( "Invalid driver type '$type'" );
+               }
+
                foreach ( $servers as $server ) {
-                       $this->output(
-                               str_pad( $server, $maxSrvLen ),
-                               $server # output channel
-                       );
+                       $this->output( str_pad( $server, $maxSrvLen ) . "\n" );
 
-                       $mcc = new MemcachedClient( [
-                               'persistant' => true,
+                       /** @var BagOStuff $mcc */
+                       $mcc = new $class( [
+                               'servers' => [ $server ],
+                               'persistent' => true,
                                'timeout' => $wgMemCachedTimeout
                        ] );
-                       $mcc->set_servers( [ $server ] );
-                       $set = 0;
-                       $incr = 0;
-                       $get = 0;
-                       $time_start = microtime( true );
-                       for ( $i = 1; $i <= $iterations; $i++ ) {
-                               if ( $mcc->set( "test$i", $i ) ) {
-                                       $set++;
-                               }
+
+                       $this->benchmarkSingleKeyOps( $mcc, $iterations );
+                       $this->benchmarkMultiKeyOpsImmediateBlocking( $mcc, $iterations );
+                       $this->benchmarkMultiKeyOpsDeferredBlocking( $mcc, $iterations );
+               }
+       }
+
+       /**
+        * @param BagOStuff $mcc
+        * @param int $iterations
+        */
+       private function benchmarkSingleKeyOps( $mcc, $iterations ) {
+               $add = 0;
+               $set = 0;
+               $incr = 0;
+               $get = 0;
+               $delete = 0;
+
+               $keys = [];
+               for ( $i = 1; $i <= $iterations; $i++ ) {
+                       $keys[] = "test$i";
+               }
+
+               // Clear out any old values
+               $mcc->deleteMulti( $keys );
+
+               $time_start = microtime( true );
+               foreach ( $keys as $key ) {
+                       if ( $mcc->add( $key, $i ) ) {
+                               $add++;
                        }
-                       for ( $i = 1; $i <= $iterations; $i++ ) {
-                               if ( !is_null( $mcc->incr( "test$i", $i ) ) ) {
-                                       $incr++;
-                               }
+               }
+               $addMs = intval( 1e3 * ( microtime( true ) - $time_start ) );
+
+               $time_start = microtime( true );
+               foreach ( $keys as $key ) {
+                       if ( $mcc->set( $key, $i ) ) {
+                               $set++;
+                       }
+               }
+               $setMs = intval( 1e3 * ( microtime( true ) - $time_start ) );
+
+               $time_start = microtime( true );
+               foreach ( $keys as $key ) {
+                       if ( !is_null( $mcc->incr( $key, $i ) ) ) {
+                               $incr++;
                        }
-                       for ( $i = 1; $i <= $iterations; $i++ ) {
-                               $value = $mcc->get( "test$i" );
-                               if ( $value == $i * 2 ) {
-                                       $get++;
-                               }
+               }
+               $incrMs = intval( 1e3 * ( microtime( true ) - $time_start ) );
+
+               $time_start = microtime( true );
+               foreach ( $keys as $key ) {
+                       $value = $mcc->get( $key );
+                       if ( $value == $i * 2 ) {
+                               $get++;
                        }
-                       $exectime = microtime( true ) - $time_start;
+               }
+               $getMs = intval( 1e3 * ( microtime( true ) - $time_start ) );
 
-                       $this->output( " set: $set   incr: $incr   get: $get time: $exectime", $server );
+               $time_start = microtime( true );
+               foreach ( $keys as $key ) {
+                       if ( $mcc->delete( $key ) ) {
+                               $delete++;
+                       }
                }
+               $delMs = intval( 1e3 * ( microtime( true ) - $time_start ) );
+
+               $this->output(
+                       " add: $add/$iterations {$addMs}ms   " .
+                       "set: $set/$iterations {$setMs}ms   " .
+                       "incr: $incr/$iterations {$incrMs}ms   " .
+                       "get: $get/$iterations ({$getMs}ms)   " .
+                       "delete: $delete/$iterations ({$delMs}ms)\n"
+               );
+       }
+
+       /**
+        * @param BagOStuff $mcc
+        * @param int $iterations
+        */
+       private function benchmarkMultiKeyOpsImmediateBlocking( $mcc, $iterations ) {
+               $keysByValue = [];
+               for ( $i = 1; $i <= $iterations; $i++ ) {
+                       $keysByValue["test$i"] = 'S' . str_pad( $i, 2048 );
+               }
+               $keyList = array_keys( $keysByValue );
+
+               $time_start = microtime( true );
+               $mSetOk = $mcc->setMulti( $keysByValue ) ? 'S' : 'F';
+               $mSetMs = intval( 1e3 * ( microtime( true ) - $time_start ) );
+
+               $time_start = microtime( true );
+               $found = $mcc->getMulti( $keyList );
+               $mGetMs = intval( 1e3 * ( microtime( true ) - $time_start ) );
+               $mGetOk = 0;
+               foreach ( $found as $key => $value ) {
+                       $mGetOk += ( $value === $keysByValue[$key] );
+               }
+
+               $time_start = microtime( true );
+               $mChangeTTLOk = $mcc->changeTTLMulti( $keyList, 3600 ) ? 'S' : 'F';
+               $mChangeTTTMs = intval( 1e3 * ( microtime( true ) - $time_start ) );
+
+               $time_start = microtime( true );
+               $mDelOk = $mcc->deleteMulti( $keyList ) ? 'S' : 'F';
+               $mDelMs = intval( 1e3 * ( microtime( true ) - $time_start ) );
+
+               $this->output(
+                       " setMulti (IB): $mSetOk {$mSetMs}ms   " .
+                       "getMulti (IB): $mGetOk/$iterations {$mGetMs}ms   " .
+                       "changeTTLMulti (IB): $mChangeTTLOk {$mChangeTTTMs}ms   " .
+                       "deleteMulti (IB): $mDelOk {$mDelMs}ms\n"
+               );
+       }
+
+       /**
+        * @param BagOStuff $mcc
+        * @param int $iterations
+        */
+       private function benchmarkMultiKeyOpsDeferredBlocking( $mcc, $iterations ) {
+               $flags = $mcc::WRITE_BACKGROUND;
+               $keysByValue = [];
+               for ( $i = 1; $i <= $iterations; $i++ ) {
+                       $keysByValue["test$i"] = 'A' . str_pad( $i, 2048 );
+               }
+               $keyList = array_keys( $keysByValue );
+
+               $time_start = microtime( true );
+               $mSetOk = $mcc->setMulti( $keysByValue, 0, $flags ) ? 'S' : 'F';
+               $mSetMs = intval( 1e3 * ( microtime( true ) - $time_start ) );
+
+               $time_start = microtime( true );
+               $found = $mcc->getMulti( $keyList );
+               $mGetMs = intval( 1e3 * ( microtime( true ) - $time_start ) );
+               $mGetOk = 0;
+               foreach ( $found as $key => $value ) {
+                       $mGetOk += ( $value === $keysByValue[$key] );
+               }
+
+               $time_start = microtime( true );
+               $mChangeTTLOk = $mcc->changeTTLMulti( $keyList, 3600, $flags ) ? 'S' : 'F';
+               $mChangeTTTMs = intval( 1e3 * ( microtime( true ) - $time_start ) );
+
+               $time_start = microtime( true );
+               $mDelOk = $mcc->deleteMulti( $keyList, $flags ) ? 'S' : 'F';
+               $mDelMs = intval( 1e3 * ( microtime( true ) - $time_start ) );
+
+               $this->output(
+                       " setMulti (DB): $mSetOk {$mSetMs}ms   " .
+                       "getMulti (DB): $mGetOk/$iterations {$mGetMs}ms   " .
+                       "changeTTLMulti (DB): $mChangeTTLOk {$mChangeTTTMs}ms   " .
+                       "deleteMulti (DB): $mDelOk {$mDelMs}ms\n"
+               );
        }
 }
 
index 6298086..9455994 100644 (file)
@@ -1526,7 +1526,9 @@ return [
                'remoteBasePath' => "$wgResourceBasePath/resources/src/mediawiki.jqueryMsg",
                'packageFiles' => [
                        'mediawiki.jqueryMsg.js',
-                       [ 'name' => 'parserDefaults.json', 'callback' => function ( ResourceLoaderContext $context ) {
+                       [ 'name' => 'parserDefaults.json', 'callback' => function (
+                               ResourceLoaderContext $context, Config $config
+                       ) {
                                $tagData = Sanitizer::getRecognizedTagData();
                                $allowedHtmlElements = array_merge(
                                        array_keys( $tagData['htmlpairs'] ),
@@ -1537,7 +1539,7 @@ return [
                                );
 
                                $magicWords = [
-                                       'SITENAME' => $context->getConfig()->get( 'Sitename' ),
+                                       'SITENAME' => $config->get( 'Sitename' ),
                                ];
                                Hooks::run( 'ResourceLoaderJqueryMsgModuleMagicWords', [ $context, &$magicWords ] );
 
index 43a333c..5f7746b 100644 (file)
@@ -36,7 +36,8 @@ abstract class MediaWikiUnitTestCase extends TestCase {
        protected function setUp() {
                parent::setUp();
                $reflection = new ReflectionClass( $this );
-               if ( strpos( $reflection->getFilename(), '/unit/' ) === false ) {
+               $dirSeparator = DIRECTORY_SEPARATOR;
+               if ( strpos( $reflection->getFilename(), "${dirSeparator}unit${dirSeparator}" ) === false ) {
                        $this->fail( 'This unit test needs to be in "tests/phpunit/unit" !' );
                }
                $this->unitGlobals = $GLOBALS;
index 1210a50..660734e 100644 (file)
@@ -716,6 +716,7 @@ class GlobalTest extends MediaWikiTestCase {
         */
        public function testWfGlobalCacheKey() {
                $cache = ObjectCache::getLocalClusterInstance();
+               $this->hideDeprecated( 'wfGlobalCacheKey' );
                $this->assertEquals(
                        $cache->makeGlobalKey( 'foo', 123, 'bar' ),
                        wfGlobalCacheKey( 'foo', 123, 'bar' )
index 5b015b3..8a98217 100644 (file)
@@ -3,21 +3,25 @@
 namespace MediaWiki\Tests\Permissions;
 
 use Action;
+use ContentHandler;
 use FauxRequest;
-use MediaWiki\Session\SessionId;
-use MediaWiki\Session\TestUtils;
-use MediaWikiLangTestCase;
-use RequestContext;
-use stdClass;
-use Title;
-use User;
 use MediaWiki\Block\DatabaseBlock;
 use MediaWiki\Block\Restriction\NamespaceRestriction;
 use MediaWiki\Block\Restriction\PageRestriction;
 use MediaWiki\Block\SystemBlock;
+use MediaWiki\Linker\LinkTarget;
 use MediaWiki\MediaWikiServices;
 use MediaWiki\Permissions\PermissionManager;
+use MediaWiki\Revision\MutableRevisionRecord;
+use MediaWiki\Revision\RevisionLookup;
 use Wikimedia\ScopedCallback;
+use MediaWiki\Session\SessionId;
+use MediaWiki\Session\TestUtils;
+use MediaWikiLangTestCase;
+use RequestContext;
+use stdClass;
+use Title;
+use User;
 use Wikimedia\TestingAccessWrapper;
 
 /**
@@ -698,6 +702,64 @@ class PermissionManagerTest extends MediaWikiLangTestCase {
                );
        }
 
+       public function testJsConfigRedirectEditPermissions() {
+               $revision = null;
+               $user = $this->getTestUser()->getUser();
+               $otherUser = $this->getTestUser( 'sysop' )->getUser();
+               $localJsTitle = Title::newFromText( 'User:' . $user->getName() . '/foo.js' );
+               $otherLocalJsTitle = Title::newFromText( 'User:' . $user->getName() . '/foo2.js' );
+               $nonlocalJsTitle = Title::newFromText( 'User:' . $otherUser->getName() . '/foo.js' );
+
+               $services = MediaWikiServices::getInstance();
+               $revisionLookup = $this->getMockBuilder( RevisionLookup::class )
+                       ->setMethods( [ 'getRevisionByTitle' ] )
+                       ->getMockForAbstractClass();
+               $revisionLookup->method( 'getRevisionByTitle' )
+                       ->willReturnCallback( function ( LinkTarget $page ) use (
+                               $services, &$revision, $localJsTitle
+                       ) {
+                               if ( $localJsTitle->equals( Title::newFromLinkTarget( $page ) ) ) {
+                                       return $revision;
+                               } else {
+                                       return $services->getRevisionLookup()->getRevisionByTitle( $page );
+                               }
+                       } );
+               $permissionManager = new PermissionManager(
+                       $services->getSpecialPageFactory(),
+                       $revisionLookup,
+                       [],
+                       [],
+                       false,
+                       false,
+                       [],
+                       [],
+                       [],
+                       MediaWikiServices::getInstance()->getNamespaceInfo()
+               );
+               $this->setService( 'PermissionManager', $permissionManager );
+
+               $permissionManager->overrideUserRightsForTesting( $user, [ 'edit', 'editmyuserjs' ] );
+
+               $revision = $this->getJavascriptRevision( $localJsTitle, $user, '/* script */' );
+               $errors = $permissionManager->getPermissionErrors( 'edit', $user, $localJsTitle );
+               $this->assertSame( [], $errors );
+
+               $revision = $this->getJavascriptRedirectRevision( $localJsTitle, $otherLocalJsTitle, $user );
+               $errors = $permissionManager->getPermissionErrors( 'edit', $user, $localJsTitle );
+               $this->assertSame( [], $errors );
+
+               $revision = $this->getJavascriptRedirectRevision( $localJsTitle, $nonlocalJsTitle, $user );
+               $errors = $permissionManager->getPermissionErrors( 'edit', $user, $localJsTitle );
+               $this->assertSame( [ [ 'mycustomjsredirectprotected', 'edit' ] ], $errors );
+
+               $permissionManager->overrideUserRightsForTesting( $user,
+                       [ 'edit', 'editmyuserjs', 'editmyuserjsredirect' ] );
+
+               $revision = $this->getJavascriptRedirectRevision( $localJsTitle, $nonlocalJsTitle, $user );
+               $errors = $permissionManager->getPermissionErrors( 'edit', $user, $localJsTitle );
+               $this->assertSame( [], $errors );
+       }
+
        /**
         * @todo This test method should be split up into separate test methods and
         * data providers
@@ -1683,4 +1745,35 @@ class PermissionManagerTest extends MediaWikiLangTestCase {
                $this->assertFalse( $permissionManager->userHasRight( $this->user, 'move' ) );
        }
 
+       /**
+        * Create a RevisionRecord with a single Javascript main slot.
+        * @param Title $title
+        * @param User $user
+        * @param string $text
+        * @return MutableRevisionRecord
+        */
+       private function getJavascriptRevision( Title $title, User $user, $text ) {
+               $content = ContentHandler::makeContent( $text, $title, CONTENT_MODEL_JAVASCRIPT );
+               $revision = new MutableRevisionRecord( $title );
+               $revision->setContent( 'main', $content );
+               return $revision;
+       }
+
+       /**
+        * Create a RevisionRecord with a single Javascript redirect main slot.
+        * @param Title $title
+        * @param Title $redirectTargetTitle
+        * @param User $user
+        * @return MutableRevisionRecord
+        */
+       private function getJavascriptRedirectRevision(
+               Title $title, Title $redirectTargetTitle, User $user
+       ) {
+               $content = ContentHandler::getForModelID( CONTENT_MODEL_JAVASCRIPT )
+                       ->makeRedirectContent( $redirectTargetTitle );
+               $revision = new MutableRevisionRecord( $title );
+               $revision->setContent( 'main', $content );
+               return $revision;
+       }
+
 }
index 2d141e6..83e8d85 100644 (file)
@@ -1468,7 +1468,7 @@ abstract class RevisionDbTestBase extends MediaWikiTestCase {
                        $rev->getPage(),
                        $rev->getId()
                );
-               $cache->delete( $key, WANObjectCache::HOLDOFF_NONE );
+               $cache->delete( $key, WANObjectCache::HOLDOFF_TTL_NONE );
                $this->assertFalse( $cache->get( $key ) );
 
                ++$now;
diff --git a/tests/phpunit/includes/api/ApiCSPReportTest.php b/tests/phpunit/includes/api/ApiCSPReportTest.php
new file mode 100644 (file)
index 0000000..b3e0543
--- /dev/null
@@ -0,0 +1,120 @@
+<?php
+
+/**
+ * @group API
+ * @group medium
+ * @covers ApiCSPReport
+ */
+class ApiCSPReportTest extends MediaWikiIntegrationTestCase {
+
+       public function setUp() {
+               parent::setUp();
+               $this->setMwGlobals( [
+                       'CSPFalsePositiveUrls' => [],
+               ] );
+       }
+
+       public function testInternalReportonly() {
+               $params = [
+                       'reportonly' => '1',
+                       'source' => 'internal',
+               ];
+               $cspReport = [
+                       'document-uri' => 'https://doc.test/path',
+                       'referrer' => 'https://referrer.test/path',
+                       'violated-directive' => 'connet-src',
+                       'disposition' => 'report',
+                       'blocked-uri' => 'https://blocked.test/path?query',
+                       'line-number' => 4,
+                       'column-number' => 2,
+                       'source-file' => 'https://source.test/path?query',
+               ];
+
+               $log = $this->doExecute( $params, $cspReport );
+
+               $this->assertEquals(
+                       [
+                               [
+                                       '[report-only] Received CSP report: ' .
+                                               '<https://blocked.test> blocked from being loaded on <https://doc.test/path>:4',
+                                       [
+                                               'method' => 'ApiCSPReport::execute',
+                                               'user_id' => 'logged-out',
+                                               'user-agent' => 'Test/0.0',
+                                               'source' => 'internal'
+                                       ]
+                               ],
+                       ],
+                       $log,
+                       'logged messages'
+               );
+       }
+
+       public function testFalsePositiveOriginMatch() {
+               $params = [
+                       'reportonly' => '1',
+                       'source' => 'internal',
+               ];
+               $cspReport = [
+                       'document-uri' => 'https://doc.test/path',
+                       'referrer' => 'https://referrer.test/path',
+                       'violated-directive' => 'connet-src',
+                       'disposition' => 'report',
+                       'blocked-uri' => 'https://blocked.test/path/file?query',
+                       'line-number' => 4,
+                       'column-number' => 2,
+                       'source-file' => 'https://source.test/path/file?query',
+               ];
+
+               $this->setMwGlobals( [
+                       'wgCSPFalsePositiveUrls' => [
+                               'https://blocked.test/path/' => true,
+                       ],
+               ] );
+               $log = $this->doExecute( $params, $cspReport );
+
+               $this->assertSame(
+                       [],
+                       $log,
+                       'logged messages'
+               );
+       }
+
+       private function doExecute( array $params, array $cspReport ) {
+               $log = [];
+               $logger = $this->createMock( Psr\Log\AbstractLogger::class );
+               $logger->method( 'warning' )->will( $this->returnCallback(
+                       function ( $msg, $ctx ) use ( &$log ) {
+                               unset( $ctx['csp-report'] );
+                               $log[] = [ $msg, $ctx ];
+                       }
+               ) );
+               $this->setLogger( 'csp-report-only', $logger );
+
+               $postBody = json_encode( [ 'csp-report' => $cspReport ] );
+               $req = $this->getMockBuilder( FauxRequest::class )
+                       ->setMethods( [ 'getRawInput' ] )
+                       ->setConstructorArgs( [ $params, /* $wasPosted */ true ] )
+                       ->getMock();
+               $req->method( 'getRawInput' )->willReturn( $postBody );
+               $req->setHeaders( [
+                       'Content-Type' => 'application/csp-report',
+                       'User-Agent' => 'Test/0.0'
+               ] );
+
+               $api = $this->getMockBuilder( ApiCSPReport::class )
+                       ->disableOriginalConstructor()
+                       ->setMethods( [ 'getParameter', 'getRequest', 'getResult' ] )
+                       ->getMock();
+               $api->method( 'getParameter' )->will( $this->returnCallback(
+                       function ( $key ) use ( $req ) {
+                               return $req->getRawVal( $key );
+                       }
+               ) );
+               $api->method( 'getRequest' )->willReturn( $req );
+               $api->method( 'getResult' )->willReturn( new ApiResult( false ) );
+
+               $api->execute();
+               return $log;
+       }
+}
index fe3bb88..b8f60c4 100644 (file)
@@ -2,9 +2,11 @@
 
 use MediaWiki\Block\BlockManager;
 use MediaWiki\Block\DatabaseBlock;
+use MediaWiki\Block\CompositeBlock;
 use MediaWiki\Block\SystemBlock;
 use MediaWiki\Config\ServiceOptions;
 use MediaWiki\MediaWikiServices;
+use Wikimedia\TestingAccessWrapper;
 
 /**
  * @group Blocking
@@ -63,26 +65,23 @@ class BlockManagerTest extends MediaWikiTestCase {
         * @covers ::shouldApplyCookieBlock
         */
        public function testGetBlockFromCookieValue( $options, $expected ) {
-               $blockManager = $this->getBlockManager( [
-                       'wgCookieSetOnAutoblock' => true,
-                       'wgCookieSetOnIpBlock' => true,
-               ] );
+               $blockManager = TestingAccessWrapper::newFromObject(
+                       $this->getBlockManager( [
+                               'wgCookieSetOnAutoblock' => true,
+                               'wgCookieSetOnIpBlock' => true,
+                       ] )
+               );
 
                $block = new DatabaseBlock( array_merge( [
-                       'address' => $options[ 'target' ] ?: $this->user,
+                       'address' => $options['target'] ?: $this->user,
                        'by' => $this->sysopId,
-               ], $options[ 'blockOptions' ] ) );
+               ], $options['blockOptions'] ) );
                $block->insert();
 
-               $class = new ReflectionClass( BlockManager::class );
-               $method = $class->getMethod( 'getBlockFromCookieValue' );
-               $method->setAccessible( true );
-
-               $user = $options[ 'loggedIn' ] ? $this->user : new User();
+               $user = $options['loggedIn'] ? $this->user : new User();
                $user->getRequest()->setCookie( 'BlockID', $block->getCookieValue() );
 
-               $this->assertSame( $expected, (bool)$method->invoke(
-                       $blockManager,
+               $this->assertSame( $expected, (bool)$blockManager->getBlockFromCookieValue(
                        $user,
                        $user->getRequest()
                ) );
@@ -142,16 +141,14 @@ class BlockManagerTest extends MediaWikiTestCase {
         * @covers ::isLocallyBlockedProxy
         */
        public function testIsLocallyBlockedProxy( $proxyList, $expected ) {
-               $class = new ReflectionClass( BlockManager::class );
-               $method = $class->getMethod( 'isLocallyBlockedProxy' );
-               $method->setAccessible( true );
-
-               $blockManager = $this->getBlockManager( [
-                       'wgProxyList' => $proxyList
-               ] );
+               $blockManager = TestingAccessWrapper::newFromObject(
+                       $this->getBlockManager( [
+                               'wgProxyList' => $proxyList
+                       ] )
+               );
 
                $ip = '1.2.3.4';
-               $this->assertSame( $expected, $method->invoke( $blockManager, $ip ) );
+               $this->assertSame( $expected, $blockManager->isLocallyBlockedProxy( $ip ) );
        }
 
        public static function provideIsLocallyBlockedProxy() {
@@ -174,16 +171,14 @@ class BlockManagerTest extends MediaWikiTestCase {
                        'addresses in keys: ' . $proxy . ', please move them to values)'
                );
 
-               $class = new ReflectionClass( BlockManager::class );
-               $method = $class->getMethod( 'isLocallyBlockedProxy' );
-               $method->setAccessible( true );
-
-               $blockManager = $this->getBlockManager( [
-                       'wgProxyList' => [ $proxy => 'test' ]
-               ] );
+               $blockManager = TestingAccessWrapper::newFromObject(
+                       $this->getBlockManager( [
+                               'wgProxyList' => [ $proxy => 'test' ]
+                       ] )
+               );
 
                $ip = '1.2.3.4';
-               $this->assertSame( true, $method->invoke( $blockManager, $ip ) );
+               $this->assertTrue( $blockManager->isLocallyBlockedProxy( $ip ) );
        }
 
        /**
@@ -202,9 +197,7 @@ class BlockManagerTest extends MediaWikiTestCase {
                        ->setConstructorArgs( $this->getBlockManagerConstructorArgs( $blockManagerConfig ) )
                        ->setMethods( [ 'checkHost' ] )
                        ->getMock();
-
-               $blockManager->expects( $this->any() )
-                       ->method( 'checkHost' )
+               $blockManager->method( 'checkHost' )
                        ->will( $this->returnValueMap( [ [
                                $options['dnsblQuery'],
                                $options['dnsblResponse'],
@@ -306,89 +299,406 @@ class BlockManagerTest extends MediaWikiTestCase {
        public function testGetUniqueBlocks() {
                $blockId = 100;
 
-               $class = new ReflectionClass( BlockManager::class );
-               $method = $class->getMethod( 'getUniqueBlocks' );
-               $method->setAccessible( true );
-
-               $blockManager = $this->getBlockManager( [] );
+               $blockManager = TestingAccessWrapper::newFromObject( $this->getBlockManager( [] ) );
 
                $block = $this->getMockBuilder( DatabaseBlock::class )
                        ->setMethods( [ 'getId' ] )
                        ->getMock();
-               $block->expects( $this->any() )
-                       ->method( 'getId' )
+               $block->method( 'getId' )
                        ->willReturn( $blockId );
 
                $autoblock = $this->getMockBuilder( DatabaseBlock::class )
                        ->setMethods( [ 'getParentBlockId', 'getType' ] )
                        ->getMock();
-               $autoblock->expects( $this->any() )
-                       ->method( 'getParentBlockId' )
+               $autoblock->method( 'getParentBlockId' )
                        ->willReturn( $blockId );
-               $autoblock->expects( $this->any() )
-                       ->method( 'getType' )
+               $autoblock->method( 'getType' )
                        ->willReturn( DatabaseBlock::TYPE_AUTO );
 
                $blocks = [ $block, $block, $autoblock, new SystemBlock() ];
 
-               $this->assertSame( 2, count( $method->invoke( $blockManager, $blocks ) ) );
+               $this->assertSame( 2, count( $blockManager->getUniqueBlocks( $blocks ) ) );
        }
 
        /**
-        * @covers ::trackBlockWithCookie
         * @dataProvider provideTrackBlockWithCookie
-        * @param bool $expectCookieSet
-        * @param bool $hasCookie
-        * @param bool $isBlocked
+        * @covers ::trackBlockWithCookie
         */
-       public function testTrackBlockWithCookie( $expectCookieSet, $hasCookie, $isBlocked ) {
-               $blockID = 123;
+       public function testTrackBlockWithCookie( $options, $expectedVal ) {
                $this->setMwGlobals( 'wgCookiePrefix', '' );
 
                $request = new FauxRequest();
-               if ( $hasCookie ) {
+               if ( $options['cookieSet'] ) {
                        $request->setCookie( 'BlockID', 'the value does not matter' );
                }
 
-               if ( $isBlocked ) {
-                       $block = $this->getMockBuilder( DatabaseBlock::class )
-                               ->setMethods( [ 'getType', 'getId' ] )
-                               ->getMock();
-                       $block->method( 'getType' )
-                               ->willReturn( DatabaseBlock::TYPE_IP );
-                       $block->method( 'getId' )
-                               ->willReturn( $blockID );
-               } else {
-                       $block = null;
-               }
-
                $user = $this->getMockBuilder( User::class )
                        ->setMethods( [ 'getBlock', 'getRequest' ] )
                        ->getMock();
                $user->method( 'getBlock' )
-                       ->willReturn( $block );
+                       ->willReturn( $options['block'] );
                $user->method( 'getRequest' )
                        ->willReturn( $request );
-               /** @var User $user */
 
                // Although the block cookie is set via DeferredUpdates, in command line mode updates are
                // processed immediately
-               $blockManager = $this->getBlockManager( [] );
+               $blockManager = $this->getBlockManager( [
+                       'wgSecretKey' => '',
+                       'wgCookieSetOnIpBlock' => true,
+               ] );
                $blockManager->trackBlockWithCookie( $user );
 
                /** @var FauxResponse $response */
                $response = $request->response();
-               $this->assertCount( $expectCookieSet ? 1 : 0, $response->getCookies() );
-               $this->assertEquals( $expectCookieSet ? $blockID : null, $response->getCookie( 'BlockID' ) );
+               $this->assertCount( $expectedVal ? 1 : 0, $response->getCookies() );
+               $this->assertEquals( $expectedVal ?: null, $response->getCookie( 'BlockID' ) );
        }
 
        public function provideTrackBlockWithCookie() {
+               $blockId = 123;
+               return [
+                       'Block cookie is already set; there is a trackable block' => [
+                               [
+                                       'cookieSet' => true,
+                                       'block' => $this->getTrackableBlock( $blockId ),
+                               ],
+                               null,
+                       ],
+                       'Block cookie is already set; there is no block' => [
+                               [
+                                       'cookieSet' => true,
+                                       'block' => null,
+                               ],
+                               null,
+                       ],
+                       'Block cookie is not yet set; there is no block' => [
+                               [
+                                       'cookieSet' => false,
+                                       'block' => null,
+                               ],
+                               null,
+                       ],
+                       'Block cookie is not yet set; there is a trackable block' => [
+                               [
+                                       'cookieSet' => false,
+                                       'block' => $this->getTrackableBlock( $blockId ),
+                               ],
+                               $blockId,
+                       ],
+                       'Block cookie is not yet set; there is a composite block with a trackable block' => [
+                               [
+                                       'cookieSet' => false,
+                                       'block' => new CompositeBlock( [
+                                               'originalBlocks' => [
+                                                       new SystemBlock(),
+                                                       $this->getTrackableBlock( $blockId ),
+                                               ]
+                                       ] ),
+                               ],
+                               $blockId,
+                       ],
+                       'Block cookie is not yet set; there is a composite block but no trackable block' => [
+                               [
+                                       'cookieSet' => false,
+                                       'block' => new CompositeBlock( [
+                                               'originalBlocks' => [
+                                                       new SystemBlock(),
+                                                       new SystemBlock(),
+                                               ]
+                                       ] ),
+                               ],
+                               null,
+                       ],
+               ];
+       }
+
+       private function getTrackableBlock( $blockId ) {
+               $block = $this->getMockBuilder( DatabaseBlock::class )
+                       ->setMethods( [ 'getType', 'getId' ] )
+                       ->getMock();
+               $block->method( 'getType' )
+                       ->willReturn( DatabaseBlock::TYPE_IP );
+               $block->method( 'getId' )
+                       ->willReturn( $blockId );
+               return $block;
+       }
+
+       /**
+        * @dataProvider provideSetBlockCookie
+        * @covers ::setBlockCookie
+        */
+       public function testSetBlockCookie( $expiryDelta, $expectedExpiryDelta ) {
+               $this->setMwGlobals( [
+                       'wgCookiePrefix' => '',
+               ] );
+
+               $request = new FauxRequest();
+               $response = $request->response();
+
+               $blockManager = $this->getBlockManager( [
+                       'wgSecretKey' => '',
+                       'wgCookieSetOnIpBlock' => true,
+               ] );
+
+               $now = wfTimestamp();
+
+               $block = new DatabaseBlock( [
+                       'expiry' => $expiryDelta === '' ? '' : $now + $expiryDelta
+               ] );
+               $blockManager->setBlockCookie( $block, $response );
+               $cookies = $response->getCookies();
+
+               $this->assertEquals(
+                       $now + $expectedExpiryDelta,
+                       $cookies['BlockID']['expire'],
+                       '',
+                       60 // Allow actual to be up to 60 seconds later than expected
+               );
+       }
+
+       public static function provideSetBlockCookie() {
+               // Maximum length of a block cookie, defined in BlockManager::setBlockCookie
+               $maxExpiryDelta = ( 24 * 60 * 60 );
+
+               $longExpiryDelta = ( 48 * 60 * 60 );
+               $shortExpiryDelta = ( 12 * 60 * 60 );
+
+               return [
+                       'Block has indefinite expiry' => [
+                               '',
+                               $maxExpiryDelta,
+                       ],
+                       'Block expiry is later than maximum cookie block expiry' => [
+                               $longExpiryDelta,
+                               $maxExpiryDelta,
+                       ],
+                       'Block expiry is sooner than maximum cookie block expiry' => [
+                               $shortExpiryDelta,
+                               $shortExpiryDelta,
+                       ],
+               ];
+       }
+
+       /**
+        * @covers ::shouldTrackBlockWithCookie
+        */
+       public function testShouldTrackBlockWithCookieSystemBlock() {
+               $blockManager = TestingAccessWrapper::newFromObject( $this->getBlockManager( [] ) );
+               $this->assertFalse( $blockManager->shouldTrackBlockWithCookie(
+                       new SystemBlock(),
+                       true
+               ) );
+       }
+
+       /**
+        * @dataProvider provideShouldTrackBlockWithCookie
+        * @covers ::shouldTrackBlockWithCookie
+        */
+       public function testShouldTrackBlockWithCookie( $options, $expected ) {
+               $block = $this->getMockBuilder( DatabaseBlock::class )
+                       ->setMethods( [ 'getType', 'isAutoblocking' ] )
+                       ->getMock();
+               $block->method( 'getType' )
+                       ->willReturn( $options['type'] );
+               if ( isset( $options['autoblocking'] ) ) {
+                       $block->method( 'isAutoblocking' )
+                               ->willReturn( $options['autoblocking'] );
+               }
+
+               $blockManager = TestingAccessWrapper::newFromObject(
+                       $this->getBlockManager( $options['blockManagerConfig'] )
+               );
+
+               $this->assertSame(
+                       $expected,
+                       $blockManager->shouldTrackBlockWithCookie( $block, $options['isAnon'] )
+               );
+       }
+
+       public static function provideShouldTrackBlockWithCookie() {
                return [
-                       // $expectCookieSet, $hasCookie, $isBlocked
-                       [ false, false, false ],
-                       [ false, true, false ],
-                       [ true, false, true ],
-                       [ false, true, true ],
+                       'IP block, anonymous user, IP block cookies enabled' => [
+                               [
+                                       'type' => DatabaseBlock::TYPE_IP,
+                                       'isAnon' => true,
+                                       'blockManagerConfig' => [ 'wgCookieSetOnIpBlock' => true ],
+                               ],
+                               true
+                       ],
+                       'IP range block, anonymous user, IP block cookies enabled' => [
+                               [
+                                       'type' => DatabaseBlock::TYPE_RANGE,
+                                       'isAnon' => true,
+                                       'blockManagerConfig' => [ 'wgCookieSetOnIpBlock' => true ],
+                               ],
+                               true
+                       ],
+                       'IP block, anonymous user, IP block cookies disabled' => [
+                               [
+                                       'type' => DatabaseBlock::TYPE_IP,
+                                       'isAnon' => true,
+                                       'blockManagerConfig' => [ 'wgCookieSetOnIpBlock' => false ],
+                               ],
+                               false
+                       ],
+                       'IP block, logged in user, IP block cookies enabled' => [
+                               [
+                                       'type' => DatabaseBlock::TYPE_IP,
+                                       'isAnon' => false,
+                                       'blockManagerConfig' => [ 'wgCookieSetOnIpBlock' => true ],
+                               ],
+                               false
+                       ],
+                       'User block, anonymous, autoblock cookies enabled, block is autoblocking' => [
+                               [
+                                       'type' => DatabaseBlock::TYPE_USER,
+                                       'isAnon' => true,
+                                       'blockManagerConfig' => [ 'wgCookieSetOnAutoblock' => true ],
+                                       'autoblocking' => true,
+                               ],
+                               false
+                       ],
+                       'User block, logged in, autoblock cookies enabled, block is autoblocking' => [
+                               [
+                                       'type' => DatabaseBlock::TYPE_USER,
+                                       'isAnon' => false,
+                                       'blockManagerConfig' => [ 'wgCookieSetOnAutoblock' => true ],
+                                       'autoblocking' => true,
+                               ],
+                               true
+                       ],
+                       'User block, logged in, autoblock cookies disabled, block is autoblocking' => [
+                               [
+                                       'type' => DatabaseBlock::TYPE_USER,
+                                       'isAnon' => false,
+                                       'blockManagerConfig' => [ 'wgCookieSetOnAutoblock' => false ],
+                                       'autoblocking' => true,
+                               ],
+                               false
+                       ],
+                       'User block, logged in, autoblock cookies enabled, block is not autoblocking' => [
+                               [
+                                       'type' => DatabaseBlock::TYPE_USER,
+                                       'isAnon' => false,
+                                       'blockManagerConfig' => [ 'wgCookieSetOnAutoblock' => true ],
+                                       'autoblocking' => false,
+                               ],
+                               false
+                       ],
+                       'Block type is autoblock' => [
+                               [
+                                       'type' => DatabaseBlock::TYPE_AUTO,
+                                       'isAnon' => true,
+                                       'blockManagerConfig' => [],
+                               ],
+                               false
+                       ]
                ];
        }
+
+       /**
+        * @covers ::clearBlockCookie
+        */
+       public function testClearBlockCookie() {
+               $this->setMwGlobals( [
+                       'wgCookiePrefix' => '',
+               ] );
+
+               $request = new FauxRequest();
+               $response = $request->response();
+               $response->setCookie( 'BlockID', '100' );
+               $this->assertSame( '100', $response->getCookie( 'BlockID' ) );
+
+               BlockManager::clearBlockCookie( $response );
+               $this->assertSame( '', $response->getCookie( 'BlockID' ) );
+       }
+
+       /**
+        * @dataProvider provideGetIdFromCookieValue
+        * @covers ::getIdFromCookieValue
+        */
+       public function testGetIdFromCookieValue( $options, $expected ) {
+               $blockManager = $this->getBlockManager( [
+                       'wgSecretKey' => $options['secretKey']
+               ] );
+               $this->assertEquals(
+                       $expected,
+                       $blockManager->getIdFromCookieValue( $options['cookieValue'] )
+               );
+       }
+
+       public static function provideGetIdFromCookieValue() {
+               $blockId = 100;
+               $secretKey = '123';
+               $hmac = MWCryptHash::hmac( $blockId, $secretKey, false );
+               return [
+                       'No secret key is set' => [
+                               [
+                                       'secretKey' => '',
+                                       'cookieValue' => $blockId,
+                                       'calculatedHmac' => MWCryptHash::hmac( $blockId, '', false ),
+                               ],
+                               $blockId,
+                       ],
+                       'Secret key is set and stored hmac is correct' => [
+                               [
+                                       'secretKey' => $secretKey,
+                                       'cookieValue' => $blockId . '!' . $hmac,
+                                       'calculatedHmac' => $hmac,
+                               ],
+                               $blockId,
+                       ],
+                       'Secret key is set and stored hmac is incorrect' => [
+                               [
+                                       'secretKey' => $secretKey,
+                                       'cookieValue' => $blockId . '!xyz',
+                                       'calculatedHmac' => $hmac,
+                               ],
+                               null,
+                       ],
+               ];
+       }
+
+       /**
+        * @dataProvider provideGetCookieValue
+        * @covers ::getCookieValue
+        */
+       public function testGetCookieValue( $options, $expected ) {
+               $blockManager = $this->getBlockManager( [
+                       'wgSecretKey' => $options['secretKey']
+               ] );
+
+               $block = $this->getMockBuilder( DatabaseBlock::class )
+                       ->setMethods( [ 'getId' ] )
+                       ->getMock();
+               $block->method( 'getId' )
+                       ->willReturn( $options['blockId'] );
+
+               $this->assertEquals(
+                       $expected,
+                       $blockManager->getCookieValue( $block )
+               );
+       }
+
+       public static function provideGetCookieValue() {
+               $blockId = 100;
+               return [
+                       'Secret key not set' => [
+                               [
+                                       'secretKey' => '',
+                                       'blockId' => $blockId,
+                                       'hmac' => MWCryptHash::hmac( $blockId, '', false ),
+                               ],
+                               $blockId,
+                       ],
+                       'Secret key set' => [
+                               [
+                                       'secretKey' => '123',
+                                       'blockId' => $blockId,
+                                       'hmac' => MWCryptHash::hmac( $blockId, '123', false ),
+                               ],
+                               $blockId . '!' . MWCryptHash::hmac( $blockId, '123', false ) ],
+               ];
+       }
+
 }
index 2fa662b..35dacac 100644 (file)
@@ -204,7 +204,7 @@ class MessageCacheTest extends MediaWikiLangTestCase {
                ];
        }
 
-       public function testNoDBAccess() {
+       public function testNoDBAccessContentLanguage() {
                global $wgContLanguageCode;
 
                $dbr = wfGetDB( DB_REPLICA );
@@ -218,7 +218,22 @@ class MessageCacheTest extends MediaWikiLangTestCase {
 
                $dbr->restoreFlags();
 
-               $this->assertEquals( 0, $dbr->trxLevel(), "No DB read queries" );
+               $this->assertEquals( 0, $dbr->trxLevel(), "No DB read queries (content language)" );
+       }
+
+       public function testNoDBAccessNonContentLanguage() {
+               $dbr = wfGetDB( DB_REPLICA );
+
+               MessageCache::singleton()->getMsgFromNamespace( 'allpages/nl', 'nl' );
+
+               $this->assertEquals( 0, $dbr->trxLevel() );
+               $dbr->setFlag( DBO_TRX, $dbr::REMEMBER_PRIOR ); // make queries trigger TRX
+
+               MessageCache::singleton()->getMsgFromNamespace( 'go/nl', 'nl' );
+
+               $dbr->restoreFlags();
+
+               $this->assertEquals( 0, $dbr->trxLevel(), "No DB read queries (non-content language)" );
        }
 
        /**
index da532b0..522af43 100644 (file)
@@ -6,6 +6,7 @@ use Wikimedia\TestingAccessWrapper;
 /**
  * @author Matthias Mullie <mmullie@wikimedia.org>
  * @group BagOStuff
+ * @covers BagOStuff
  */
 class BagOStuffTest extends MediaWikiTestCase {
        /** @var BagOStuff */
@@ -31,8 +32,8 @@ class BagOStuffTest extends MediaWikiTestCase {
        }
 
        /**
-        * @covers BagOStuff::makeGlobalKey
-        * @covers BagOStuff::makeKeyInternal
+        * @covers MediumSpecificBagOStuff::makeGlobalKey
+        * @covers MediumSpecificBagOStuff::makeKeyInternal
         */
        public function testMakeKey() {
                $cache = ObjectCache::newFromId( 'hash' );
@@ -65,8 +66,8 @@ class BagOStuffTest extends MediaWikiTestCase {
        }
 
        /**
-        * @covers BagOStuff::merge
-        * @covers BagOStuff::mergeViaCas
+        * @covers MediumSpecificBagOStuff::merge
+        * @covers MediumSpecificBagOStuff::mergeViaCas
         */
        public function testMerge() {
                $key = $this->cache->makeKey( self::TEST_KEY );
@@ -109,7 +110,7 @@ class BagOStuffTest extends MediaWikiTestCase {
        }
 
        /**
-        * @covers BagOStuff::changeTTL
+        * @covers MediumSpecificBagOStuff::changeTTL
         */
        public function testChangeTTL() {
                $key = $this->cache->makeKey( self::TEST_KEY );
@@ -130,7 +131,7 @@ class BagOStuffTest extends MediaWikiTestCase {
        }
 
        /**
-        * @covers BagOStuff::changeTTLMulti
+        * @covers MediumSpecificBagOStuff::changeTTLMulti
         */
        public function testChangeTTLMulti() {
                $key1 = $this->cache->makeKey( 'test-key1' );
@@ -183,7 +184,7 @@ class BagOStuffTest extends MediaWikiTestCase {
        }
 
        /**
-        * @covers BagOStuff::add
+        * @covers MediumSpecificBagOStuff::add
         */
        public function testAdd() {
                $key = $this->cache->makeKey( self::TEST_KEY );
@@ -193,7 +194,7 @@ class BagOStuffTest extends MediaWikiTestCase {
        }
 
        /**
-        * @covers BagOStuff::get
+        * @covers MediumSpecificBagOStuff::get
         */
        public function testGet() {
                $value = [ 'this' => 'is', 'a' => 'test' ];
@@ -204,9 +205,9 @@ class BagOStuffTest extends MediaWikiTestCase {
        }
 
        /**
-        * @covers BagOStuff::get
-        * @covers BagOStuff::set
-        * @covers BagOStuff::getWithSetCallback
+        * @covers MediumSpecificBagOStuff::get
+        * @covers MediumSpecificBagOStuff::set
+        * @covers MediumSpecificBagOStuff::getWithSetCallback
         */
        public function testGetWithSetCallback() {
                $key = $this->cache->makeKey( self::TEST_KEY );
@@ -223,7 +224,7 @@ class BagOStuffTest extends MediaWikiTestCase {
        }
 
        /**
-        * @covers BagOStuff::incr
+        * @covers MediumSpecificBagOStuff::incr
         */
        public function testIncr() {
                $key = $this->cache->makeKey( self::TEST_KEY );
@@ -235,7 +236,7 @@ class BagOStuffTest extends MediaWikiTestCase {
        }
 
        /**
-        * @covers BagOStuff::incrWithInit
+        * @covers MediumSpecificBagOStuff::incrWithInit
         */
        public function testIncrWithInit() {
                $key = $this->cache->makeKey( self::TEST_KEY );
@@ -247,7 +248,7 @@ class BagOStuffTest extends MediaWikiTestCase {
        }
 
        /**
-        * @covers BagOStuff::getMulti
+        * @covers MediumSpecificBagOStuff::getMulti
         */
        public function testGetMulti() {
                $value1 = [ 'this' => 'is', 'a' => 'test' ];
@@ -287,8 +288,8 @@ class BagOStuffTest extends MediaWikiTestCase {
        }
 
        /**
-        * @covers BagOStuff::setMulti
-        * @covers BagOStuff::deleteMulti
+        * @covers MediumSpecificBagOStuff::setMulti
+        * @covers MediumSpecificBagOStuff::deleteMulti
         */
        public function testSetDeleteMulti() {
                $map = [
@@ -319,10 +320,10 @@ class BagOStuffTest extends MediaWikiTestCase {
        }
 
        /**
-        * @covers BagOStuff::get
-        * @covers BagOStuff::getMulti
-        * @covers BagOStuff::merge
-        * @covers BagOStuff::delete
+        * @covers MediumSpecificBagOStuff::get
+        * @covers MediumSpecificBagOStuff::getMulti
+        * @covers MediumSpecificBagOStuff::merge
+        * @covers MediumSpecificBagOStuff::delete
         */
        public function testSetSegmentable() {
                $key = $this->cache->makeKey( self::TEST_KEY );
@@ -369,7 +370,7 @@ class BagOStuffTest extends MediaWikiTestCase {
        }
 
        /**
-        * @covers BagOStuff::getScopedLock
+        * @covers MediumSpecificBagOStuff::getScopedLock
         */
        public function testGetScopedLock() {
                $key = $this->cache->makeKey( self::TEST_KEY );
@@ -393,8 +394,8 @@ class BagOStuffTest extends MediaWikiTestCase {
        }
 
        /**
-        * @covers BagOStuff::__construct
-        * @covers BagOStuff::trackDuplicateKeys
+        * @covers MediumSpecificBagOStuff::__construct
+        * @covers MediumSpecificBagOStuff::trackDuplicateKeys
         */
        public function testReportDupes() {
                $logger = $this->createMock( Psr\Log\NullLogger::class );
@@ -418,8 +419,8 @@ class BagOStuffTest extends MediaWikiTestCase {
        }
 
        /**
-        * @covers BagOStuff::lock()
-        * @covers BagOStuff::unlock()
+        * @covers MediumSpecificBagOStuff::lock()
+        * @covers MediumSpecificBagOStuff::unlock()
         */
        public function testLocking() {
                $key = 'test';
index 890218c..76bfea2 100644 (file)
@@ -767,8 +767,8 @@ class WANObjectCacheTest extends PHPUnit\Framework\TestCase {
                $localBag = $this->getMockBuilder( HashBagOStuff::class )
                        ->setMethods( [ 'getMulti' ] )->getMock();
                $localBag->expects( $this->exactly( 1 ) )->method( 'getMulti' )->willReturn( [
-                       WANObjectCache::VALUE_KEY_PREFIX . 'k1' => 'val-id1',
-                       WANObjectCache::VALUE_KEY_PREFIX . 'k2' => 'val-id2'
+                       'WANCache:v:' . 'k1' => 'val-id1',
+                       'WANCache:v:' . 'k2' => 'val-id2'
                ] );
                $wanCache = new WANObjectCache( [ 'cache' => $localBag ] );
 
@@ -964,7 +964,7 @@ class WANObjectCacheTest extends PHPUnit\Framework\TestCase {
                $this->assertEquals( 1, $calls, 'Value was populated' );
 
                // Acquire the mutex to verify that getWithSetCallback uses lockTSE properly
-               $this->internalCache->add( $cache::MUTEX_KEY_PREFIX . $key, 1, 0 );
+               $this->internalCache->add( 'WANCache:m:' . $key, 1, 0 );
 
                $checkKeys = [ wfRandomString() ]; // new check keys => force misses
                $ret = $cache->getWithSetCallback( $key, 30, $func,
@@ -1026,7 +1026,7 @@ class WANObjectCacheTest extends PHPUnit\Framework\TestCase {
 
                $mockWallClock += 2; // low logical TTL expired
                // Acquire a lock to verify that getWithSetCallback uses lockTSE properly
-               $this->internalCache->add( $cache::MUTEX_KEY_PREFIX . $key, 1, 0 );
+               $this->internalCache->add( 'WANCache:m:' . $key, 1, 0 );
 
                $ret = $cache->getWithSetCallback( $key, 300, $func, [ 'lockTSE' => 5 ] );
                $this->assertEquals( $value, $ret );
@@ -1034,7 +1034,7 @@ class WANObjectCacheTest extends PHPUnit\Framework\TestCase {
 
                $mockWallClock += 301; // physical TTL expired
                // Acquire a lock to verify that getWithSetCallback uses lockTSE properly
-               $this->internalCache->add( $cache::MUTEX_KEY_PREFIX . $key, 1, 0 );
+               $this->internalCache->add( 'WANCache:m:' . $key, 1, 0 );
 
                $ret = $cache->getWithSetCallback( $key, 300, $func, [ 'lockTSE' => 5 ] );
                $this->assertEquals( $value, $ret );
@@ -1092,7 +1092,7 @@ class WANObjectCacheTest extends PHPUnit\Framework\TestCase {
                $mockWallClock += 0.2; // interim keys not brand new
 
                // Acquire a lock to verify that getWithSetCallback uses busyValue properly
-               $this->internalCache->add( $cache::MUTEX_KEY_PREFIX . $key, 1, 0 );
+               $this->internalCache->add( 'WANCache:m:' . $key, 1, 0 );
 
                $checkKeys = [ wfRandomString() ]; // new check keys => force misses
                $ret = $cache->getWithSetCallback( $key, 30, $func,
@@ -1111,14 +1111,14 @@ class WANObjectCacheTest extends PHPUnit\Framework\TestCase {
                $this->assertEquals( $busyValue, $ret, 'Callback was not used; used busy value' );
                $this->assertEquals( 2, $calls, 'Callback was not used; used busy value' );
 
-               $this->internalCache->delete( $cache::MUTEX_KEY_PREFIX . $key );
+               $this->internalCache->delete( 'WANCache:m:' . $key );
                $mockWallClock += 0.001; // cached values will be newer than tombstone
                $ret = $cache->getWithSetCallback( $key, 30, $func,
                        [ 'lockTSE' => 30, 'busyValue' => $busyValue, 'checkKeys' => $checkKeys ] );
                $this->assertEquals( $value, $ret, 'Callback was used; saved interim' );
                $this->assertEquals( 3, $calls, 'Callback was used; saved interim' );
 
-               $this->internalCache->add( $cache::MUTEX_KEY_PREFIX . $key, 1, 0 );
+               $this->internalCache->add( 'WANCache:m:' . $key, 1, 0 );
                $ret = $cache->getWithSetCallback( $key, 30, $func,
                        [ 'busyValue' => $busyValue, 'checkKeys' => $checkKeys ] );
                $this->assertEquals( $value, $ret, 'Callback was not used; used interim' );
@@ -1208,7 +1208,7 @@ class WANObjectCacheTest extends PHPUnit\Framework\TestCase {
                // Fake initial check key to be set in the past. Otherwise we'd have to sleep for
                // several seconds during the test to assert the behaviour.
                foreach ( [ $checkAll, $check1, $check2 ] as $checkKey ) {
-                       $cache->touchCheckKey( $checkKey, WANObjectCache::HOLDOFF_NONE );
+                       $cache->touchCheckKey( $checkKey, WANObjectCache::HOLDOFF_TTL_NONE );
                }
 
                $mockWallClock += 0.100;
@@ -1330,7 +1330,7 @@ class WANObjectCacheTest extends PHPUnit\Framework\TestCase {
                $this->assertLessThan( 0, $curTTL, "Deleted key is tombstoned and has current TTL < 0" );
 
                $this->cache->set( $key, $value );
-               $this->cache->delete( $key, WANObjectCache::HOLDOFF_NONE );
+               $this->cache->delete( $key, WANObjectCache::HOLDOFF_TTL_NONE );
 
                $curTTL = null;
                $v = $this->cache->get( $key, $curTTL );
@@ -1462,10 +1462,10 @@ class WANObjectCacheTest extends PHPUnit\Framework\TestCase {
                $v = $cache->getWithSetCallback( $key, 60, $func );
                $this->assertEquals( 3, $wasCalled, 'Value regenerated (got mutex)' ); // sets interim
                // Lock up the mutex so interim cache is used
-               $this->internalCache->add( $cache::MUTEX_KEY_PREFIX . $key, 1, 0 );
+               $this->internalCache->add( 'WANCache:m:' . $key, 1, 0 );
                $v = $cache->getWithSetCallback( $key, 60, $func );
                $this->assertEquals( 3, $wasCalled, 'Value interim cached (failed mutex)' );
-               $this->internalCache->delete( $cache::MUTEX_KEY_PREFIX . $key );
+               $this->internalCache->delete( 'WANCache:m:' . $key );
 
                $cache->useInterimHoldOffCaching( false );
 
@@ -1482,7 +1482,7 @@ class WANObjectCacheTest extends PHPUnit\Framework\TestCase {
                $v = $cache->getWithSetCallback( $key, 60, $func );
                $this->assertEquals( 4, $wasCalled, 'Value still regenerated (got mutex)' );
                // Lock up the mutex so interim cache is used
-               $this->internalCache->add( $cache::MUTEX_KEY_PREFIX . $key, 1, 0 );
+               $this->internalCache->add( 'WANCache:m:' . $key, 1, 0 );
                $v = $cache->getWithSetCallback( $key, 60, $func );
                $this->assertEquals( 5, $wasCalled, 'Value still regenerated (failed mutex)' );
        }
@@ -1548,16 +1548,16 @@ class WANObjectCacheTest extends PHPUnit\Framework\TestCase {
 
                // Two check keys are newer (given hold-off) than $key, another is older
                $this->internalCache->set(
-                       WANObjectCache::TIME_KEY_PREFIX . $tKey2,
-                       WANObjectCache::PURGE_VAL_PREFIX . ( $priorTime - 3 )
+                       'WANCache:t:' . $tKey2,
+                       'PURGED:' . ( $priorTime - 3 )
                );
                $this->internalCache->set(
-                       WANObjectCache::TIME_KEY_PREFIX . $tKey2,
-                       WANObjectCache::PURGE_VAL_PREFIX . ( $priorTime - 5 )
+                       'WANCache:t:' . $tKey2,
+                       'PURGED:' . ( $priorTime - 5 )
                );
                $this->internalCache->set(
-                       WANObjectCache::TIME_KEY_PREFIX . $tKey1,
-                       WANObjectCache::PURGE_VAL_PREFIX . ( $priorTime - 30 )
+                       'WANCache:t:' . $tKey1,
+                       'PURGED:' . ( $priorTime - 30 )
                );
                $this->cache->set( $key, $value, 30 );
 
@@ -1584,30 +1584,30 @@ class WANObjectCacheTest extends PHPUnit\Framework\TestCase {
                $badTime = microtime( true ) - 300;
 
                $this->internalCache->set(
-                       WANObjectCache::VALUE_KEY_PREFIX . $vKey1,
+                       'WANCache:v:' . $vKey1,
                        [
-                               WANObjectCache::FLD_FORMAT_VERSION => WANObjectCache::VERSION,
-                               WANObjectCache::FLD_VALUE => $value,
-                               WANObjectCache::FLD_TTL => 3600,
-                               WANObjectCache::FLD_TIME => $goodTime
+                               0 => 1,
+                               1 => $value,
+                               2 => 3600,
+                               3 => $goodTime
                        ]
                );
                $this->internalCache->set(
-                       WANObjectCache::VALUE_KEY_PREFIX . $vKey2,
+                       'WANCache:v:' . $vKey2,
                        [
-                               WANObjectCache::FLD_FORMAT_VERSION => WANObjectCache::VERSION,
-                               WANObjectCache::FLD_VALUE => $value,
-                               WANObjectCache::FLD_TTL => 3600,
-                               WANObjectCache::FLD_TIME => $badTime
+                               0 => 1,
+                               1 => $value,
+                               2 => 3600,
+                               3 => $badTime
                        ]
                );
                $this->internalCache->set(
-                       WANObjectCache::TIME_KEY_PREFIX . $tKey1,
-                       WANObjectCache::PURGE_VAL_PREFIX . $goodTime
+                       'WANCache:t:' . $tKey1,
+                       'PURGED:' . $goodTime
                );
                $this->internalCache->set(
-                       WANObjectCache::TIME_KEY_PREFIX . $tKey2,
-                       WANObjectCache::PURGE_VAL_PREFIX . $badTime
+                       'WANCache:t:' . $tKey2,
+                       'PURGED:' . $badTime
                );
 
                $this->assertEquals( $value, $this->cache->get( $vKey1 ) );
@@ -1632,10 +1632,10 @@ class WANObjectCacheTest extends PHPUnit\Framework\TestCase {
                        ->setMethods( [ 'get', 'changeTTL' ] )->getMock();
                $backend->expects( $this->once() )->method( 'get' )
                        ->willReturn( [
-                               WANObjectCache::FLD_FORMAT_VERSION => WANObjectCache::VERSION,
-                               WANObjectCache::FLD_VALUE => 'value',
-                               WANObjectCache::FLD_TTL => 3600,
-                               WANObjectCache::FLD_TIME => 300,
+                               0 => 1,
+                               1 => 'value',
+                               2 => 3600,
+                               3 => 300,
                        ] );
                $backend->expects( $this->once() )->method( 'changeTTL' )
                        ->willReturn( false );
@@ -1721,7 +1721,7 @@ class WANObjectCacheTest extends PHPUnit\Framework\TestCase {
                ] );
 
                $localBag->expects( $this->once() )->method( 'set' )
-                       ->with( "/*/mw-wan/" . $wanCache::VALUE_KEY_PREFIX . "test" );
+                       ->with( "/*/mw-wan/" . 'WANCache:v:' . "test" );
 
                $wanCache->delete( 'test' );
        }
@@ -1737,7 +1737,7 @@ class WANObjectCacheTest extends PHPUnit\Framework\TestCase {
                ] );
 
                $localBag->expects( $this->once() )->method( 'set' )
-                       ->with( "/*/mw-wan/" . $wanCache::TIME_KEY_PREFIX . "test" );
+                       ->with( "/*/mw-wan/" . 'WANCache:t:' . "test" );
 
                $wanCache->touchCheckKey( 'test' );
        }
@@ -1753,7 +1753,7 @@ class WANObjectCacheTest extends PHPUnit\Framework\TestCase {
                ] );
 
                $localBag->expects( $this->once() )->method( 'delete' )
-                       ->with( "/*/mw-wan/" . $wanCache::TIME_KEY_PREFIX . "test" );
+                       ->with( "/*/mw-wan/" . 'WANCache:t:' . "test" );
 
                $wanCache->resetCheckKey( 'test' );
        }
diff --git a/tests/phpunit/includes/resourceloader/ResourceLoaderFilePathTest.php b/tests/phpunit/includes/resourceloader/ResourceLoaderFilePathTest.php
deleted file mode 100644 (file)
index 292340b..0000000
+++ /dev/null
@@ -1,53 +0,0 @@
-<?php
-
-class ResourceLoaderFilePathTest extends PHPUnit\Framework\TestCase {
-       /**
-        * @covers ResourceLoaderFilePath::__construct
-        */
-       public function testConstructor() {
-               $resourceLoaderFilePath = new ResourceLoaderFilePath(
-                       'dummy/path', 'localBasePath', 'remoteBasePath'
-               );
-
-               $this->assertInstanceOf( ResourceLoaderFilePath::class, $resourceLoaderFilePath );
-       }
-
-       /**
-        * @covers ResourceLoaderFilePath::getLocalPath
-        */
-       public function testGetLocalPath() {
-               $resourceLoaderFilePath = new ResourceLoaderFilePath(
-                       'dummy/path', 'localBasePath', 'remoteBasePath'
-               );
-
-               $this->assertSame(
-                       'localBasePath/dummy/path', $resourceLoaderFilePath->getLocalPath()
-               );
-       }
-
-       /**
-        * @covers ResourceLoaderFilePath::getRemotePath
-        */
-       public function testGetRemotePath() {
-               $resourceLoaderFilePath = new ResourceLoaderFilePath(
-                       'dummy/path', 'localBasePath', 'remoteBasePath'
-               );
-
-               $this->assertSame(
-                       'remoteBasePath/dummy/path', $resourceLoaderFilePath->getRemotePath()
-               );
-       }
-
-       /**
-        * @covers ResourceLoaderFilePath::getPath
-        */
-       public function testGetPath() {
-               $resourceLoaderFilePath = new ResourceLoaderFilePath(
-                       'dummy/path', 'localBasePath', 'remoteBasePath'
-               );
-
-               $this->assertSame(
-                       'dummy/path', $resourceLoaderFilePath->getPath()
-               );
-       }
-}
index f9e30f0..64148b0 100644 (file)
@@ -2,13 +2,17 @@
 
 namespace MediaWiki\Session;
 
+use CachedBagOStuff;
+use HashBagOStuff;
+use RequestContext;
+
 /**
  * BagOStuff with utility functions for MediaWiki\\Session\\* testing
  */
-class TestBagOStuff extends \CachedBagOStuff {
+class TestBagOStuff extends CachedBagOStuff {
 
        public function __construct() {
-               parent::__construct( new \HashBagOStuff );
+               parent::__construct( new HashBagOStuff );
        }
 
        /**
@@ -51,7 +55,7 @@ class TestBagOStuff extends \CachedBagOStuff {
         * @param array|mixed $blob Session metadata and data
         */
        public function setRawSession( $id, $blob ) {
-               $expiry = \RequestContext::getMain()->getConfig()->get( 'ObjectCacheSessionExpiry' );
+               $expiry = RequestContext::getMain()->getConfig()->get( 'ObjectCacheSessionExpiry' );
                $this->set( $this->makeKey( 'MWSession', $id ), $blob, $expiry );
        }
 
index e31357c..a57745b 100644 (file)
@@ -35,10 +35,15 @@ class SpecialMuteTest extends SpecialPageTestBase {
 
        /**
         * @covers SpecialMute::execute
-        * @expectedExceptionMessage Muting users from sending you emails is not enabled
+        * @expectedExceptionMessage Mute features are unavailable
         * @expectedException ErrorPageError
         */
        public function testEmailBlacklistNotEnabled() {
+               $this->setTemporaryHook(
+                       'SpecialMuteModifyFormFields',
+                       null
+               );
+
                $this->setMwGlobals( [
                        'wgEnableUserEmailBlacklist' => false
                ] );
@@ -72,7 +77,7 @@ class SpecialMuteTest extends SpecialPageTestBase {
                $loggedInUser->confirmEmail();
                $loggedInUser->saveSettings();
 
-               $fauxRequest = new FauxRequest( [ 'wpMuteEmail' => 1 ], true );
+               $fauxRequest = new FauxRequest( [ 'wpemail-blacklist' => true ], true );
                list( $html, ) = $this->executeSpecialPage(
                        $targetUser->getName(), $fauxRequest, 'qqx', $loggedInUser
                );
@@ -99,7 +104,7 @@ class SpecialMuteTest extends SpecialPageTestBase {
                $loggedInUser->confirmEmail();
                $loggedInUser->saveSettings();
 
-               $fauxRequest = new FauxRequest( [ 'wpMuteEmail' => false ], true );
+               $fauxRequest = new FauxRequest( [ 'wpemail-blacklist' => false ], true );
                list( $html, ) = $this->executeSpecialPage(
                        $targetUser->getName(), $fauxRequest, 'qqx', $loggedInUser
                );
index b992a86..d7901be 100644 (file)
@@ -171,7 +171,7 @@ class SiteConfigurationTest extends \MediaWikiUnitTestCase {
                $this->assertEquals(
                        'wiki',
                        $this->mConf->get( 'SimpleKey', 'eswiki', 'wiki' ),
-                       'get(): simple setting on an non-existing wiki'
+                       'get(): simple setting on a non-existing wiki'
                );
 
                // Fallback
@@ -209,12 +209,12 @@ class SiteConfigurationTest extends \MediaWikiUnitTestCase {
                $this->assertEquals(
                        'wiki',
                        $this->mConf->get( 'Fallback', 'eswiki', 'wiki' ),
-                       'get(): fallback setting on an non-existing wiki'
+                       'get(): fallback setting on a non-existing wiki'
                );
                $this->assertEquals(
                        'tag',
                        $this->mConf->get( 'Fallback', 'eswiki', 'wiki', [], [ 'tag' ] ),
-                       'get(): fallback setting on an non-existing wiki (with wiki tag)'
+                       'get(): fallback setting on a non-existing wiki (with wiki tag)'
                );
 
                // Merging
@@ -263,12 +263,12 @@ class SiteConfigurationTest extends \MediaWikiUnitTestCase {
                $this->assertEquals(
                        $common,
                        $this->mConf->get( 'MergeIt', 'eswiki', 'wiki' ),
-                       'get(): merging setting on an non-existing wiki'
+                       'get(): merging setting on a non-existing wiki'
                );
                $this->assertEquals(
                        $commonTag,
                        $this->mConf->get( 'MergeIt', 'eswiki', 'wiki', [], [ 'tag' ] ),
-                       'get(): merging setting on an non-existing wiki (with tag)'
+                       'get(): merging setting on a non-existing wiki (with tag)'
                );
        }
 
@@ -324,7 +324,7 @@ class SiteConfigurationTest extends \MediaWikiUnitTestCase {
                $this->assertEquals(
                        'es wiki eswiki',
                        $this->mConf->get( 'WithParams', 'eswiki', 'wiki' ),
-                       'get(): parameter replacement on an non-existing wiki'
+                       'get(): parameter replacement on a non-existing wiki'
                );
        }
 
diff --git a/tests/phpunit/unit/includes/resourceloader/ResourceLoaderFilePathTest.php b/tests/phpunit/unit/includes/resourceloader/ResourceLoaderFilePathTest.php
new file mode 100644 (file)
index 0000000..b1db383
--- /dev/null
@@ -0,0 +1,23 @@
+<?php
+
+/**
+ * @covers ResourceLoaderFilePath
+ */
+class ResourceLoaderFilePathTest extends MediaWikiUnitTestCase {
+
+       public function testConstructor() {
+               $path = new ResourceLoaderFilePath( 'dummy/path', '/local', '/remote' );
+
+               $this->assertInstanceOf( ResourceLoaderFilePath::class, $path );
+       }
+
+       public function testGetters() {
+               $path = new ResourceLoaderFilePath( 'dummy/path', '/local', '/remote' );
+
+               $this->assertSame( '/local/dummy/path', $path->getLocalPath() );
+               $this->assertSame( '/remote/dummy/path', $path->getRemotePath() );
+               $this->assertSame( '/local', $path->getLocalBasePath() );
+               $this->assertSame( '/remote', $path->getRemoteBasePath() );
+               $this->assertSame( 'dummy/path', $path->getPath() );
+       }
+}
index 521eee6..ed6c78a 100644 (file)
@@ -1,3 +1,9 @@
+## 0.4.0 / 2019-07-18
+
+* Util: Added a `waitForModuleState()` method.
+* Api: Added optional `username`, `password` and `baseUrl` parameters to `edit()` method.
+* RunJobs: Unpublished `getJobCount()`, `log()`, `runThroughMainPageRequests()` methods.
+
 ## 0.3.0 / 2019-01-25
 
 * RunJobs: Added initial version.
index a52d594..dc16e81 100644 (file)
@@ -22,7 +22,7 @@ Utilities to interact with the MediaWiki API. Uses the [mwbot](https://github.co
 Actions are performed logged-in using `browser.options.username` and `browser.options.password`,
 which typically come from `MEDIAWIKI_USER` and `MEDIAWIKI_PASSWORD` environment variables.
 
-* `edit(title, content)`
+* `edit(title, content [, string username [, string password [, string baseUrl ] ] ])`
 * `delete(title, reason)`
 * `createAccount(username, password)`
 * `blockUser(username, expiry)`
@@ -33,6 +33,13 @@ which typically come from `MEDIAWIKI_USER` and `MEDIAWIKI_PASSWORD` environment
 Use the static `RunJobs.run()` method to ensure that any queued jobs are executed before
 making assertions that depend on its outcome.
 
+### Util
+
+`Util` is a collection of popular utility methods.
+
+* `getTestString([ string prefix ])`
+* `waitForModuleState(string moduleName [, string moduleStatus [, number timeout ] ])`
+
 ## Versioning
 
 This package follows [Semantic Versioning guidelines](https://semver.org/) for its releases. In
index 4babd08..423487f 100644 (file)
@@ -1,6 +1,6 @@
 {
   "name": "wdio-mediawiki",
-  "version": "0.3.0",
+  "version": "0.4.0",
   "description": "WebdriverIO plugin for testing a MediaWiki site.",
   "homepage": "https://gerrit.wikimedia.org/g/mediawiki/core/+/master/tests/selenium/wdio-mediawiki/",
   "license": "MIT",