This touches various production classes and maintenance scripts. The code should do the exact same as before. The main benefit is that the syntax avoids any repetition. Change-Id: I5c552125469f4d7fb5b0fe494d198951b05eb35f
1863 lines
54 KiB
PHP
1863 lines
54 KiB
PHP
<?php
|
|
|
|
namespace MediaWiki\Watchlist;
|
|
|
|
use DateInterval;
|
|
use JobQueueGroup;
|
|
use LogicException;
|
|
use MapCacheLRU;
|
|
use MediaWiki\Cache\LinkBatchFactory;
|
|
use MediaWiki\Config\ServiceOptions;
|
|
use MediaWiki\Deferred\DeferredUpdates;
|
|
use MediaWiki\Linker\LinkTarget;
|
|
use MediaWiki\MainConfigNames;
|
|
use MediaWiki\Page\PageIdentity;
|
|
use MediaWiki\Revision\RevisionLookup;
|
|
use MediaWiki\Title\NamespaceInfo;
|
|
use MediaWiki\Title\TitleValue;
|
|
use MediaWiki\User\UserIdentity;
|
|
use MediaWiki\Utils\MWTimestamp;
|
|
use stdClass;
|
|
use Wikimedia\Assert\Assert;
|
|
use Wikimedia\ObjectCache\BagOStuff;
|
|
use Wikimedia\ObjectCache\HashBagOStuff;
|
|
use Wikimedia\ParamValidator\TypeDef\ExpiryDef;
|
|
use Wikimedia\Rdbms\IDatabase;
|
|
use Wikimedia\Rdbms\ILBFactory;
|
|
use Wikimedia\Rdbms\IReadableDatabase;
|
|
use Wikimedia\Rdbms\IResultWrapper;
|
|
use Wikimedia\Rdbms\ReadOnlyMode;
|
|
use Wikimedia\Rdbms\SelectQueryBuilder;
|
|
use Wikimedia\ScopedCallback;
|
|
use Wikimedia\Stats\StatsFactory;
|
|
|
|
/**
|
|
* Storage layer class for WatchedItems.
|
|
* Database interaction & caching
|
|
* TODO caching should be factored out into a CachingWatchedItemStore class
|
|
*
|
|
* @author Addshore
|
|
* @since 1.27
|
|
*/
|
|
class WatchedItemStore implements WatchedItemStoreInterface {
|
|
|
|
/**
|
|
* @internal For use by ServiceWiring
|
|
*/
|
|
public const CONSTRUCTOR_OPTIONS = [
|
|
MainConfigNames::UpdateRowsPerQuery,
|
|
MainConfigNames::WatchlistExpiry,
|
|
MainConfigNames::WatchlistExpiryMaxDuration,
|
|
MainConfigNames::WatchlistPurgeRate,
|
|
];
|
|
|
|
/**
|
|
* @var ILBFactory
|
|
*/
|
|
private $lbFactory;
|
|
|
|
/**
|
|
* @var JobQueueGroup
|
|
*/
|
|
private $queueGroup;
|
|
|
|
/**
|
|
* @var BagOStuff
|
|
*/
|
|
private $stash;
|
|
|
|
/**
|
|
* @var ReadOnlyMode
|
|
*/
|
|
private $readOnlyMode;
|
|
|
|
/**
|
|
* @var HashBagOStuff
|
|
*/
|
|
private $cache;
|
|
|
|
/**
|
|
* @var HashBagOStuff
|
|
*/
|
|
private $latestUpdateCache;
|
|
|
|
/**
|
|
* @var array[][] Looks like $cacheIndex[Namespace ID][Target DB Key][User Id] => 'key'
|
|
* The index is needed so that on mass changes all relevant items can be un-cached.
|
|
* For example: Clearing a users watchlist of all items or updating notification timestamps
|
|
* for all users watching a single target.
|
|
* @phan-var array<int,array<string,array<int,string>>>
|
|
*/
|
|
private $cacheIndex = [];
|
|
|
|
/**
|
|
* @var callable|null
|
|
*/
|
|
private $deferredUpdatesAddCallableUpdateCallback;
|
|
|
|
/**
|
|
* @var int
|
|
*/
|
|
private $updateRowsPerQuery;
|
|
|
|
/**
|
|
* @var NamespaceInfo
|
|
*/
|
|
private $nsInfo;
|
|
|
|
/**
|
|
* @var RevisionLookup
|
|
*/
|
|
private $revisionLookup;
|
|
|
|
/**
|
|
* @var bool Correlates to $wgWatchlistExpiry feature flag.
|
|
*/
|
|
private $expiryEnabled;
|
|
|
|
/**
|
|
* @var LinkBatchFactory
|
|
*/
|
|
private $linkBatchFactory;
|
|
|
|
/** @var StatsFactory */
|
|
private $statsFactory;
|
|
|
|
/**
|
|
* @var string|null Maximum configured relative expiry.
|
|
*/
|
|
private $maxExpiryDuration;
|
|
|
|
/** @var float corresponds to $wgWatchlistPurgeRate value */
|
|
private $watchlistPurgeRate;
|
|
|
|
/**
|
|
* @param ServiceOptions $options
|
|
* @param ILBFactory $lbFactory
|
|
* @param JobQueueGroup $queueGroup
|
|
* @param BagOStuff $stash
|
|
* @param HashBagOStuff $cache
|
|
* @param ReadOnlyMode $readOnlyMode
|
|
* @param NamespaceInfo $nsInfo
|
|
* @param RevisionLookup $revisionLookup
|
|
* @param LinkBatchFactory $linkBatchFactory
|
|
* @param StatsFactory $statsFactory
|
|
*/
|
|
public function __construct(
|
|
ServiceOptions $options,
|
|
ILBFactory $lbFactory,
|
|
JobQueueGroup $queueGroup,
|
|
BagOStuff $stash,
|
|
HashBagOStuff $cache,
|
|
ReadOnlyMode $readOnlyMode,
|
|
NamespaceInfo $nsInfo,
|
|
RevisionLookup $revisionLookup,
|
|
LinkBatchFactory $linkBatchFactory,
|
|
StatsFactory $statsFactory
|
|
) {
|
|
$options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
|
|
$this->updateRowsPerQuery = $options->get( MainConfigNames::UpdateRowsPerQuery );
|
|
$this->expiryEnabled = $options->get( MainConfigNames::WatchlistExpiry );
|
|
$this->maxExpiryDuration = $options->get( MainConfigNames::WatchlistExpiryMaxDuration );
|
|
$this->watchlistPurgeRate = $options->get( MainConfigNames::WatchlistPurgeRate );
|
|
|
|
$this->lbFactory = $lbFactory;
|
|
$this->queueGroup = $queueGroup;
|
|
$this->stash = $stash;
|
|
$this->cache = $cache;
|
|
$this->readOnlyMode = $readOnlyMode;
|
|
$this->deferredUpdatesAddCallableUpdateCallback =
|
|
[ DeferredUpdates::class, 'addCallableUpdate' ];
|
|
$this->nsInfo = $nsInfo;
|
|
$this->revisionLookup = $revisionLookup;
|
|
$this->linkBatchFactory = $linkBatchFactory;
|
|
$this->statsFactory = $statsFactory;
|
|
|
|
$this->latestUpdateCache = new HashBagOStuff( [ 'maxKeys' => 3 ] );
|
|
}
|
|
|
|
/**
|
|
* Overrides the DeferredUpdates::addCallableUpdate callback
|
|
* This is intended for use while testing and will fail if MW_PHPUNIT_TEST is not defined.
|
|
*
|
|
* @param callable $callback
|
|
*
|
|
* @see DeferredUpdates::addCallableUpdate for callback signiture
|
|
*
|
|
* @return ScopedCallback to reset the overridden value
|
|
*/
|
|
public function overrideDeferredUpdatesAddCallableUpdateCallback( callable $callback ) {
|
|
if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
|
|
throw new LogicException(
|
|
'Cannot override DeferredUpdates::addCallableUpdate callback in operation.'
|
|
);
|
|
}
|
|
$previousValue = $this->deferredUpdatesAddCallableUpdateCallback;
|
|
$this->deferredUpdatesAddCallableUpdateCallback = $callback;
|
|
return new ScopedCallback( function () use ( $previousValue ) {
|
|
$this->deferredUpdatesAddCallableUpdateCallback = $previousValue;
|
|
} );
|
|
}
|
|
|
|
/**
|
|
* @param UserIdentity $user
|
|
* @param LinkTarget|PageIdentity $target
|
|
* @return string
|
|
*/
|
|
private function getCacheKey( UserIdentity $user, $target ): string {
|
|
return $this->cache->makeKey(
|
|
(string)$target->getNamespace(),
|
|
$target->getDBkey(),
|
|
(string)$user->getId()
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param WatchedItem $item
|
|
*/
|
|
private function cache( WatchedItem $item ) {
|
|
$user = $item->getUserIdentity();
|
|
$target = $item->getTarget();
|
|
$key = $this->getCacheKey( $user, $target );
|
|
$this->cache->set( $key, $item );
|
|
$this->cacheIndex[$target->getNamespace()][$target->getDBkey()][$user->getId()] = $key;
|
|
$this->statsFactory->getCounter( 'WatchedItemStore_cache_total' )
|
|
->copyToStatsdAt( 'WatchedItemStore.cache' )
|
|
->increment();
|
|
}
|
|
|
|
/**
|
|
* @param UserIdentity $user
|
|
* @param LinkTarget|PageIdentity $target
|
|
*/
|
|
private function uncache( UserIdentity $user, $target ) {
|
|
$this->cache->delete( $this->getCacheKey( $user, $target ) );
|
|
unset( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()][$user->getId()] );
|
|
$this->statsFactory->getCounter( 'WatchedItemStore_uncache_total' )
|
|
->copyToStatsdAt( 'WatchedItemStore.uncache' )
|
|
->increment();
|
|
}
|
|
|
|
/**
|
|
* @param LinkTarget|PageIdentity $target
|
|
*/
|
|
private function uncacheLinkTarget( $target ) {
|
|
$this->statsFactory->getCounter( 'WatchedItemStore_uncacheLinkTarget_total' )
|
|
->copyToStatsdAt( 'WatchedItemStore.uncacheLinkTarget' )
|
|
->increment();
|
|
if ( !isset( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()] ) ) {
|
|
return;
|
|
}
|
|
|
|
$uncacheLinkTargetItemsTotal = $this->statsFactory
|
|
->getCounter( 'WatchedItemStore_uncacheLinkTarget_items_total' )
|
|
->copyToStatsdAt( 'WatchedItemStore.uncacheLinkTarget.items' );
|
|
|
|
foreach ( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()] as $key ) {
|
|
$uncacheLinkTargetItemsTotal->increment();
|
|
$this->cache->delete( $key );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param UserIdentity $user
|
|
*/
|
|
private function uncacheUser( UserIdentity $user ) {
|
|
$this->statsFactory->getCounter( 'WatchedItemStore_uncacheUser_total' )
|
|
->copyToStatsdAt( 'WatchedItemStore.uncacheUser' )
|
|
->increment();
|
|
$uncacheUserItemsTotal = $this->statsFactory->getCounter( 'WatchedItemStore_uncacheUser_items_total' )
|
|
->copyToStatsdAt( 'WatchedItemStore.uncacheUser.items' );
|
|
|
|
foreach ( $this->cacheIndex as $dbKeyArray ) {
|
|
foreach ( $dbKeyArray as $userArray ) {
|
|
if ( isset( $userArray[$user->getId()] ) ) {
|
|
$uncacheUserItemsTotal->increment();
|
|
$this->cache->delete( $userArray[$user->getId()] );
|
|
}
|
|
}
|
|
}
|
|
|
|
$pageSeenKey = $this->getPageSeenTimestampsKey( $user );
|
|
$this->latestUpdateCache->delete( $pageSeenKey );
|
|
$this->stash->delete( $pageSeenKey );
|
|
}
|
|
|
|
/**
|
|
* @param UserIdentity $user
|
|
* @param LinkTarget|PageIdentity $target
|
|
*
|
|
* @return WatchedItem|false
|
|
*/
|
|
private function getCached( UserIdentity $user, $target ) {
|
|
return $this->cache->get( $this->getCacheKey( $user, $target ) );
|
|
}
|
|
|
|
/**
|
|
* Helper method to deduplicate logic around queries that need to be modified
|
|
* if watchlist expiration is enabled
|
|
*
|
|
* @param SelectQueryBuilder $queryBuilder
|
|
* @param IReadableDatabase $db
|
|
*/
|
|
private function modifyQueryBuilderForExpiry(
|
|
SelectQueryBuilder $queryBuilder,
|
|
IReadableDatabase $db
|
|
) {
|
|
if ( $this->expiryEnabled ) {
|
|
$queryBuilder->where( $db->expr( 'we_expiry', '=', null )->or( 'we_expiry', '>', $db->timestamp() ) );
|
|
$queryBuilder->leftJoin( 'watchlist_expiry', null, 'wl_id = we_item' );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Deletes ALL watched items for the given user when under
|
|
* $updateRowsPerQuery entries exist.
|
|
*
|
|
* @since 1.30
|
|
*
|
|
* @param UserIdentity $user
|
|
*
|
|
* @return bool true on success, false when too many items are watched
|
|
*/
|
|
public function clearUserWatchedItems( UserIdentity $user ): bool {
|
|
if ( $this->mustClearWatchedItemsUsingJobQueue( $user ) ) {
|
|
return false;
|
|
}
|
|
|
|
$dbw = $this->lbFactory->getPrimaryDatabase();
|
|
|
|
if ( $this->expiryEnabled ) {
|
|
$ticket = $this->lbFactory->getEmptyTransactionTicket( __METHOD__ );
|
|
// First fetch the wl_ids.
|
|
$wlIds = $dbw->newSelectQueryBuilder()
|
|
->select( 'wl_id' )
|
|
->from( 'watchlist' )
|
|
->where( [ 'wl_user' => $user->getId() ] )
|
|
->caller( __METHOD__ )
|
|
->fetchFieldValues();
|
|
if ( $wlIds ) {
|
|
// Delete rows from both the watchlist and watchlist_expiry tables.
|
|
$dbw->newDeleteQueryBuilder()
|
|
->deleteFrom( 'watchlist' )
|
|
->where( [ 'wl_id' => $wlIds ] )
|
|
->caller( __METHOD__ )->execute();
|
|
|
|
$dbw->newDeleteQueryBuilder()
|
|
->deleteFrom( 'watchlist_expiry' )
|
|
->where( [ 'we_item' => $wlIds ] )
|
|
->caller( __METHOD__ )->execute();
|
|
}
|
|
$this->lbFactory->commitAndWaitForReplication( __METHOD__, $ticket );
|
|
} else {
|
|
$dbw->newDeleteQueryBuilder()
|
|
->deleteFrom( 'watchlist' )
|
|
->where( [ 'wl_user' => $user->getId() ] )
|
|
->caller( __METHOD__ )->execute();
|
|
}
|
|
|
|
$this->uncacheAllItemsForUser( $user );
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* @param UserIdentity $user
|
|
* @return bool
|
|
*/
|
|
public function mustClearWatchedItemsUsingJobQueue( UserIdentity $user ): bool {
|
|
return $this->countWatchedItems( $user ) > $this->updateRowsPerQuery;
|
|
}
|
|
|
|
/**
|
|
* @param UserIdentity $user
|
|
*/
|
|
private function uncacheAllItemsForUser( UserIdentity $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.
|
|
*
|
|
* @since 1.31
|
|
*
|
|
* @param UserIdentity $user
|
|
*/
|
|
public function clearUserWatchedItemsUsingJobQueue( UserIdentity $user ) {
|
|
$job = ClearUserWatchlistJob::newForUser( $user, $this->getMaxId() );
|
|
$this->queueGroup->push( $job );
|
|
}
|
|
|
|
/**
|
|
* @inheritDoc
|
|
*/
|
|
public function maybeEnqueueWatchlistExpiryJob(): void {
|
|
if ( !$this->expiryEnabled ) {
|
|
// No need to purge expired entries if there are none
|
|
return;
|
|
}
|
|
|
|
$max = mt_getrandmax();
|
|
if ( mt_rand( 0, $max ) < $max * $this->watchlistPurgeRate ) {
|
|
// The higher the watchlist purge rate, the more likely we are to enqueue a job.
|
|
$this->queueGroup->lazyPush( new WatchlistExpiryJob() );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @since 1.31
|
|
* @return int The maximum current wl_id
|
|
*/
|
|
public function getMaxId(): int {
|
|
return (int)$this->lbFactory->getReplicaDatabase()->newSelectQueryBuilder()
|
|
->select( 'MAX(wl_id)' )
|
|
->from( 'watchlist' )
|
|
->caller( __METHOD__ )
|
|
->fetchField();
|
|
}
|
|
|
|
/**
|
|
* @since 1.31
|
|
* @param UserIdentity $user
|
|
* @return int
|
|
*/
|
|
public function countWatchedItems( UserIdentity $user ): int {
|
|
$dbr = $this->lbFactory->getReplicaDatabase();
|
|
$queryBuilder = $dbr->newSelectQueryBuilder()
|
|
->select( 'COUNT(*)' )
|
|
->from( 'watchlist' )
|
|
->where( [ 'wl_user' => $user->getId() ] )
|
|
->caller( __METHOD__ );
|
|
|
|
$this->modifyQueryBuilderForExpiry( $queryBuilder, $dbr );
|
|
|
|
return (int)$queryBuilder->fetchField();
|
|
}
|
|
|
|
/**
|
|
* @since 1.27
|
|
* @param LinkTarget|PageIdentity $target deprecated passing LinkTarget since 1.36
|
|
* @return int
|
|
*/
|
|
public function countWatchers( $target ): int {
|
|
$dbr = $this->lbFactory->getReplicaDatabase();
|
|
$queryBuilder = $dbr->newSelectQueryBuilder()
|
|
->select( 'COUNT(*)' )
|
|
->from( 'watchlist' )
|
|
->where( [
|
|
'wl_namespace' => $target->getNamespace(),
|
|
'wl_title' => $target->getDBkey()
|
|
] )
|
|
->caller( __METHOD__ );
|
|
|
|
$this->modifyQueryBuilderForExpiry( $queryBuilder, $dbr );
|
|
|
|
return (int)$queryBuilder->fetchField();
|
|
}
|
|
|
|
/**
|
|
* @since 1.27
|
|
* @param LinkTarget|PageIdentity $target deprecated passing LinkTarget since 1.36
|
|
* @param string|int $threshold
|
|
* @return int
|
|
*/
|
|
public function countVisitingWatchers( $target, $threshold ): int {
|
|
$dbr = $this->lbFactory->getReplicaDatabase();
|
|
$queryBuilder = $dbr->newSelectQueryBuilder()
|
|
->select( 'COUNT(*)' )
|
|
->from( 'watchlist' )
|
|
->where( [
|
|
'wl_namespace' => $target->getNamespace(),
|
|
'wl_title' => $target->getDBkey(),
|
|
$dbr->expr( 'wl_notificationtimestamp', '>=', $dbr->timestamp( $threshold ) )
|
|
->or( 'wl_notificationtimestamp', '=', null )
|
|
] )
|
|
->caller( __METHOD__ );
|
|
|
|
$this->modifyQueryBuilderForExpiry( $queryBuilder, $dbr );
|
|
|
|
return (int)$queryBuilder->fetchField();
|
|
}
|
|
|
|
/**
|
|
* @param UserIdentity $user
|
|
* @param LinkTarget[]|PageIdentity[] $titles deprecated passing LinkTarget[] since 1.36
|
|
* @return bool
|
|
*/
|
|
public function removeWatchBatchForUser( UserIdentity $user, array $titles ): bool {
|
|
if ( !$user->isRegistered() || $this->readOnlyMode->isReadOnly() ) {
|
|
return false;
|
|
}
|
|
if ( !$titles ) {
|
|
return true;
|
|
}
|
|
|
|
$rows = $this->getTitleDbKeysGroupedByNamespace( $titles );
|
|
$this->uncacheTitlesForUser( $user, $titles );
|
|
|
|
$dbw = $this->lbFactory->getPrimaryDatabase();
|
|
$ticket = count( $titles ) > $this->updateRowsPerQuery ?
|
|
$this->lbFactory->getEmptyTransactionTicket( __METHOD__ ) : null;
|
|
$affectedRows = 0;
|
|
|
|
// Batch delete items per namespace.
|
|
foreach ( $rows as $namespace => $namespaceTitles ) {
|
|
$rowBatches = array_chunk( $namespaceTitles, $this->updateRowsPerQuery );
|
|
foreach ( $rowBatches as $toDelete ) {
|
|
// First fetch the wl_ids.
|
|
$wlIds = $dbw->newSelectQueryBuilder()
|
|
->select( 'wl_id' )
|
|
->from( 'watchlist' )
|
|
->where(
|
|
[
|
|
'wl_user' => $user->getId(),
|
|
'wl_namespace' => $namespace,
|
|
'wl_title' => $toDelete
|
|
]
|
|
)
|
|
->caller( __METHOD__ )
|
|
->fetchFieldValues();
|
|
|
|
if ( $wlIds ) {
|
|
// Delete rows from both the watchlist and watchlist_expiry tables.
|
|
$dbw->newDeleteQueryBuilder()
|
|
->deleteFrom( 'watchlist' )
|
|
->where( [ 'wl_id' => $wlIds ] )
|
|
->caller( __METHOD__ )->execute();
|
|
$affectedRows += $dbw->affectedRows();
|
|
|
|
if ( $this->expiryEnabled ) {
|
|
$dbw->newDeleteQueryBuilder()
|
|
->deleteFrom( 'watchlist_expiry' )
|
|
->where( [ 'we_item' => $wlIds ] )
|
|
->caller( __METHOD__ )->execute();
|
|
$affectedRows += $dbw->affectedRows();
|
|
}
|
|
}
|
|
|
|
if ( $ticket ) {
|
|
$this->lbFactory->commitAndWaitForReplication( __METHOD__, $ticket );
|
|
}
|
|
}
|
|
}
|
|
|
|
return (bool)$affectedRows;
|
|
}
|
|
|
|
/**
|
|
* @since 1.27
|
|
* @param LinkTarget[]|PageIdentity[] $targets deprecated passing LinkTarget[] since 1.36
|
|
* @param array $options Supported options are:
|
|
* - 'minimumWatchers': filter for pages that have at least a minimum number of watchers
|
|
* @return array
|
|
*/
|
|
public function countWatchersMultiple( array $targets, array $options = [] ): array {
|
|
$linkTargets = array_map( static function ( $target ) {
|
|
if ( !$target instanceof LinkTarget ) {
|
|
return new TitleValue( $target->getNamespace(), $target->getDBkey() );
|
|
}
|
|
return $target;
|
|
}, $targets );
|
|
$lb = $this->linkBatchFactory->newLinkBatch( $linkTargets );
|
|
$dbr = $this->lbFactory->getReplicaDatabase();
|
|
$queryBuilder = $dbr->newSelectQueryBuilder();
|
|
$queryBuilder
|
|
->select( [ 'wl_title', 'wl_namespace', 'watchers' => 'COUNT(*)' ] )
|
|
->from( 'watchlist' )
|
|
->where( [ $lb->constructSet( 'wl', $dbr ) ] )
|
|
->groupBy( [ 'wl_namespace', 'wl_title' ] )
|
|
->caller( __METHOD__ );
|
|
|
|
if ( array_key_exists( 'minimumWatchers', $options ) ) {
|
|
$queryBuilder->having( 'COUNT(*) >= ' . (int)$options['minimumWatchers'] );
|
|
}
|
|
|
|
$this->modifyQueryBuilderForExpiry( $queryBuilder, $dbr );
|
|
|
|
$res = $queryBuilder->fetchResultSet();
|
|
|
|
$watchCounts = [];
|
|
foreach ( $targets as $linkTarget ) {
|
|
$watchCounts[$linkTarget->getNamespace()][$linkTarget->getDBkey()] = 0;
|
|
}
|
|
|
|
foreach ( $res as $row ) {
|
|
$watchCounts[$row->wl_namespace][$row->wl_title] = (int)$row->watchers;
|
|
}
|
|
|
|
return $watchCounts;
|
|
}
|
|
|
|
/**
|
|
* @since 1.27
|
|
* @param array $targetsWithVisitThresholds array of LinkTarget[]|PageIdentity[] (not type
|
|
* hinted since it annoys phan) - deprecated passing LinkTarget[] since 1.36
|
|
* @param int|null $minimumWatchers
|
|
* @return int[][] two dimensional array, first is namespace, second is database key,
|
|
* value is the number of watchers
|
|
*/
|
|
public function countVisitingWatchersMultiple(
|
|
array $targetsWithVisitThresholds,
|
|
$minimumWatchers = null
|
|
): array {
|
|
if ( $targetsWithVisitThresholds === [] ) {
|
|
// No titles requested => no results returned
|
|
return [];
|
|
}
|
|
|
|
$dbr = $this->lbFactory->getReplicaDatabase();
|
|
$queryBuilder = $dbr->newSelectQueryBuilder()
|
|
->select( [ 'wl_namespace', 'wl_title', 'watchers' => 'COUNT(*)' ] )
|
|
->from( 'watchlist' )
|
|
->where( [ $this->getVisitingWatchersCondition( $dbr, $targetsWithVisitThresholds ) ] )
|
|
->groupBy( [ 'wl_namespace', 'wl_title' ] )
|
|
->caller( __METHOD__ );
|
|
if ( $minimumWatchers !== null ) {
|
|
$queryBuilder->having( 'COUNT(*) >= ' . (int)$minimumWatchers );
|
|
}
|
|
$this->modifyQueryBuilderForExpiry( $queryBuilder, $dbr );
|
|
|
|
$res = $queryBuilder->fetchResultSet();
|
|
|
|
$watcherCounts = [];
|
|
foreach ( $targetsWithVisitThresholds as [ $target ] ) {
|
|
/** @var LinkTarget|PageIdentity $target */
|
|
$watcherCounts[$target->getNamespace()][$target->getDBkey()] = 0;
|
|
}
|
|
|
|
foreach ( $res as $row ) {
|
|
$watcherCounts[$row->wl_namespace][$row->wl_title] = (int)$row->watchers;
|
|
}
|
|
|
|
return $watcherCounts;
|
|
}
|
|
|
|
/**
|
|
* Generates condition for the query used in a batch count visiting watchers.
|
|
*
|
|
* @param IReadableDatabase $db
|
|
* @param array $targetsWithVisitThresholds array of pairs (LinkTarget|PageIdentity,
|
|
* last visit threshold) - deprecated passing LinkTarget since 1.36
|
|
* @return string
|
|
*/
|
|
private function getVisitingWatchersCondition(
|
|
IReadableDatabase $db,
|
|
array $targetsWithVisitThresholds
|
|
): string {
|
|
$missingTargets = [];
|
|
$namespaceConds = [];
|
|
foreach ( $targetsWithVisitThresholds as [ $target, $threshold ] ) {
|
|
if ( $threshold === null ) {
|
|
$missingTargets[] = $target;
|
|
continue;
|
|
}
|
|
/** @var LinkTarget|PageIdentity $target */
|
|
$namespaceConds[$target->getNamespace()][] = $db->expr( 'wl_title', '=', $target->getDBkey() )
|
|
->andExpr(
|
|
$db->expr( 'wl_notificationtimestamp', '>=', $db->timestamp( $threshold ) )
|
|
->or( 'wl_notificationtimestamp', '=', null )
|
|
);
|
|
}
|
|
|
|
$conds = [];
|
|
foreach ( $namespaceConds as $namespace => $pageConds ) {
|
|
$conds[] = $db->makeList( [
|
|
'wl_namespace = ' . $namespace,
|
|
'(' . $db->makeList( $pageConds, LIST_OR ) . ')'
|
|
], LIST_AND );
|
|
}
|
|
|
|
if ( $missingTargets ) {
|
|
$lb = $this->linkBatchFactory->newLinkBatch( $missingTargets );
|
|
$conds[] = $lb->constructSet( 'wl', $db );
|
|
}
|
|
|
|
return $db->makeList( $conds, LIST_OR );
|
|
}
|
|
|
|
/**
|
|
* @since 1.27
|
|
* @param UserIdentity $user
|
|
* @param LinkTarget|PageIdentity $target deprecated passing LinkTarget since 1.36
|
|
* @return WatchedItem|false
|
|
*/
|
|
public function getWatchedItem( UserIdentity $user, $target ) {
|
|
if ( !$user->isRegistered() ) {
|
|
return false;
|
|
}
|
|
|
|
$cached = $this->getCached( $user, $target );
|
|
if ( $cached && !$cached->isExpired() ) {
|
|
$this->statsFactory->getCounter( 'WatchedItemStore_getWatchedItem_accesses_total' )
|
|
->setLabel( 'status', 'hit' )
|
|
->copyToStatsdAt( 'WatchedItemStore.getWatchedItem.cached' )
|
|
->increment();
|
|
return $cached;
|
|
}
|
|
$this->statsFactory->getCounter( 'WatchedItemStore_getWatchedItem_accesses_total' )
|
|
->setLabel( 'status', 'miss' )
|
|
->copyToStatsdAt( 'WatchedItemStore.getWatchedItem.load' )
|
|
->increment();
|
|
return $this->loadWatchedItem( $user, $target );
|
|
}
|
|
|
|
/**
|
|
* @since 1.27
|
|
* @param UserIdentity $user
|
|
* @param LinkTarget|PageIdentity $target deprecated passing LinkTarget since 1.36
|
|
* @return WatchedItem|false
|
|
*/
|
|
public function loadWatchedItem( UserIdentity $user, $target ) {
|
|
$item = $this->loadWatchedItemsBatch( $user, [ $target ] );
|
|
return $item ? $item[0] : false;
|
|
}
|
|
|
|
/**
|
|
* @since 1.36
|
|
* @param UserIdentity $user
|
|
* @param LinkTarget[]|PageIdentity[] $targets deprecated passing LinkTarget[] since 1.36
|
|
* @return WatchedItem[]|false
|
|
*/
|
|
public function loadWatchedItemsBatch( UserIdentity $user, array $targets ) {
|
|
// Only registered user can have a watchlist
|
|
if ( !$user->isRegistered() ) {
|
|
return false;
|
|
}
|
|
|
|
$dbr = $this->lbFactory->getReplicaDatabase();
|
|
|
|
$rows = $this->fetchWatchedItems(
|
|
$dbr,
|
|
$user,
|
|
[ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
|
|
[],
|
|
$targets
|
|
);
|
|
|
|
if ( !$rows ) {
|
|
return false;
|
|
}
|
|
|
|
$items = [];
|
|
foreach ( $rows as $row ) {
|
|
// TODO: convert to PageIdentity
|
|
$target = new TitleValue( (int)$row->wl_namespace, $row->wl_title );
|
|
$item = $this->getWatchedItemFromRow( $user, $target, $row );
|
|
$this->cache( $item );
|
|
$items[] = $item;
|
|
}
|
|
|
|
return $items;
|
|
}
|
|
|
|
/**
|
|
* @since 1.27
|
|
* @param UserIdentity $user
|
|
* @param array $options Supported options are:
|
|
* - 'forWrite': whether to use the primary database instead of a replica
|
|
* - 'sort': how to sort the titles, either SORT_ASC or SORT_DESC
|
|
* - 'sortByExpiry': whether to also sort results by expiration, with temporarily watched titles
|
|
* above titles watched indefinitely and titles expiring soonest at the top
|
|
* @return WatchedItem[]
|
|
*/
|
|
public function getWatchedItemsForUser( UserIdentity $user, array $options = [] ): array {
|
|
$options += [ 'forWrite' => false ];
|
|
$vars = [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ];
|
|
$orderBy = [];
|
|
if ( $options['forWrite'] ) {
|
|
$db = $this->lbFactory->getPrimaryDatabase();
|
|
} else {
|
|
$db = $this->lbFactory->getReplicaDatabase();
|
|
}
|
|
if ( array_key_exists( 'sort', $options ) ) {
|
|
Assert::parameter(
|
|
( in_array( $options['sort'], [ self::SORT_ASC, self::SORT_DESC ] ) ),
|
|
'$options[\'sort\']',
|
|
'must be SORT_ASC or SORT_DESC'
|
|
);
|
|
$orderBy[] = "wl_namespace {$options['sort']}";
|
|
if ( $this->expiryEnabled
|
|
&& array_key_exists( 'sortByExpiry', $options )
|
|
&& $options['sortByExpiry']
|
|
) {
|
|
// Add `wl_has_expiry` column to allow sorting by watched titles that have an expiration date first.
|
|
$vars['wl_has_expiry'] = $db->conditional( 'we_expiry IS NULL', '0', '1' );
|
|
// Display temporarily watched titles first.
|
|
// Order by expiration date, with the titles that will expire soonest at the top.
|
|
$orderBy[] = "wl_has_expiry DESC";
|
|
$orderBy[] = "we_expiry ASC";
|
|
}
|
|
|
|
$orderBy[] = "wl_title {$options['sort']}";
|
|
}
|
|
|
|
$res = $this->fetchWatchedItems(
|
|
$db,
|
|
$user,
|
|
$vars,
|
|
$orderBy
|
|
);
|
|
|
|
$watchedItems = [];
|
|
foreach ( $res as $row ) {
|
|
// TODO: convert to PageIdentity
|
|
$target = new TitleValue( (int)$row->wl_namespace, $row->wl_title );
|
|
// @todo: Should we add these to the process cache?
|
|
$watchedItems[] = $this->getWatchedItemFromRow( $user, $target, $row );
|
|
}
|
|
|
|
return $watchedItems;
|
|
}
|
|
|
|
/**
|
|
* Construct a new WatchedItem given a row from watchlist/watchlist_expiry.
|
|
* @param UserIdentity $user
|
|
* @param LinkTarget|PageIdentity $target deprecated passing LinkTarget since 1.36
|
|
* @param \stdClass $row
|
|
* @return WatchedItem
|
|
*/
|
|
private function getWatchedItemFromRow(
|
|
UserIdentity $user,
|
|
$target,
|
|
stdClass $row
|
|
): WatchedItem {
|
|
return new WatchedItem(
|
|
$user,
|
|
$target,
|
|
$this->getLatestNotificationTimestamp(
|
|
$row->wl_notificationtimestamp, $user, $target ),
|
|
wfTimestampOrNull( TS_ISO_8601, $row->we_expiry ?? null )
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Fetches either a single or all watched items for the given user, or a specific set of items.
|
|
* If a $target is given, IDatabase::selectRow() is called, otherwise select().
|
|
* If $wgWatchlistExpiry is enabled, expired items are not returned.
|
|
*
|
|
* @param IReadableDatabase $db
|
|
* @param UserIdentity $user
|
|
* @param array $vars we_expiry is added when $wgWatchlistExpiry is enabled.
|
|
* @param array $orderBy array of columns
|
|
* @param LinkTarget|LinkTarget[]|PageIdentity|PageIdentity[]|null $target null if selecting all
|
|
* watched items - deprecated passing LinkTarget or LinkTarget[] since 1.36
|
|
* @return IResultWrapper|\stdClass|false
|
|
*/
|
|
private function fetchWatchedItems(
|
|
IReadableDatabase $db,
|
|
UserIdentity $user,
|
|
array $vars,
|
|
array $orderBy = [],
|
|
$target = null
|
|
) {
|
|
$dbMethod = 'select';
|
|
$queryBuilder = $db->newSelectQueryBuilder()
|
|
->select( $vars )
|
|
->from( 'watchlist' )
|
|
->where( [ 'wl_user' => $user->getId() ] )
|
|
->caller( __METHOD__ );
|
|
if ( $target ) {
|
|
if ( $target instanceof LinkTarget || $target instanceof PageIdentity ) {
|
|
$queryBuilder->where( [
|
|
'wl_namespace' => $target->getNamespace(),
|
|
'wl_title' => $target->getDBkey(),
|
|
] );
|
|
$dbMethod = 'selectRow';
|
|
} else {
|
|
$titleConds = [];
|
|
foreach ( $target as $linkTarget ) {
|
|
$titleConds[] = $db->makeList(
|
|
[
|
|
'wl_namespace' => $linkTarget->getNamespace(),
|
|
'wl_title' => $linkTarget->getDBkey(),
|
|
],
|
|
$db::LIST_AND
|
|
);
|
|
}
|
|
$queryBuilder->where( $db->makeList( $titleConds, $db::LIST_OR ) );
|
|
}
|
|
}
|
|
|
|
$this->modifyQueryBuilderForExpiry( $queryBuilder, $db );
|
|
if ( $this->expiryEnabled ) {
|
|
$queryBuilder->field( 'we_expiry' );
|
|
}
|
|
if ( $orderBy ) {
|
|
$queryBuilder->orderBy( $orderBy );
|
|
}
|
|
|
|
if ( $dbMethod == 'selectRow' ) {
|
|
return $queryBuilder->fetchRow();
|
|
}
|
|
return $queryBuilder->fetchResultSet();
|
|
}
|
|
|
|
/**
|
|
* @since 1.27
|
|
* @param UserIdentity $user
|
|
* @param LinkTarget|PageIdentity $target deprecated passing LinkTarget since 1.36
|
|
* @return bool
|
|
*/
|
|
public function isWatched( UserIdentity $user, $target ): bool {
|
|
return (bool)$this->getWatchedItem( $user, $target );
|
|
}
|
|
|
|
/**
|
|
* Check if the user is temporarily watching the page.
|
|
* @since 1.35
|
|
* @param UserIdentity $user
|
|
* @param LinkTarget|PageIdentity $target deprecated passing LinkTarget since 1.36
|
|
* @return bool
|
|
*/
|
|
public function isTempWatched( UserIdentity $user, $target ): bool {
|
|
$item = $this->getWatchedItem( $user, $target );
|
|
return $item && $item->getExpiry();
|
|
}
|
|
|
|
/**
|
|
* @since 1.27
|
|
* @param UserIdentity $user
|
|
* @param LinkTarget[] $targets
|
|
* @return (string|null|false)[][] two dimensional array, first is namespace, second is database key,
|
|
* value is the notification timestamp or null, or false if not available
|
|
*/
|
|
public function getNotificationTimestampsBatch( UserIdentity $user, array $targets ): array {
|
|
$timestamps = [];
|
|
foreach ( $targets as $target ) {
|
|
$timestamps[$target->getNamespace()][$target->getDBkey()] = false;
|
|
}
|
|
|
|
if ( !$user->isRegistered() ) {
|
|
return $timestamps;
|
|
}
|
|
|
|
$targetsToLoad = [];
|
|
foreach ( $targets as $target ) {
|
|
$cachedItem = $this->getCached( $user, $target );
|
|
if ( $cachedItem ) {
|
|
$timestamps[$target->getNamespace()][$target->getDBkey()] =
|
|
$cachedItem->getNotificationTimestamp();
|
|
} else {
|
|
$targetsToLoad[] = $target;
|
|
}
|
|
}
|
|
|
|
if ( !$targetsToLoad ) {
|
|
return $timestamps;
|
|
}
|
|
|
|
$dbr = $this->lbFactory->getReplicaDatabase();
|
|
|
|
$lb = $this->linkBatchFactory->newLinkBatch( $targetsToLoad );
|
|
$res = $dbr->newSelectQueryBuilder()
|
|
->select( [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ] )
|
|
->from( 'watchlist' )
|
|
->where( [
|
|
$lb->constructSet( 'wl', $dbr ),
|
|
'wl_user' => $user->getId(),
|
|
] )
|
|
->caller( __METHOD__ )
|
|
->fetchResultSet();
|
|
|
|
foreach ( $res as $row ) {
|
|
// TODO: convert to PageIdentity
|
|
$target = new TitleValue( (int)$row->wl_namespace, $row->wl_title );
|
|
$timestamps[$row->wl_namespace][$row->wl_title] =
|
|
$this->getLatestNotificationTimestamp(
|
|
$row->wl_notificationtimestamp, $user, $target );
|
|
}
|
|
|
|
return $timestamps;
|
|
}
|
|
|
|
/**
|
|
* @since 1.27 Method added.
|
|
* @since 1.35 Accepts $expiry parameter.
|
|
* @param UserIdentity $user
|
|
* @param LinkTarget|PageIdentity $target deprecated passing LinkTarget since 1.36
|
|
* @param string|null $expiry Optional expiry in any format acceptable to wfTimestamp().
|
|
* null will not create an expiry, or leave it unchanged should one already exist.
|
|
*/
|
|
public function addWatch( UserIdentity $user, $target, ?string $expiry = null ) {
|
|
$this->addWatchBatchForUser( $user, [ $target ], $expiry );
|
|
|
|
if ( $this->expiryEnabled && !$expiry ) {
|
|
// When re-watching a page with a null $expiry, any existing expiry is left unchanged.
|
|
// However we must re-fetch the preexisting expiry or else the cached WatchedItem will
|
|
// incorrectly have a null expiry. Note that loadWatchedItem() does the caching.
|
|
// See T259379
|
|
$this->loadWatchedItem( $user, $target );
|
|
} else {
|
|
// Create a new WatchedItem and add it to the process cache.
|
|
// In this case we don't need to re-fetch the expiry.
|
|
$expiry = ExpiryDef::normalizeUsingMaxExpiry( $expiry, $this->maxExpiryDuration, TS_ISO_8601 );
|
|
$item = new WatchedItem(
|
|
$user,
|
|
$target,
|
|
null,
|
|
$expiry
|
|
);
|
|
$this->cache( $item );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Add multiple items to the user's watchlist.
|
|
* If you know you're adding a single page (and/or its talk page) use self::addWatch(),
|
|
* since it will add the WatchedItem to the process cache.
|
|
*
|
|
* @since 1.27 Method added.
|
|
* @since 1.35 Accepts $expiry parameter.
|
|
* @param UserIdentity $user
|
|
* @param LinkTarget[] $targets
|
|
* @param string|null $expiry Optional expiry in a format acceptable to wfTimestamp(),
|
|
* null will not create expiries, or leave them unchanged should they already exist.
|
|
* @return bool Whether database transactions were performed.
|
|
*/
|
|
public function addWatchBatchForUser(
|
|
UserIdentity $user,
|
|
array $targets,
|
|
?string $expiry = null
|
|
): bool {
|
|
// Only registered user can have a watchlist
|
|
if ( !$user->isRegistered() || $this->readOnlyMode->isReadOnly() ) {
|
|
return false;
|
|
}
|
|
|
|
if ( !$targets ) {
|
|
return true;
|
|
}
|
|
$expiry = ExpiryDef::normalizeUsingMaxExpiry( $expiry, $this->maxExpiryDuration, TS_ISO_8601 );
|
|
$rows = [];
|
|
foreach ( $targets as $target ) {
|
|
$rows[] = [
|
|
'wl_user' => $user->getId(),
|
|
'wl_namespace' => $target->getNamespace(),
|
|
'wl_title' => $target->getDBkey(),
|
|
'wl_notificationtimestamp' => null,
|
|
];
|
|
$this->uncache( $user, $target );
|
|
}
|
|
|
|
$dbw = $this->lbFactory->getPrimaryDatabase();
|
|
$ticket = count( $targets ) > $this->updateRowsPerQuery ?
|
|
$this->lbFactory->getEmptyTransactionTicket( __METHOD__ ) : null;
|
|
$affectedRows = 0;
|
|
$rowBatches = array_chunk( $rows, $this->updateRowsPerQuery );
|
|
foreach ( $rowBatches as $toInsert ) {
|
|
// Use INSERT IGNORE to avoid overwriting the notification timestamp
|
|
// if there's already an entry for this page
|
|
$dbw->newInsertQueryBuilder()
|
|
->insertInto( 'watchlist' )
|
|
->ignore()
|
|
->rows( $toInsert )
|
|
->caller( __METHOD__ )->execute();
|
|
$affectedRows += $dbw->affectedRows();
|
|
|
|
if ( $this->expiryEnabled ) {
|
|
$affectedRows += $this->updateOrDeleteExpiries( $dbw, $user->getId(), $toInsert, $expiry );
|
|
}
|
|
|
|
if ( $ticket ) {
|
|
$this->lbFactory->commitAndWaitForReplication( __METHOD__, $ticket );
|
|
}
|
|
}
|
|
|
|
return (bool)$affectedRows;
|
|
}
|
|
|
|
/**
|
|
* Insert/update expiries, or delete them if the expiry is 'infinity'.
|
|
*
|
|
* @param IDatabase $dbw
|
|
* @param int $userId
|
|
* @param array $rows
|
|
* @param string|null $expiry
|
|
* @return int Number of affected rows.
|
|
*/
|
|
private function updateOrDeleteExpiries(
|
|
IDatabase $dbw,
|
|
int $userId,
|
|
array $rows,
|
|
?string $expiry = null
|
|
): int {
|
|
if ( !$expiry ) {
|
|
// if expiry is null (shouldn't change), 0 rows affected.
|
|
return 0;
|
|
}
|
|
|
|
// Build the giant `(...) OR (...)` part to be used with WHERE.
|
|
$conds = [];
|
|
foreach ( $rows as $row ) {
|
|
$conds[] = $dbw->makeList(
|
|
[
|
|
'wl_user' => $userId,
|
|
'wl_namespace' => $row['wl_namespace'],
|
|
'wl_title' => $row['wl_title']
|
|
],
|
|
$dbw::LIST_AND
|
|
);
|
|
}
|
|
$cond = $dbw->makeList( $conds, $dbw::LIST_OR );
|
|
|
|
if ( wfIsInfinity( $expiry ) ) {
|
|
// Rows should be deleted rather than updated.
|
|
$dbw->deleteJoin(
|
|
'watchlist_expiry',
|
|
'watchlist',
|
|
'we_item',
|
|
'wl_id',
|
|
[ $cond ],
|
|
__METHOD__
|
|
);
|
|
|
|
return $dbw->affectedRows();
|
|
}
|
|
|
|
return $this->updateExpiries( $dbw, $expiry, $cond );
|
|
}
|
|
|
|
/**
|
|
* Update the expiries for items found with the given $cond.
|
|
* @param IDatabase $dbw
|
|
* @param string $expiry
|
|
* @param string $cond
|
|
* @return int Number of affected rows.
|
|
*/
|
|
private function updateExpiries( IDatabase $dbw, string $expiry, string $cond ): int {
|
|
// First fetch the wl_ids from the watchlist table.
|
|
// We'd prefer to do a INSERT/SELECT in the same query with IDatabase::insertSelect(),
|
|
// but it doesn't allow us to use the "ON DUPLICATE KEY UPDATE" clause.
|
|
$wlIds = $dbw->newSelectQueryBuilder()
|
|
->select( 'wl_id' )
|
|
->from( 'watchlist' )
|
|
->where( $cond )
|
|
->caller( __METHOD__ )
|
|
->fetchFieldValues();
|
|
|
|
if ( !$wlIds ) {
|
|
return 0;
|
|
}
|
|
|
|
$expiry = $dbw->timestamp( $expiry );
|
|
$weRows = [];
|
|
foreach ( $wlIds as $wlId ) {
|
|
$weRows[] = [
|
|
'we_item' => $wlId,
|
|
'we_expiry' => $expiry
|
|
];
|
|
}
|
|
|
|
// Insert into watchlist_expiry, updating the expiry for duplicate rows.
|
|
$dbw->newInsertQueryBuilder()
|
|
->insertInto( 'watchlist_expiry' )
|
|
->rows( $weRows )
|
|
->onDuplicateKeyUpdate()
|
|
->uniqueIndexFields( [ 'we_item' ] )
|
|
->set( [ 'we_expiry' => $expiry ] )
|
|
->caller( __METHOD__ )->execute();
|
|
|
|
return $dbw->affectedRows();
|
|
}
|
|
|
|
/**
|
|
* @since 1.27
|
|
* @param UserIdentity $user
|
|
* @param LinkTarget|PageIdentity $target deprecated passing LinkTarget since 1.36
|
|
* @return bool
|
|
*/
|
|
public function removeWatch( UserIdentity $user, $target ): bool {
|
|
return $this->removeWatchBatchForUser( $user, [ $target ] );
|
|
}
|
|
|
|
/**
|
|
* Set the "last viewed" timestamps for certain titles on a user's watchlist.
|
|
*
|
|
* If the $targets parameter is omitted or set to [], this method simply wraps
|
|
* resetAllNotificationTimestampsForUser(), and in that case you should instead call that method
|
|
* directly; support for omitting $targets is for backwards compatibility.
|
|
*
|
|
* If $targets is omitted or set to [], timestamps will be updated for every title on the user's
|
|
* watchlist, and this will be done through a DeferredUpdate. If $targets is a non-empty array,
|
|
* only the specified titles will be updated, and this will be done immediately (not deferred).
|
|
*
|
|
* @since 1.27
|
|
* @param UserIdentity $user
|
|
* @param string|int $timestamp Value to set the "last viewed" timestamp to (null to clear)
|
|
* @param LinkTarget[] $targets Titles to set the timestamp for; [] means the entire watchlist
|
|
* @return bool
|
|
*/
|
|
public function setNotificationTimestampsForUser(
|
|
UserIdentity $user,
|
|
$timestamp,
|
|
array $targets = []
|
|
): bool {
|
|
// Only registered user can have a watchlist
|
|
if ( !$user->isRegistered() || $this->readOnlyMode->isReadOnly() ) {
|
|
return false;
|
|
}
|
|
|
|
if ( !$targets ) {
|
|
// Backwards compatibility
|
|
$this->resetAllNotificationTimestampsForUser( $user, $timestamp );
|
|
return true;
|
|
}
|
|
|
|
$rows = $this->getTitleDbKeysGroupedByNamespace( $targets );
|
|
|
|
$dbw = $this->lbFactory->getPrimaryDatabase();
|
|
if ( $timestamp !== null ) {
|
|
$timestamp = $dbw->timestamp( $timestamp );
|
|
}
|
|
$ticket = $this->lbFactory->getEmptyTransactionTicket( __METHOD__ );
|
|
$affectedSinceWait = 0;
|
|
|
|
// Batch update items per namespace
|
|
foreach ( $rows as $namespace => $namespaceTitles ) {
|
|
$rowBatches = array_chunk( $namespaceTitles, $this->updateRowsPerQuery );
|
|
foreach ( $rowBatches as $toUpdate ) {
|
|
// First fetch the wl_ids.
|
|
$wlIds = $dbw->newSelectQueryBuilder()
|
|
->select( 'wl_id' )
|
|
->from( 'watchlist' )
|
|
->where( [
|
|
'wl_user' => $user->getId(),
|
|
'wl_namespace' => $namespace,
|
|
'wl_title' => $toUpdate
|
|
] )
|
|
->caller( __METHOD__ )
|
|
->fetchFieldValues();
|
|
if ( $wlIds ) {
|
|
$wlIds = array_map( 'intval', $wlIds );
|
|
$dbw->newUpdateQueryBuilder()
|
|
->update( 'watchlist' )
|
|
->set( [ 'wl_notificationtimestamp' => $timestamp ] )
|
|
->where( [ 'wl_id' => $wlIds ] )
|
|
->caller( __METHOD__ )->execute();
|
|
|
|
$affectedSinceWait += $dbw->affectedRows();
|
|
// Wait for replication every time we've touched updateRowsPerQuery rows
|
|
if ( $affectedSinceWait >= $this->updateRowsPerQuery ) {
|
|
$this->lbFactory->commitAndWaitForReplication( __METHOD__, $ticket );
|
|
$affectedSinceWait = 0;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
$this->uncacheUser( $user );
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* @param string|null $timestamp
|
|
* @param UserIdentity $user
|
|
* @param LinkTarget|PageIdentity $target deprecated passing LinkTarget since 1.36
|
|
* @return bool|string|null
|
|
*/
|
|
public function getLatestNotificationTimestamp(
|
|
$timestamp,
|
|
UserIdentity $user,
|
|
$target
|
|
) {
|
|
$timestamp = wfTimestampOrNull( TS_MW, $timestamp );
|
|
if ( $timestamp === null ) {
|
|
return null; // no notification
|
|
}
|
|
|
|
$seenTimestamps = $this->getPageSeenTimestamps( $user );
|
|
if ( $seenTimestamps ) {
|
|
$seenKey = $this->getPageSeenKey( $target );
|
|
if ( isset( $seenTimestamps[$seenKey] ) && $seenTimestamps[$seenKey] >= $timestamp ) {
|
|
// If a reset job did not yet run, then the "seen" timestamp will be higher
|
|
return null;
|
|
}
|
|
}
|
|
|
|
return $timestamp;
|
|
}
|
|
|
|
/**
|
|
* Schedule a DeferredUpdate that sets all of the "last viewed" timestamps for a given user
|
|
* to the same value.
|
|
* @param UserIdentity $user
|
|
* @param string|int|null $timestamp Value to set all timestamps to, null to clear them
|
|
*/
|
|
public function resetAllNotificationTimestampsForUser( UserIdentity $user, $timestamp = null ) {
|
|
// Only registered user can have a watchlist
|
|
if ( !$user->isRegistered() ) {
|
|
return;
|
|
}
|
|
|
|
// If the page is watched by the user (or may be watched), update the timestamp
|
|
$job = new ClearWatchlistNotificationsJob( [
|
|
'userId' => $user->getId(), 'timestamp' => $timestamp, 'casTime' => time()
|
|
] );
|
|
|
|
// Try to run this post-send
|
|
// Calls DeferredUpdates::addCallableUpdate in normal operation
|
|
call_user_func(
|
|
$this->deferredUpdatesAddCallableUpdateCallback,
|
|
static function () use ( $job ) {
|
|
$job->run();
|
|
}
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Update wl_notificationtimestamp for all watching users except the editor
|
|
* @since 1.27
|
|
* @param UserIdentity $editor
|
|
* @param LinkTarget|PageIdentity $target deprecated passing LinkTarget since 1.36
|
|
* @param string|int $timestamp
|
|
* @return int[]
|
|
*/
|
|
public function updateNotificationTimestamp(
|
|
UserIdentity $editor,
|
|
$target,
|
|
$timestamp
|
|
): array {
|
|
$dbw = $this->lbFactory->getPrimaryDatabase();
|
|
$queryBuilder = $dbw->newSelectQueryBuilder()
|
|
->select( [ 'wl_id', 'wl_user' ] )
|
|
->from( 'watchlist' )
|
|
->where(
|
|
[
|
|
'wl_user != ' . $editor->getId(),
|
|
'wl_namespace' => $target->getNamespace(),
|
|
'wl_title' => $target->getDBkey(),
|
|
'wl_notificationtimestamp' => null,
|
|
]
|
|
)
|
|
->caller( __METHOD__ );
|
|
|
|
$this->modifyQueryBuilderForExpiry( $queryBuilder, $dbw );
|
|
|
|
$res = $queryBuilder->fetchResultSet();
|
|
$watchers = [];
|
|
$wlIds = [];
|
|
foreach ( $res as $row ) {
|
|
$watchers[] = (int)$row->wl_user;
|
|
$wlIds[] = (int)$row->wl_id;
|
|
}
|
|
|
|
if ( $wlIds ) {
|
|
$fname = __METHOD__;
|
|
// Try to run this post-send
|
|
// Calls DeferredUpdates::addCallableUpdate in normal operation
|
|
call_user_func(
|
|
$this->deferredUpdatesAddCallableUpdateCallback,
|
|
function () use ( $timestamp, $wlIds, $target, $fname ) {
|
|
$dbw = $this->lbFactory->getPrimaryDatabase();
|
|
$ticket = $this->lbFactory->getEmptyTransactionTicket( $fname );
|
|
|
|
$wlIdsChunks = array_chunk( $wlIds, $this->updateRowsPerQuery );
|
|
foreach ( $wlIdsChunks as $wlIdsChunk ) {
|
|
$dbw->newUpdateQueryBuilder()
|
|
->update( 'watchlist' )
|
|
->set( [ 'wl_notificationtimestamp' => $dbw->timestamp( $timestamp ) ] )
|
|
->where( [ 'wl_id' => $wlIdsChunk ] )
|
|
->caller( $fname )->execute();
|
|
|
|
if ( count( $wlIdsChunks ) > 1 ) {
|
|
$this->lbFactory->commitAndWaitForReplication( $fname, $ticket );
|
|
}
|
|
}
|
|
$this->uncacheLinkTarget( $target );
|
|
},
|
|
DeferredUpdates::POSTSEND,
|
|
$dbw
|
|
);
|
|
}
|
|
|
|
return $watchers;
|
|
}
|
|
|
|
/**
|
|
* @since 1.27
|
|
* @param UserIdentity $user
|
|
* @param LinkTarget|PageIdentity $title deprecated passing LinkTarget since 1.36
|
|
* @param string $force
|
|
* @param int $oldid
|
|
* @return bool
|
|
*/
|
|
public function resetNotificationTimestamp(
|
|
UserIdentity $user,
|
|
$title,
|
|
$force = '',
|
|
$oldid = 0
|
|
): bool {
|
|
$time = time();
|
|
|
|
// Only registered user can have a watchlist
|
|
if ( !$user->isRegistered() || $this->readOnlyMode->isReadOnly() ) {
|
|
return false;
|
|
}
|
|
|
|
$item = null;
|
|
if ( $force != 'force' ) {
|
|
$item = $this->getWatchedItem( $user, $title );
|
|
if ( !$item || $item->getNotificationTimestamp() === null ) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Get the timestamp (TS_MW) of this revision to track the latest one seen
|
|
$id = $oldid;
|
|
$seenTime = null;
|
|
if ( !$id ) {
|
|
$latestRev = $this->revisionLookup->getRevisionByTitle( $title );
|
|
if ( $latestRev ) {
|
|
$id = $latestRev->getId();
|
|
// Save a DB query
|
|
$seenTime = $latestRev->getTimestamp();
|
|
}
|
|
}
|
|
if ( $seenTime === null ) {
|
|
// @phan-suppress-next-line PhanTypeMismatchArgumentNullable getId does not return null here
|
|
$seenTime = $this->revisionLookup->getTimestampFromId( $id );
|
|
}
|
|
|
|
// Mark the item as read immediately in lightweight storage
|
|
$this->stash->merge(
|
|
$this->getPageSeenTimestampsKey( $user ),
|
|
function ( $cache, $key, $current ) use ( $title, $seenTime ) {
|
|
if ( !$current ) {
|
|
$value = new MapCacheLRU( 300 );
|
|
} elseif ( is_array( $current ) ) {
|
|
$value = MapCacheLRU::newFromArray( $current, 300 );
|
|
} else {
|
|
// Backwards compatibility for T282105
|
|
$value = $current;
|
|
}
|
|
$subKey = $this->getPageSeenKey( $title );
|
|
|
|
if ( $seenTime > $value->get( $subKey ) ) {
|
|
// Revision is newer than the last one seen
|
|
$value->set( $subKey, $seenTime );
|
|
|
|
$this->latestUpdateCache->set( $key, $value->toArray(), BagOStuff::TTL_PROC_LONG );
|
|
} elseif ( $seenTime === false ) {
|
|
// Revision does not exist
|
|
$value->set( $subKey, wfTimestamp( TS_MW ) );
|
|
$this->latestUpdateCache->set( $key,
|
|
$value->toArray(),
|
|
BagOStuff::TTL_PROC_LONG );
|
|
} else {
|
|
return false; // nothing to update
|
|
}
|
|
|
|
return $value->toArray();
|
|
},
|
|
BagOStuff::TTL_HOUR
|
|
);
|
|
|
|
// If the page is watched by the user (or may be watched), update the timestamp
|
|
// ActivityUpdateJob accepts both LinkTarget and PageReference
|
|
$job = new ActivityUpdateJob(
|
|
$title,
|
|
[
|
|
'type' => 'updateWatchlistNotification',
|
|
'userid' => $user->getId(),
|
|
'notifTime' => $this->getNotificationTimestamp( $user, $title, $item, $force, $oldid ),
|
|
'curTime' => $time
|
|
]
|
|
);
|
|
// Try to enqueue this post-send
|
|
$this->queueGroup->lazyPush( $job );
|
|
|
|
$this->uncache( $user, $title );
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* @param UserIdentity $user
|
|
* @return array|null The map contains prefixed title keys and TS_MW values
|
|
*/
|
|
private function getPageSeenTimestamps( UserIdentity $user ) {
|
|
$key = $this->getPageSeenTimestampsKey( $user );
|
|
|
|
$cache = $this->latestUpdateCache->getWithSetCallback(
|
|
$key,
|
|
BagOStuff::TTL_PROC_LONG,
|
|
function () use ( $key ) {
|
|
return $this->stash->get( $key ) ?: null;
|
|
}
|
|
);
|
|
// Backwards compatibility for T282105
|
|
if ( $cache instanceof MapCacheLRU ) {
|
|
$cache = $cache->toArray();
|
|
}
|
|
return $cache;
|
|
}
|
|
|
|
/**
|
|
* @param UserIdentity $user
|
|
* @return string
|
|
*/
|
|
private function getPageSeenTimestampsKey( UserIdentity $user ): string {
|
|
return $this->stash->makeGlobalKey(
|
|
'watchlist-recent-updates',
|
|
$this->lbFactory->getLocalDomainID(),
|
|
$user->getId()
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param LinkTarget|PageIdentity $target
|
|
* @return string
|
|
*/
|
|
private function getPageSeenKey( $target ): string {
|
|
return "{$target->getNamespace()}:{$target->getDBkey()}";
|
|
}
|
|
|
|
/**
|
|
* @param UserIdentity $user
|
|
* @param LinkTarget|PageIdentity $title deprecated passing LinkTarget since 1.36
|
|
* @param WatchedItem|null $item
|
|
* @param string $force
|
|
* @param int|false $oldid The ID of the last revision that the user viewed
|
|
* @return string|null|false
|
|
*/
|
|
private function getNotificationTimestamp(
|
|
UserIdentity $user,
|
|
$title,
|
|
$item,
|
|
$force,
|
|
$oldid
|
|
) {
|
|
if ( !$oldid ) {
|
|
// No oldid given, assuming latest revision; clear the timestamp.
|
|
return null;
|
|
}
|
|
|
|
$oldRev = $this->revisionLookup->getRevisionById( $oldid );
|
|
if ( !$oldRev ) {
|
|
// Oldid given but does not exist (probably deleted)
|
|
return false;
|
|
}
|
|
|
|
$nextRev = $this->revisionLookup->getNextRevision( $oldRev );
|
|
if ( !$nextRev ) {
|
|
// Oldid given and is the latest revision for this title; clear the timestamp.
|
|
return null;
|
|
}
|
|
|
|
$item ??= $this->loadWatchedItem( $user, $title );
|
|
if ( !$item ) {
|
|
// This can only happen if $force is enabled.
|
|
return null;
|
|
}
|
|
|
|
// Oldid given and isn't the latest; update the timestamp.
|
|
// This will result in no further notification emails being sent!
|
|
$notificationTimestamp = $this->revisionLookup->getTimestampFromId( $oldid );
|
|
// @FIXME: this should use getTimestamp() for consistency with updates on new edits
|
|
// $notificationTimestamp = $nextRev->getTimestamp(); // first unseen revision timestamp
|
|
|
|
// We need to go one second to the future because of various strict comparisons
|
|
// throughout the codebase
|
|
$ts = new MWTimestamp( $notificationTimestamp );
|
|
$ts->timestamp->add( new DateInterval( 'PT1S' ) );
|
|
$notificationTimestamp = $ts->getTimestamp( TS_MW );
|
|
|
|
if ( $notificationTimestamp < $item->getNotificationTimestamp() ) {
|
|
if ( $force != 'force' ) {
|
|
return false;
|
|
} else {
|
|
// This is a little silly…
|
|
return $item->getNotificationTimestamp();
|
|
}
|
|
}
|
|
|
|
return $notificationTimestamp;
|
|
}
|
|
|
|
/**
|
|
* @since 1.27
|
|
* @param UserIdentity $user
|
|
* @param int|null $unreadLimit
|
|
* @return int|bool
|
|
*/
|
|
public function countUnreadNotifications( UserIdentity $user, $unreadLimit = null ) {
|
|
$queryBuilder = $this->lbFactory->getReplicaDatabase()->newSelectQueryBuilder()
|
|
->select( '1' )
|
|
->from( 'watchlist' )
|
|
->where( [
|
|
'wl_user' => $user->getId(),
|
|
'wl_notificationtimestamp IS NOT NULL'
|
|
] )
|
|
->caller( __METHOD__ );
|
|
if ( $unreadLimit !== null ) {
|
|
$unreadLimit = (int)$unreadLimit;
|
|
$queryBuilder->limit( $unreadLimit );
|
|
}
|
|
|
|
$rowCount = $queryBuilder->fetchRowCount();
|
|
|
|
if ( $unreadLimit === null ) {
|
|
return $rowCount;
|
|
}
|
|
|
|
if ( $rowCount >= $unreadLimit ) {
|
|
return true;
|
|
}
|
|
|
|
return $rowCount;
|
|
}
|
|
|
|
/**
|
|
* @since 1.27
|
|
* @param LinkTarget|PageIdentity $oldTarget deprecated passing LinkTarget since 1.36
|
|
* @param LinkTarget|PageIdentity $newTarget deprecated passing LinkTarget since 1.36
|
|
*/
|
|
public function duplicateAllAssociatedEntries( $oldTarget, $newTarget ) {
|
|
// Duplicate first the subject page, then the talk page
|
|
// TODO: convert to PageIdentity
|
|
$this->duplicateEntry(
|
|
new TitleValue( $this->nsInfo->getSubject( $oldTarget->getNamespace() ), $oldTarget->getDBkey() ),
|
|
new TitleValue( $this->nsInfo->getSubject( $newTarget->getNamespace() ), $newTarget->getDBkey() )
|
|
);
|
|
$this->duplicateEntry(
|
|
new TitleValue( $this->nsInfo->getTalk( $oldTarget->getNamespace() ), $oldTarget->getDBkey() ),
|
|
new TitleValue( $this->nsInfo->getTalk( $newTarget->getNamespace() ), $newTarget->getDBkey() )
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @since 1.27
|
|
* @param LinkTarget|PageIdentity $oldTarget deprecated passing LinkTarget since 1.36
|
|
* @param LinkTarget|PageIdentity $newTarget deprecated passing LinkTarget since 1.36
|
|
*/
|
|
public function duplicateEntry( $oldTarget, $newTarget ) {
|
|
$dbw = $this->lbFactory->getPrimaryDatabase();
|
|
$result = $this->fetchWatchedItemsForPage( $dbw, $oldTarget );
|
|
$newNamespace = $newTarget->getNamespace();
|
|
$newDBkey = $newTarget->getDBkey();
|
|
|
|
# Construct array to replace into the watchlist
|
|
$values = [];
|
|
$expiries = [];
|
|
foreach ( $result as $row ) {
|
|
$values[] = [
|
|
'wl_user' => $row->wl_user,
|
|
'wl_namespace' => $newNamespace,
|
|
'wl_title' => $newDBkey,
|
|
'wl_notificationtimestamp' => $row->wl_notificationtimestamp,
|
|
];
|
|
|
|
if ( $this->expiryEnabled && $row->we_expiry ) {
|
|
$expiries[$row->wl_user] = $row->we_expiry;
|
|
}
|
|
}
|
|
|
|
if ( !$values ) {
|
|
return;
|
|
}
|
|
|
|
// Perform a replace on the watchlist table rows.
|
|
// Note that multi-row replace is very efficient for MySQL but may be inefficient for
|
|
// some other DBMSes, mostly due to poor simulation by us.
|
|
$dbw->newReplaceQueryBuilder()
|
|
->replaceInto( 'watchlist' )
|
|
->uniqueIndexFields( [ 'wl_user', 'wl_namespace', 'wl_title' ] )
|
|
->rows( $values )
|
|
->caller( __METHOD__ )->execute();
|
|
|
|
if ( $this->expiryEnabled ) {
|
|
$this->updateExpiriesAfterMove( $dbw, $expiries, $newNamespace, $newDBkey );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param IReadableDatabase $dbr
|
|
* @param LinkTarget|PageIdentity $target
|
|
* @return IResultWrapper
|
|
*/
|
|
private function fetchWatchedItemsForPage(
|
|
IReadableDatabase $dbr,
|
|
$target
|
|
): IResultWrapper {
|
|
$queryBuilder = $dbr->newSelectQueryBuilder()
|
|
->select( [ 'wl_user', 'wl_notificationtimestamp' ] )
|
|
->from( 'watchlist' )
|
|
->where( [
|
|
'wl_namespace' => $target->getNamespace(),
|
|
'wl_title' => $target->getDBkey(),
|
|
] )
|
|
->caller( __METHOD__ )
|
|
->forUpdate();
|
|
|
|
if ( $this->expiryEnabled ) {
|
|
$queryBuilder->leftJoin( 'watchlist_expiry', null, [ 'wl_id = we_item' ] )
|
|
->field( 'we_expiry' );
|
|
}
|
|
|
|
return $queryBuilder->fetchResultSet();
|
|
}
|
|
|
|
/**
|
|
* @param IDatabase $dbw
|
|
* @param array $expiries
|
|
* @param int $namespace
|
|
* @param string $dbKey
|
|
*/
|
|
private function updateExpiriesAfterMove(
|
|
IDatabase $dbw,
|
|
array $expiries,
|
|
int $namespace,
|
|
string $dbKey
|
|
): void {
|
|
DeferredUpdates::addCallableUpdate(
|
|
function ( $fname ) use ( $dbw, $expiries, $namespace, $dbKey ) {
|
|
// First fetch new wl_ids.
|
|
$res = $dbw->newSelectQueryBuilder()
|
|
->select( [ 'wl_user', 'wl_id' ] )
|
|
->from( 'watchlist' )
|
|
->where( [
|
|
'wl_namespace' => $namespace,
|
|
'wl_title' => $dbKey,
|
|
] )
|
|
->caller( $fname )
|
|
->fetchResultSet();
|
|
|
|
// Build new array to INSERT into multiple rows at once.
|
|
$expiryData = [];
|
|
foreach ( $res as $row ) {
|
|
if ( !empty( $expiries[$row->wl_user] ) ) {
|
|
$expiryData[] = [
|
|
'we_item' => $row->wl_id,
|
|
'we_expiry' => $expiries[$row->wl_user],
|
|
];
|
|
}
|
|
}
|
|
|
|
// Batch the insertions.
|
|
$batches = array_chunk( $expiryData, $this->updateRowsPerQuery );
|
|
foreach ( $batches as $toInsert ) {
|
|
$dbw->newReplaceQueryBuilder()
|
|
->replaceInto( 'watchlist_expiry' )
|
|
->uniqueIndexFields( [ 'we_item' ] )
|
|
->rows( $toInsert )
|
|
->caller( $fname )
|
|
->execute();
|
|
}
|
|
},
|
|
DeferredUpdates::POSTSEND,
|
|
$dbw
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param LinkTarget[]|PageIdentity[] $titles
|
|
* @return array
|
|
*/
|
|
private function getTitleDbKeysGroupedByNamespace( array $titles ) {
|
|
$rows = [];
|
|
foreach ( $titles as $title ) {
|
|
// Group titles by namespace.
|
|
$rows[ $title->getNamespace() ][] = $title->getDBkey();
|
|
}
|
|
return $rows;
|
|
}
|
|
|
|
/**
|
|
* @param UserIdentity $user
|
|
* @param LinkTarget[]|PageIdentity[] $titles
|
|
*/
|
|
private function uncacheTitlesForUser( UserIdentity $user, array $titles ) {
|
|
foreach ( $titles as $title ) {
|
|
$this->uncache( $user, $title );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @inheritDoc
|
|
*/
|
|
public function countExpired(): int {
|
|
$dbr = $this->lbFactory->getReplicaDatabase();
|
|
return $dbr->newSelectQueryBuilder()
|
|
->select( '*' )
|
|
->from( 'watchlist_expiry' )
|
|
->where( $dbr->expr( 'we_expiry', '<=', $dbr->timestamp() ) )
|
|
->caller( __METHOD__ )
|
|
->fetchRowCount();
|
|
}
|
|
|
|
/**
|
|
* @inheritDoc
|
|
*/
|
|
public function removeExpired( int $limit, bool $deleteOrphans = false ): void {
|
|
$dbr = $this->lbFactory->getReplicaDatabase();
|
|
$dbw = $this->lbFactory->getPrimaryDatabase();
|
|
$ticket = $this->lbFactory->getEmptyTransactionTicket( __METHOD__ );
|
|
|
|
// Get a batch of watchlist IDs to delete.
|
|
$toDelete = $dbr->newSelectQueryBuilder()
|
|
->select( 'we_item' )
|
|
->from( 'watchlist_expiry' )
|
|
->where( $dbr->expr( 'we_expiry', '<=', $dbr->timestamp() ) )
|
|
->limit( $limit )
|
|
->caller( __METHOD__ )
|
|
->fetchFieldValues();
|
|
|
|
if ( count( $toDelete ) > 0 ) {
|
|
// Delete them from the watchlist and watchlist_expiry table.
|
|
$dbw->newDeleteQueryBuilder()
|
|
->deleteFrom( 'watchlist' )
|
|
->where( [ 'wl_id' => $toDelete ] )
|
|
->caller( __METHOD__ )->execute();
|
|
$dbw->newDeleteQueryBuilder()
|
|
->deleteFrom( 'watchlist_expiry' )
|
|
->where( [ 'we_item' => $toDelete ] )
|
|
->caller( __METHOD__ )->execute();
|
|
}
|
|
|
|
// Also delete any orphaned or null-expiry watchlist_expiry rows
|
|
// (they should not exist, but might because not everywhere knows about the expiry table yet).
|
|
if ( $deleteOrphans ) {
|
|
$expiryToDelete = $dbr->newSelectQueryBuilder()
|
|
->select( 'we_item' )
|
|
->from( 'watchlist_expiry' )
|
|
->leftJoin( 'watchlist', null, 'wl_id = we_item' )
|
|
->where( $dbr->makeList(
|
|
[ 'wl_id' => null, 'we_expiry' => null ],
|
|
$dbr::LIST_OR
|
|
) )
|
|
->caller( __METHOD__ )
|
|
->fetchFieldValues();
|
|
if ( count( $expiryToDelete ) > 0 ) {
|
|
$dbw->newDeleteQueryBuilder()
|
|
->deleteFrom( 'watchlist_expiry' )
|
|
->where( [ 'we_item' => $expiryToDelete ] )
|
|
->caller( __METHOD__ )->execute();
|
|
}
|
|
}
|
|
|
|
$this->lbFactory->commitAndWaitForReplication( __METHOD__, $ticket );
|
|
}
|
|
}
|
|
/** @deprecated class alias since 1.43 */
|
|
class_alias( WatchedItemStore::class, 'WatchedItemStore' );
|