}
}
+ MWExceptionHandler::handleException( $e );
+ } catch ( Error $e ) {
+ // Type errors and such: at least handle it now and clean up the LBFactory state
MWExceptionHandler::handleException( $e );
}
$store = new WatchedItemStore(
$services->getDBLoadBalancer(),
new HashBagOStuff( [ 'maxKeys' => 100 ] ),
- $services->getReadOnlyMode()
+ $services->getReadOnlyMode(),
+ $services->getMainConfig()->get( 'UpdateRowsPerQuery' )
);
$store->setStatsdDataFactory( $services->getStatsdDataFactory() );
/** @var bool Whether to disregard replica DB lag as a factor in replica DB selection */
private $mAllowLagged;
/** @var int Seconds to spend waiting on replica DB lag to resolve */
- private $mWaitTimeout;
+ private $waitTimeout;
/** @var array The LoadMonitor configuration */
private $loadMonitorConfig;
/** @var array[] $aliases Map of (table => (dbname, schema, prefix) map) */
: DatabaseDomain::newUnspecified();
$this->setLocalDomain( $localDomain );
- $this->mWaitTimeout = isset( $params['waitTimeout'] )
+ $this->waitTimeout = isset( $params['waitTimeout'] )
? $params['waitTimeout']
: self::MAX_WAIT_DEFAULT;
}
public function waitForAll( $pos, $timeout = null ) {
- $timeout = $timeout ?: $this->mWaitTimeout;
+ $timeout = $timeout ?: $this->waitTimeout;
$oldPos = $this->mWaitForPos;
try {
* Wait for a given replica DB to catch up to the master pos stored in $this
* @param int $index Server index
* @param bool $open Check the server even if a new connection has to be made
- * @param int $timeout Max seconds to wait; default is mWaitTimeout
+ * @param int $timeout Max seconds to wait; default is "waitTimeout" given to __construct()
* @return bool
*/
protected function doWait( $index, $open = false, $timeout = null ) {
- $timeout = max( 1, $timeout ?: $this->mWaitTimeout );
+ $timeout = max( 1, $timeout ?: $this->waitTimeout );
// Check if we already know that the DB has reached this point
$server = $this->getServerName( $index );
$this->trxRoundId = false;
$this->forEachOpenMasterConnection(
function ( IDatabase $conn ) use ( $fname, $restore ) {
- if ( $conn->writesOrCallbacksPending() ) {
+ if ( $conn->writesOrCallbacksPending() || $conn->explicitTrxActive() ) {
$conn->rollback( $fname, $conn::FLUSHING_ALL_PEERS );
}
if ( $restore ) {
}
public function hasOrMadeRecentMasterChanges( $age = null ) {
- $age = ( $age === null ) ? $this->mWaitTimeout : $age;
+ $age = ( $age === null ) ? $this->waitTimeout : $age;
return ( $this->hasMasterChanges()
|| $this->lastMasterChangeTimestamp() > microtime( true ) - $age );
/**
* @param IDatabase $conn
* @param DBMasterPos|bool $pos
- * @param int $timeout
+ * @param int|null $timeout
* @return bool
*/
- public function safeWaitForMasterPos( IDatabase $conn, $pos = false, $timeout = 10 ) {
+ public function safeWaitForMasterPos( IDatabase $conn, $pos = false, $timeout = null ) {
+ $timeout = max( 1, $timeout ?: $this->waitTimeout );
+
if ( $this->getServerCount() <= 1 || !$conn->getLBInfo( 'replica' ) ) {
return true; // server is not a replica DB
}
] );
$startLen = strlen( $start );
$end = Html::closeElement( 'div' );
+ $endPos = strrpos( $text, $end );
$endLen = strlen( $end );
- if ( substr( $text, 0, $startLen ) === $start && substr( $text, -$endLen ) === $end ) {
- $text = substr( $text, $startLen, -$endLen );
+ if ( substr( $text, 0, $startLen ) === $start && $endPos !== false
+ // if the closing div is followed by real content, bail out of unwrapping
+ && preg_match( '/^(?>\s*<!--.*?-->)*\s*$/s', substr( $text, $endPos + $endLen ) )
+ ) {
+ $text = substr( $text, $startLen );
+ $text = substr( $text, 0, $endPos - $startLen )
+ . substr( $text, $endPos - $startLen + $endLen );
}
}
use MediaWiki\Linker\LinkRenderer;
use MediaWiki\Linker\LinkTarget;
use MediaWiki\MediaWikiServices;
-use Wikimedia\Rdbms\DBReadOnlyError;
/**
* Provides the UI through which users can perform editing
$this->showTitles( $toUnwatch, $this->successMessage );
}
} else {
- $this->clearWatchlist();
- $this->getUser()->invalidateCache();
- if ( count( $current ) > 0 ) {
- $this->successMessage = $this->msg( 'watchlistedit-raw-done' )->parse();
- } else {
+ if ( count( $current ) === 0 ) {
return false;
}
- $this->successMessage .= ' ' . $this->msg( 'watchlistedit-raw-removed' )
- ->numParams( count( $current ) )->parse();
+ $this->clearUserWatchedItems( $current, 'raw' );
$this->showTitles( $current, $this->successMessage );
}
public function submitClear( $data ) {
$current = $this->getWatchlist();
- $this->clearWatchlist();
- $this->getUser()->invalidateCache();
- $this->successMessage = $this->msg( 'watchlistedit-clear-done' )->parse();
- $this->successMessage .= ' ' . $this->msg( 'watchlistedit-clear-removed' )
- ->numParams( count( $current ) )->parse();
+ $this->clearUserWatchedItems( $current, 'clear' );
$this->showTitles( $current, $this->successMessage );
-
return true;
}
+ /**
+ * @param array $current
+ * @param string $messageFor 'raw' or 'clear'
+ */
+ private function clearUserWatchedItems( $current, $messageFor ) {
+ $watchedItemStore = MediaWikiServices::getInstance()->getWatchedItemStore();
+ if ( $watchedItemStore->clearUserWatchedItems( $this->getUser() ) ) {
+ $this->successMessage = $this->msg( 'watchlistedit-' . $messageFor . '-done' )->parse();
+ $this->successMessage .= ' ' . $this->msg( 'watchlistedit-' . $messageFor . '-removed' )
+ ->numParams( count( $current ) )->parse();
+ $this->getUser()->invalidateCache();
+ } else {
+ $watchedItemStore->clearUserWatchedItemsUsingJobQueue( $this->getUser() );
+ $this->successMessage = $this->msg( 'watchlistedit-clear-jobqueue' )->parse();
+ }
+ }
+
/**
* Print out a list of linked titles
*
} );
}
- /**
- * Remove all titles from a user's watchlist
- */
- private function clearWatchlist() {
- if ( $this->getConfig()->get( 'ReadOnlyWatchedItemStore' ) ) {
- throw new DBReadOnlyError( null, 'The watchlist is currently readonly.' );
- }
-
- $dbw = wfGetDB( DB_MASTER );
- $dbw->delete(
- 'watchlist',
- [ 'wl_user' => $this->getUser()->getId() ],
- __METHOD__
- );
- }
-
/**
* Add a list of targets to a user's watchlist
*
if ( $mode === 'refresh' ) {
$cache->delete( $key, 1 );
} else {
- wfGetDB( DB_MASTER )->onTransactionPreCommitOrIdle(
- function () use ( $cache, $key ) {
- $cache->delete( $key );
- },
- __METHOD__
- );
+ $lb = MediaWikiServices::getInstance()->getDBLoadBalancer();
+ if ( $lb->hasOrMadeRecentMasterChanges() ) {
+ $lb->getConnection( DB_MASTER )->onTransactionPreCommitOrIdle(
+ function () use ( $cache, $key ) {
+ $cache->delete( $key );
+ },
+ __METHOD__
+ );
+ } else {
+ $cache->delete( $key );
+ }
}
}
* Database interaction & caching
* TODO caching should be factored out into a CachingWatchedItemStore class
*
- * Uses database because this uses User::isAnon
- *
- * @group Database
- *
* @author Addshore
* @since 1.27
*/
*/
private $revisionGetTimestampFromIdCallback;
+ /**
+ * @var int
+ */
+ private $updateRowsPerQuery;
+
/**
* @var StatsdDataFactoryInterface
*/
* @param LoadBalancer $loadBalancer
* @param HashBagOStuff $cache
* @param ReadOnlyMode $readOnlyMode
+ * @param int $updateRowsPerQuery
*/
public function __construct(
LoadBalancer $loadBalancer,
HashBagOStuff $cache,
- ReadOnlyMode $readOnlyMode
+ ReadOnlyMode $readOnlyMode,
+ $updateRowsPerQuery
) {
$this->loadBalancer = $loadBalancer;
$this->cache = $cache;
$this->readOnlyMode = $readOnlyMode;
$this->stats = new NullStatsdDataFactory();
- $this->deferredUpdatesAddCallableUpdateCallback = [ DeferredUpdates::class, 'addCallableUpdate' ];
- $this->revisionGetTimestampFromIdCallback = [ Revision::class, 'getTimestampFromId' ];
+ $this->deferredUpdatesAddCallableUpdateCallback =
+ [ DeferredUpdates::class, 'addCallableUpdate' ];
+ $this->revisionGetTimestampFromIdCallback =
+ [ Revision::class, 'getTimestampFromId' ];
+ $this->updateRowsPerQuery = $updateRowsPerQuery;
}
/**
return $this->loadBalancer->getConnectionRef( $dbIndex, [ 'watchlist' ] );
}
+ /**
+ * Deletes ALL watched items for the given user when under
+ * $updateRowsPerQuery entries exist.
+ *
+ * @since 1.30
+ *
+ * @param User $user
+ *
+ * @return bool true on success, false when too many items are watched
+ */
+ public function clearUserWatchedItems( User $user ) {
+ if ( $this->countWatchedItems( $user ) > $this->updateRowsPerQuery ) {
+ return false;
+ }
+
+ $dbw = $this->loadBalancer->getConnectionRef( DB_MASTER );
+ $dbw->delete(
+ 'watchlist',
+ [ 'wl_user' => $user->getId() ],
+ __METHOD__
+ );
+ $this->uncacheAllItemsForUser( $user );
+
+ return true;
+ }
+
+ private function uncacheAllItemsForUser( User $user ) {
+ $userId = $user->getId();
+ foreach ( $this->cacheIndex as $ns => $dbKeyIndex ) {
+ foreach ( $dbKeyIndex as $dbKey => $userIndex ) {
+ if ( array_key_exists( $userId, $userIndex ) ) {
+ $this->cache->delete( $userIndex[$userId] );
+ unset( $this->cacheIndex[$ns][$dbKey][$userId] );
+ }
+ }
+ }
+
+ // Cleanup empty cache keys
+ foreach ( $this->cacheIndex as $ns => $dbKeyIndex ) {
+ foreach ( $dbKeyIndex as $dbKey => $userIndex ) {
+ if ( empty( $this->cacheIndex[$ns][$dbKey] ) ) {
+ unset( $this->cacheIndex[$ns][$dbKey] );
+ }
+ }
+ if ( empty( $this->cacheIndex[$ns] ) ) {
+ unset( $this->cacheIndex[$ns] );
+ }
+ }
+ }
+
/**
* Queues a job that will clear the users watchlist using the Job Queue.
*
"watchlistedit-clear-titles": "Titles:",
"watchlistedit-clear-submit": "Clear the watchlist (This is permanent!)",
"watchlistedit-clear-done": "Your watchlist has been cleared.",
+ "watchlistedit-clear-jobqueue": "Your watchlist is being cleared. This may take some time!",
"watchlistedit-clear-removed": "{{PLURAL:$1|1 title was|$1 titles were}} removed:",
"watchlistedit-too-many": "There are too many pages to display here.",
"watchlisttools-clear": "Clear the watchlist",
"watchlistedit-clear-titles": "Text above edit box containing items being watched on [[Special:Watchlist/clear]].\n{{Identical|Title}}",
"watchlistedit-clear-submit": "Text of submit button on [[Special:Watchlist/clear]].\n{{Identical|Clear watchlist}}",
"watchlistedit-clear-done": "A message which appears after the watchlist has been cleared using [[Special:Watchlist/clear]].",
+ "watchlistedit-clear-jobqueue": "A message which appears after the watchlist has been scheduled to be cleared using [[Special:Watchlist/clear]] and the Job Queue.",
"watchlistedit-clear-removed": "Message on [[Special:EditWatchlist/clear]].\n\nThe message appears once the watchlist has been cleared.",
"watchlistedit-too-many": "Message on [[Special:EditWatchlist]] that is used when there are too many titles to display.\n\nShown instead of list of the pages.",
"watchlisttools-clear": "[[Special:Watchlist]]: Navigation link under the title.\n{{Identical|Clear watchlist}}",
'Unwrap without a mw-parser-output wrapper' => [
[ 'unwrap' => true ], [], '<div class="foobar">Content</div>', '<div class="foobar">Content</div>'
],
+ 'Unwrap with extra comment at end' => [
+ [ 'unwrap' => true ], [], '<div class="mw-parser-output"><p>Test document.</p></div>
+<!-- Saved in parser cache... -->', '<p>Test document.</p>
+<!-- Saved in parser cache... -->'
+ ],
];
// phpcs:enable
}
);
}
+ public function testWatchBatchAndClearItems() {
+ $user = $this->getUser();
+ $title1 = Title::newFromText( 'WatchedItemStoreIntegrationTestPage1' );
+ $title2 = Title::newFromText( 'WatchedItemStoreIntegrationTestPage2' );
+ $store = MediaWikiServices::getInstance()->getWatchedItemStore();
+
+ $store->addWatchBatchForUser( $user, [ $title1, $title2 ] );
+
+ $this->assertTrue( $store->isWatched( $user, $title1 ) );
+ $this->assertTrue( $store->isWatched( $user, $title2 ) );
+
+ $store->clearUserWatchedItems( $user );
+
+ $this->assertFalse( $store->isWatched( $user, $title1 ) );
+ $this->assertFalse( $store->isWatched( $user, $title2 ) );
+ }
+
public function testUpdateResetAndSetNotificationTimestamp() {
$user = $this->getUser();
$otherUser = ( new TestUser( 'WatchedItemStoreIntegrationTestUser_otherUser' ) )->getUser();
use MediaWiki\Linker\LinkTarget;
use Wikimedia\Rdbms\LoadBalancer;
use Wikimedia\ScopedCallback;
+use Wikimedia\TestingAccessWrapper;
/**
* @author Addshore
return new WatchedItemStore(
$loadBalancer,
$cache,
- $readOnlyMode
+ $readOnlyMode,
+ 1000
);
}
+ public function testClearWatchedItems() {
+ $user = $this->getMockNonAnonUserWithId( 7 );
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'selectField' )
+ ->with(
+ 'watchlist',
+ 'COUNT(*)',
+ [
+ 'wl_user' => $user->getId(),
+ ],
+ $this->isType( 'string' )
+ )
+ ->will( $this->returnValue( 12 ) );
+ $mockDb->expects( $this->once() )
+ ->method( 'delete' )
+ ->with(
+ 'watchlist',
+ [ 'wl_user' => 7 ],
+ $this->isType( 'string' )
+ );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->once() )
+ ->method( 'delete' )
+ ->with( 'RM-KEY' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+ TestingAccessWrapper::newFromObject( $store )
+ ->cacheIndex = [ 0 => [ 'F' => [ 7 => 'RM-KEY', 9 => 'KEEP-KEY' ] ] ];
+
+ $this->assertTrue( $store->clearUserWatchedItems( $user ) );
+ }
+
+ public function testClearWatchedItems_tooManyItemsWatched() {
+ $user = $this->getMockNonAnonUserWithId( 7 );
+
+ $mockDb = $this->getMockDb();
+ $mockDb->expects( $this->once() )
+ ->method( 'selectField' )
+ ->with(
+ 'watchlist',
+ 'COUNT(*)',
+ [
+ 'wl_user' => $user->getId(),
+ ],
+ $this->isType( 'string' )
+ )
+ ->will( $this->returnValue( 99999 ) );
+
+ $mockCache = $this->getMockCache();
+ $mockCache->expects( $this->never() )->method( 'get' );
+ $mockCache->expects( $this->never() )->method( 'set' );
+ $mockCache->expects( $this->never() )->method( 'delete' );
+
+ $store = $this->newWatchedItemStore(
+ $this->getMockLoadBalancer( $mockDb ),
+ $mockCache,
+ $this->getMockReadOnlyMode()
+ );
+
+ $this->assertFalse( $store->clearUserWatchedItems( $user ) );
+ }
+
public function testCountWatchedItems() {
$user = $this->getMockNonAnonUserWithId( 1 );