deletion will be processed via the job queue.
* action=setnotificationtimestamp will now update the watchlist asynchronously
if entirewatchlist is set, so updates may not be visible immediately
+* Block info will be added to "blocked" errors from more modules.
+* (T216245) Autoblocks will now be spread by action=edit and action=move.
=== Action API internal changes in 1.33 ===
* A number of deprecated methods for API documentation, intended for overriding
hyphen. Methods such as ApiBase::dieWithError() and
ApiMessageTrait::setApiCode() will throw an InvalidArgumentException if
passed a bad code.
+* ApiBase::checkTitleUserPermissions() now takes an options array as its third
+ parameter. Passing a User object or null is deprecated.
=== Languages updated in 1.33 ===
MediaWiki supports over 350 languages. Many localisations are updated regularly.
Use require( 'mediawiki.language.specialCharacters' ) instead.
* ChangeTags::purgeTagUsageCache() has been deprecated, and is expected to be
removed in a future release.
+* Passing a User object or null as the third parameter to
+ ApiBase::checkTitleUserPermissions() has been deprecated. Pass an array
+ [ 'user' => $user ] instead.
=== Other changes in 1.33 ===
* (T201747) Html::openElement() warns if given an element name with a space
/** @var int[][][] Cache for self::filterIDs() */
private static $filterIDsCache = [];
+ /** $var array Map of web UI block messages to corresponding API messages and codes */
+ private static $blockMsgMap = [
+ 'blockedtext' => [ 'apierror-blocked', 'blocked' ],
+ 'blockedtext-partial' => [ 'apierror-blocked', 'blocked' ],
+ 'autoblockedtext' => [ 'apierror-autoblocked', 'autoblocked' ],
+ 'systemblockedtext' => [ 'apierror-systemblocked', 'blocked' ],
+ ];
+
/** @var ApiMain */
private $mMainModule;
/** @var string */
$status = Status::newGood();
foreach ( $errors as $error ) {
- if ( is_array( $error ) && $error[0] === 'blockedtext' && $user->getBlock() ) {
- $status->fatal( ApiMessage::create(
- 'apierror-blocked',
- 'blocked',
- [ 'blockinfo' => ApiQueryUserInfo::getBlockInfo( $user->getBlock() ) ]
- ) );
- } elseif ( is_array( $error ) && $error[0] === 'blockedtext-partial' && $user->getBlock() ) {
- $status->fatal( ApiMessage::create(
- 'apierror-blocked-partial',
- 'blocked',
- [ 'blockinfo' => ApiQueryUserInfo::getBlockInfo( $user->getBlock() ) ]
- ) );
- } elseif ( is_array( $error ) && $error[0] === 'autoblockedtext' && $user->getBlock() ) {
- $status->fatal( ApiMessage::create(
- 'apierror-autoblocked',
- 'autoblocked',
- [ 'blockinfo' => ApiQueryUserInfo::getBlockInfo( $user->getBlock() ) ]
- ) );
- } elseif ( is_array( $error ) && $error[0] === 'systemblockedtext' && $user->getBlock() ) {
- $status->fatal( ApiMessage::create(
- 'apierror-systemblocked',
- 'blocked',
+ if ( is_array( $error ) && isset( self::$blockMsgMap[$error[0]] ) && $user->getBlock() ) {
+ list( $msg, $code ) = self::$blockMsgMap[$error[0]];
+ $status->fatal( ApiMessage::create( $msg, $code,
[ 'blockinfo' => ApiQueryUserInfo::getBlockInfo( $user->getBlock() ) ]
) );
} else {
return $status;
}
+ /**
+ * Add block info to block messages in a Status
+ * @since 1.33
+ * @param StatusValue $status
+ * @param User|null $user
+ */
+ public function addBlockInfoToStatus( StatusValue $status, User $user = null ) {
+ if ( $user === null ) {
+ $user = $this->getUser();
+ }
+
+ foreach ( self::$blockMsgMap as $msg => list( $apiMsg, $code ) ) {
+ if ( $status->hasMessage( $msg ) && $user->getBlock() ) {
+ $status->replaceMessage( $msg, ApiMessage::create( $apiMsg, $code,
+ [ 'blockinfo' => ApiQueryUserInfo::getBlockInfo( $user->getBlock() ) ]
+ ) );
+ }
+ }
+ }
+
/**
* Call wfTransactionalTimeLimit() if this request was POSTed
* @since 1.26
$status = $newStatus;
}
+ $this->addBlockInfoToStatus( $status );
throw new ApiUsageException( $this, $status );
}
/**
* Helper function for permission-denied errors
* @since 1.29
+ * @since 1.33 Changed the third parameter from $user to $options.
* @param Title $title
* @param string|string[] $actions
- * @param User|null $user
+ * @param array $options Additional options
+ * - user: (User) User to use rather than $this->getUser()
+ * - autoblock: (bool, default false) Whether to spread autoblocks
+ * For compatibility, passing a User object is treated as the value for the 'user' option.
* @throws ApiUsageException if the user doesn't have all of the rights.
*/
- public function checkTitleUserPermissions( Title $title, $actions, $user = null ) {
- if ( !$user ) {
- $user = $this->getUser();
+ public function checkTitleUserPermissions( Title $title, $actions, $options = [] ) {
+ if ( !is_array( $options ) ) {
+ wfDeprecated( '$user as the third parameter to ' . __METHOD__, '1.33' );
+ $options = [ 'user' => $options ];
}
+ $user = $options['user'] ?? $this->getUser();
$errors = [];
foreach ( (array)$actions as $action ) {
$this->trackBlockNotices( $errors );
}
+ if ( !empty( $options['autoblock'] ) ) {
+ $user->spreadAnyEditBlock();
+ }
+
$this->dieStatus( $this->errorArrayToStatus( $errors, $user ) );
}
}
// Now let's check whether we're even allowed to do this
$this->checkTitleUserPermissions(
$titleObj,
- $titleObj->exists() ? 'edit' : [ 'edit', 'create' ]
+ $titleObj->exists() ? 'edit' : [ 'edit', 'create' ],
+ [ 'autoblock' => true ]
);
$toMD5 = $params['text'];
$status = $this->movePage( $fromTitle, $toTitle, $params['reason'], !$params['noredirect'],
$params['tags'] ?: [] );
if ( !$status->isOK() ) {
+ $user->spreadAnyEditBlock();
$this->dieStatus( $status );
}
// Try cache
if ( !$this->mRefreshCache ) {
$difftext = $cache->get( $key );
- if ( $difftext ) {
+ if ( is_string( $difftext ) ) {
wfIncrStats( 'diff_cache.hit' );
$difftext = $this->localiseDiff( $difftext );
$difftext .= "\n<!-- diff cache key $key -->\n";
/** 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 */
const LOW_TTL = 30;
/** Tiny negative float to use when CTL comes up >= 0 due to clock skew */
const TINY_NEGATIVE = -0.000001;
+ /** Seconds of delay after get() where set() storms are a consideration with 'lockTSE' */
+ const SET_DELAY_HIGH_SEC = 0.1;
+
/** Cache format version number */
const VERSION = 1;
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:';
* is useful if thousands or millions of keys depend on the same entity. The entity can
* simply have its "check" key updated whenever the entity is modified.
* Default: [].
- * - graceTTL: If the key is invalidated (by "checkKeys") less than this many seconds ago,
- * consider reusing the stale value. The odds of a refresh becomes more likely over time,
- * becoming certain once the grace period is reached. This can reduce traffic spikes
- * when millions of keys are compared to the same "check" key and touchCheckKey() or
- * resetCheckKey() is called on that "check" key. This option is not useful for the
- * case of the key simply expiring on account of its TTL (use "lowTTL" instead).
+ * - graceTTL: If the key is invalidated (by "checkKeys"/"touchedCallback") less than this
+ * many seconds ago, consider reusing the stale value. The odds of a refresh becomes
+ * more likely over time, becoming certain once the grace period is reached. This can
+ * reduce traffic spikes when millions of keys are compared to the same "check" key and
+ * touchCheckKey() or resetCheckKey() is called on that "check" key. This option is not
+ * useful for avoiding traffic spikes in the case of the key simply expiring on account
+ * of its TTL (use "lowTTL" instead).
* Default: WANObjectCache::GRACE_TTL_NONE.
- * - lockTSE: If the key is tombstoned or invalidated (by "checkKeys") less than this many
- * seconds ago, try to have a single thread handle cache regeneration at any given time.
- * Other threads will try to use stale values if possible. If, on miss, the time since
- * expiration is low, the assumption is that the key is hot and that a stampede is worth
- * avoiding. Setting this above WANObjectCache::HOLDOFF_TTL makes no difference. The
- * higher this is set, the higher the worst-case staleness can be. This option does not
- * by itself handle the case of the key simply expiring on account of its TTL, so make
- * sure that "lowTTL" is not disabled when using this option.
+ * - lockTSE: If the key is tombstoned or invalidated (by "checkKeys"/"touchedCallback")
+ * less than this many seconds ago, try to have a single thread handle cache regeneration
+ * at any given time. Other threads will use stale values if possible. If, on miss,
+ * the time since expiration is low, the assumption is that the key is hot and that a
+ * stampede is worth avoiding. Note that if the key falls out of cache then concurrent
+ * threads will all run the callback on cache miss until the value is saved in cache.
+ * The only stampede protection in that case is from duplicate cache sets when the
+ * callback takes longer than WANObjectCache::SET_DELAY_HIGH_SEC seconds; consider
+ * using "busyValue" if such stampedes are a problem. Note that the higher "lockTSE" is
+ * set, the higher the worst-case staleness of returned values can be. Also note that
+ * this option does not by itself handle the case of the key simply expiring on account
+ * of its TTL, so make sure that "lowTTL" is not disabled when using this option. Avoid
+ * combining this option with delete() as it can always cause a stampede due to their
+ * being no stale value available until after a thread completes the callback.
* Use WANObjectCache::TSE_NONE to disable this logic.
* Default: WANObjectCache::TSE_NONE.
* - busyValue: If no value exists and another thread is currently regenerating it, use this
// This avoids stampedes on eviction or preemptive regeneration taking too long.
( $busyValue !== null && $value === false );
- $lockAcquired = false;
+ $hasLock = false;
if ( $useMutex ) {
// Acquire a datacenter-local non-blocking lock
if ( $this->cache->add( self::MUTEX_KEY_PREFIX . $key, 1, self::LOCK_TTL ) ) {
// Lock acquired; this thread will recompute the value and update cache
- $lockAcquired = true;
+ $hasLock = true;
} elseif ( $this->isValid( $value, $versioned, $asOf, $minTime ) ) {
// Lock not acquired and a stale value exists; use the stale value
$this->stats->increment( "wanobjectcache.$kClass.hit.stale" );
$valueIsCacheable = ( $value !== false && $ttl >= 0 );
if ( $valueIsCacheable ) {
+ $ago = max( $this->getCurrentTime() - $preCallbackTime, 0.0 );
if ( $isKeyTombstoned ) {
- // When delete() is called, writes are write-holed by the tombstone,
- // so use a special INTERIM key to pass the new value among threads.
- $tempTTL = max( self::INTERIM_KEY_TTL, (int)$lockTSE ); // set() expects seconds
- $newAsOf = $this->getCurrentTime();
- $wrapped = $this->wrap( $value, $tempTTL, $newAsOf );
- // Avoid using set() to avoid pointless mcrouter broadcasting
- $this->setInterimValue( $key, $wrapped, $tempTTL );
- } elseif ( !$useMutex || $lockAcquired ) {
- // Save the value unless a lock-winning thread is already expected to do that
- $setOpts['lockTSE'] = $lockTSE;
- $setOpts['staleTTL'] = $staleTTL;
- // Use best known "since" timestamp if not provided
- $setOpts += [ 'since' => $preCallbackTime ];
- // Update the cache; this will fail if the key is tombstoned
- $this->set( $key, $value, $ttl, $setOpts );
+ if ( $this->checkAndSetCooloff( $key, $kClass, $ago, $lockTSE, $hasLock ) ) {
+ // When delete() is called, writes are write-holed by the tombstone,
+ // so use a special INTERIM key to pass the new value among threads.
+ $tempTTL = max( self::INTERIM_KEY_TTL, (int)$lockTSE ); // set() expects seconds
+ $newAsOf = $this->getCurrentTime();
+ $wrapped = $this->wrap( $value, $tempTTL, $newAsOf );
+ // Avoid using set() to avoid pointless mcrouter broadcasting
+ $this->setInterimValue( $key, $wrapped, $tempTTL );
+ }
+ } elseif ( !$useMutex || $hasLock ) {
+ if ( $this->checkAndSetCooloff( $key, $kClass, $ago, $lockTSE, $hasLock ) ) {
+ // Save the value unless a lock-winning thread is already expected to do that
+ $setOpts['lockTSE'] = $lockTSE;
+ $setOpts['staleTTL'] = $staleTTL;
+ // Use best known "since" timestamp if not provided
+ $setOpts += [ 'since' => $preCallbackTime ];
+ // Update the cache; this will fail if the key is tombstoned
+ $this->set( $key, $value, $ttl, $setOpts );
+ }
}
}
- if ( $lockAcquired ) {
+ if ( $hasLock ) {
// Avoid using delete() to avoid pointless mcrouter broadcasting
$this->cache->changeTTL( self::MUTEX_KEY_PREFIX . $key, (int)$preCallbackTime - 60 );
}
return $value;
}
+ /**
+ * @param string $key
+ * @param string $kClass
+ * @param float $elapsed Seconds spent regenerating the value
+ * @param float $lockTSE
+ * @param $hasLock bool
+ * @return bool Whether it is OK to proceed with a key set operation
+ */
+ private function checkAndSetCooloff( $key, $kClass, $elapsed, $lockTSE, $hasLock ) {
+ // If $lockTSE is set, the lock was bypassed because there was no stale/interim value,
+ // and $elapsed indicates that regeration is slow, then there is a risk of set()
+ // stampedes with large blobs. With a typical scale-out infrastructure, CPU and query
+ // load from $callback invocations is distributed among appservers and replica DBs,
+ // but cache operations for a given key route to a single cache server (e.g. striped
+ // consistent hashing).
+ if ( $lockTSE < 0 || $hasLock ) {
+ return true; // either not a priori hot or thread has the lock
+ } elseif ( $elapsed <= self::SET_DELAY_HIGH_SEC ) {
+ 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 ) &&
+ // Don't treat failures due to I/O errors as the key being in cooloff
+ $this->cache->getLastError() === BagOStuff::ERR_NONE
+ ) {
+ $this->stats->increment( "wanobjectcache.$kClass.cooloff_bounce" );
+
+ return false;
+ }
+
+ return true;
+ }
+
/**
* @param mixed $value
* @param float $asOf
// Add the QUnit testrunner as implicit dependency to extension test suites.
foreach ( $testModules['qunit'] as &$module ) {
+ // Shuck any single-module dependency as an array
+ if ( is_string( $module['dependencies'] ) ) {
+ $module['dependencies'] = [ $module['dependencies'] ];
+ }
+
$module['dependencies'][] = 'test.mediawiki.qunit.testrunner';
}
use MediaWiki\MediaWikiServices;
use Wikimedia\Rdbms\IDatabase;
+use Wikimedia\Rdbms\IMaintainableDatabase;
require_once __DIR__ . '/../Maintenance.php';
<?php
use MediaWiki\MediaWikiServices;
+use Wikimedia\Rdbms\IMaintainableDatabase;
use Wikimedia\ScopedCallback;
use Wikimedia\TestingAccessWrapper;
], $user ) );
}
+ public function testAddBlockInfoToStatus() {
+ $mock = new MockApi();
+
+ // Sanity check empty array
+ $expect = Status::newGood();
+ $test = Status::newGood();
+ $mock->addBlockInfoToStatus( $test );
+ $this->assertEquals( $expect, $test );
+
+ // No blocked $user, so no special block handling
+ $expect = Status::newGood();
+ $expect->fatal( 'blockedtext' );
+ $expect->fatal( 'autoblockedtext' );
+ $expect->fatal( 'systemblockedtext' );
+ $expect->fatal( 'mainpage' );
+ $expect->fatal( 'parentheses', 'foobar' );
+ $test = clone $expect;
+ $mock->addBlockInfoToStatus( $test );
+ $this->assertEquals( $expect, $test );
+
+ // Has a blocked $user, so special block handling
+ $user = $this->getMutableTestUser()->getUser();
+ $block = new \Block( [
+ 'address' => $user->getName(),
+ 'user' => $user->getID(),
+ 'by' => $this->getTestSysop()->getUser()->getId(),
+ 'reason' => __METHOD__,
+ 'expiry' => time() + 100500,
+ ] );
+ $block->insert();
+ $blockinfo = [ 'blockinfo' => ApiQueryUserInfo::getBlockInfo( $block ) ];
+
+ $expect = Status::newGood();
+ $expect->fatal( ApiMessage::create( 'apierror-blocked', 'blocked', $blockinfo ) );
+ $expect->fatal( ApiMessage::create( 'apierror-autoblocked', 'autoblocked', $blockinfo ) );
+ $expect->fatal( ApiMessage::create( 'apierror-systemblocked', 'blocked', $blockinfo ) );
+ $expect->fatal( 'mainpage' );
+ $expect->fatal( 'parentheses', 'foobar' );
+ $test = Status::newGood();
+ $test->fatal( 'blockedtext' );
+ $test->fatal( 'autoblockedtext' );
+ $test->fatal( 'systemblockedtext' );
+ $test->fatal( 'mainpage' );
+ $test->fatal( 'parentheses', 'foobar' );
+ $mock->addBlockInfoToStatus( $test, $user );
+ $this->assertEquals( $expect, $test );
+ }
+
public function testDieStatus() {
$mock = new MockApi();
public function testEditWhileBlocked() {
$name = 'Help:' . ucfirst( __FUNCTION__ );
- $this->setExpectedException( ApiUsageException::class,
- 'You have been blocked from editing.' );
+ $this->assertNull( Block::newFromTarget( '127.0.0.1' ), 'Sanity check' );
$block = new Block( [
'address' => self::$users['sysop']->getUser()->getName(),
'reason' => 'Capriciousness',
'timestamp' => '19370101000000',
'expiry' => 'infinity',
+ 'enableAutoblock' => true,
] );
$block->insert();
'title' => $name,
'text' => 'Some text',
] );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( ApiUsageException $ex ) {
+ $this->assertSame( 'You have been blocked from editing.', $ex->getMessage() );
+ $this->assertNotNull( Block::newFromTarget( '127.0.0.1' ), 'Autoblock spread' );
} finally {
$block->delete();
self::$users['sysop']->getUser()->clearInstanceCache();
}
}
+ public function testMoveWhileBlocked() {
+ $this->assertNull( Block::newFromTarget( '127.0.0.1' ), 'Sanity check' );
+
+ $block = new Block( [
+ 'address' => self::$users['sysop']->getUser()->getName(),
+ 'by' => self::$users['sysop']->getUser()->getId(),
+ 'reason' => 'Capriciousness',
+ 'timestamp' => '19370101000000',
+ 'expiry' => 'infinity',
+ 'enableAutoblock' => true,
+ ] );
+ $block->insert();
+
+ $name = ucfirst( __FUNCTION__ );
+ $id = $this->createPage( $name );
+
+ try {
+ $this->doApiRequestWithToken( [
+ 'action' => 'move',
+ 'from' => $name,
+ 'to' => "$name 2",
+ ] );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( ApiUsageException $ex ) {
+ $this->assertSame( 'You have been blocked from editing.', $ex->getMessage() );
+ $this->assertNotNull( Block::newFromTarget( '127.0.0.1' ), 'Autoblock spread' );
+ } finally {
+ $block->delete();
+ self::$users['sysop']->getUser()->clearInstanceCache();
+ $this->assertSame( $id, Title::newFromText( $name )->getArticleID() );
+ }
+ }
+
// @todo File moving
public function testPingLimiter() {