3 namespace MediaWiki\Watchlist
;
9 use MediaWiki\Cache\LinkBatchFactory
;
10 use MediaWiki\Config\ServiceOptions
;
11 use MediaWiki\Deferred\DeferredUpdates
;
12 use MediaWiki\Linker\LinkTarget
;
13 use MediaWiki\MainConfigNames
;
14 use MediaWiki\Page\PageIdentity
;
15 use MediaWiki\Revision\RevisionLookup
;
16 use MediaWiki\Title\NamespaceInfo
;
17 use MediaWiki\Title\TitleValue
;
18 use MediaWiki\User\UserIdentity
;
19 use MediaWiki\Utils\MWTimestamp
;
21 use Wikimedia\Assert\Assert
;
22 use Wikimedia\ObjectCache\BagOStuff
;
23 use Wikimedia\ObjectCache\HashBagOStuff
;
24 use Wikimedia\ParamValidator\TypeDef\ExpiryDef
;
25 use Wikimedia\Rdbms\IDatabase
;
26 use Wikimedia\Rdbms\ILBFactory
;
27 use Wikimedia\Rdbms\IReadableDatabase
;
28 use Wikimedia\Rdbms\IResultWrapper
;
29 use Wikimedia\Rdbms\ReadOnlyMode
;
30 use Wikimedia\Rdbms\SelectQueryBuilder
;
31 use Wikimedia\ScopedCallback
;
32 use Wikimedia\Stats\StatsFactory
;
35 * Storage layer class for WatchedItems.
36 * Database interaction & caching
37 * TODO caching should be factored out into a CachingWatchedItemStore class
42 class WatchedItemStore
implements WatchedItemStoreInterface
{
45 * @internal For use by ServiceWiring
47 public const CONSTRUCTOR_OPTIONS
= [
48 MainConfigNames
::UpdateRowsPerQuery
,
49 MainConfigNames
::WatchlistExpiry
,
50 MainConfigNames
::WatchlistExpiryMaxDuration
,
51 MainConfigNames
::WatchlistPurgeRate
,
72 private $readOnlyMode;
82 private $latestUpdateCache;
85 * @var array[][] Looks like $cacheIndex[Namespace ID][Target DB Key][User Id] => 'key'
86 * The index is needed so that on mass changes all relevant items can be un-cached.
87 * For example: Clearing a users watchlist of all items or updating notification timestamps
88 * for all users watching a single target.
89 * @phan-var array<int,array<string,array<int,string>>>
91 private $cacheIndex = [];
96 private $deferredUpdatesAddCallableUpdateCallback;
101 private $updateRowsPerQuery;
109 * @var RevisionLookup
111 private $revisionLookup;
114 * @var bool Correlates to $wgWatchlistExpiry feature flag.
116 private $expiryEnabled;
119 * @var LinkBatchFactory
121 private $linkBatchFactory;
123 /** @var StatsFactory */
124 private $statsFactory;
127 * @var string|null Maximum configured relative expiry.
129 private $maxExpiryDuration;
131 /** @var float corresponds to $wgWatchlistPurgeRate value */
132 private $watchlistPurgeRate;
135 * @param ServiceOptions $options
136 * @param ILBFactory $lbFactory
137 * @param JobQueueGroup $queueGroup
138 * @param BagOStuff $stash
139 * @param HashBagOStuff $cache
140 * @param ReadOnlyMode $readOnlyMode
141 * @param NamespaceInfo $nsInfo
142 * @param RevisionLookup $revisionLookup
143 * @param LinkBatchFactory $linkBatchFactory
144 * @param StatsFactory $statsFactory
146 public function __construct(
147 ServiceOptions
$options,
148 ILBFactory
$lbFactory,
149 JobQueueGroup
$queueGroup,
151 HashBagOStuff
$cache,
152 ReadOnlyMode
$readOnlyMode,
153 NamespaceInfo
$nsInfo,
154 RevisionLookup
$revisionLookup,
155 LinkBatchFactory
$linkBatchFactory,
156 StatsFactory
$statsFactory
158 $options->assertRequiredOptions( self
::CONSTRUCTOR_OPTIONS
);
159 $this->updateRowsPerQuery
= $options->get( MainConfigNames
::UpdateRowsPerQuery
);
160 $this->expiryEnabled
= $options->get( MainConfigNames
::WatchlistExpiry
);
161 $this->maxExpiryDuration
= $options->get( MainConfigNames
::WatchlistExpiryMaxDuration
);
162 $this->watchlistPurgeRate
= $options->get( MainConfigNames
::WatchlistPurgeRate
);
164 $this->lbFactory
= $lbFactory;
165 $this->queueGroup
= $queueGroup;
166 $this->stash
= $stash;
167 $this->cache
= $cache;
168 $this->readOnlyMode
= $readOnlyMode;
169 $this->deferredUpdatesAddCallableUpdateCallback
=
170 [ DeferredUpdates
::class, 'addCallableUpdate' ];
171 $this->nsInfo
= $nsInfo;
172 $this->revisionLookup
= $revisionLookup;
173 $this->linkBatchFactory
= $linkBatchFactory;
174 $this->statsFactory
= $statsFactory;
176 $this->latestUpdateCache
= new HashBagOStuff( [ 'maxKeys' => 3 ] );
180 * Overrides the DeferredUpdates::addCallableUpdate callback
181 * This is intended for use while testing and will fail if MW_PHPUNIT_TEST is not defined.
183 * @param callable $callback
185 * @see DeferredUpdates::addCallableUpdate for callback signiture
187 * @return ScopedCallback to reset the overridden value
189 public function overrideDeferredUpdatesAddCallableUpdateCallback( callable
$callback ) {
190 if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
191 throw new LogicException(
192 'Cannot override DeferredUpdates::addCallableUpdate callback in operation.'
195 $previousValue = $this->deferredUpdatesAddCallableUpdateCallback
;
196 $this->deferredUpdatesAddCallableUpdateCallback
= $callback;
197 return new ScopedCallback( function () use ( $previousValue ) {
198 $this->deferredUpdatesAddCallableUpdateCallback
= $previousValue;
203 * @param UserIdentity $user
204 * @param LinkTarget|PageIdentity $target
207 private function getCacheKey( UserIdentity
$user, $target ): string {
208 return $this->cache
->makeKey(
209 (string)$target->getNamespace(),
211 (string)$user->getId()
215 private function cache( WatchedItem
$item ) {
216 $user = $item->getUserIdentity();
217 $target = $item->getTarget();
218 $key = $this->getCacheKey( $user, $target );
219 $this->cache
->set( $key, $item );
220 $this->cacheIndex
[$target->getNamespace()][$target->getDBkey()][$user->getId()] = $key;
221 $this->statsFactory
->getCounter( 'WatchedItemStore_cache_total' )
222 ->copyToStatsdAt( 'WatchedItemStore.cache' )
227 * @param UserIdentity $user
228 * @param LinkTarget|PageIdentity $target
230 private function uncache( UserIdentity
$user, $target ) {
231 $this->cache
->delete( $this->getCacheKey( $user, $target ) );
232 unset( $this->cacheIndex
[$target->getNamespace()][$target->getDBkey()][$user->getId()] );
233 $this->statsFactory
->getCounter( 'WatchedItemStore_uncache_total' )
234 ->copyToStatsdAt( 'WatchedItemStore.uncache' )
239 * @param LinkTarget|PageIdentity $target
241 private function uncacheLinkTarget( $target ) {
242 $this->statsFactory
->getCounter( 'WatchedItemStore_uncacheLinkTarget_total' )
243 ->copyToStatsdAt( 'WatchedItemStore.uncacheLinkTarget' )
245 if ( !isset( $this->cacheIndex
[$target->getNamespace()][$target->getDBkey()] ) ) {
249 $uncacheLinkTargetItemsTotal = $this->statsFactory
250 ->getCounter( 'WatchedItemStore_uncacheLinkTarget_items_total' )
251 ->copyToStatsdAt( 'WatchedItemStore.uncacheLinkTarget.items' );
253 foreach ( $this->cacheIndex
[$target->getNamespace()][$target->getDBkey()] as $key ) {
254 $uncacheLinkTargetItemsTotal->increment();
255 $this->cache
->delete( $key );
259 private function uncacheUser( UserIdentity
$user ) {
260 $this->statsFactory
->getCounter( 'WatchedItemStore_uncacheUser_total' )
261 ->copyToStatsdAt( 'WatchedItemStore.uncacheUser' )
263 $uncacheUserItemsTotal = $this->statsFactory
->getCounter( 'WatchedItemStore_uncacheUser_items_total' )
264 ->copyToStatsdAt( 'WatchedItemStore.uncacheUser.items' );
266 foreach ( $this->cacheIndex
as $dbKeyArray ) {
267 foreach ( $dbKeyArray as $userArray ) {
268 if ( isset( $userArray[$user->getId()] ) ) {
269 $uncacheUserItemsTotal->increment();
270 $this->cache
->delete( $userArray[$user->getId()] );
275 $pageSeenKey = $this->getPageSeenTimestampsKey( $user );
276 $this->latestUpdateCache
->delete( $pageSeenKey );
277 $this->stash
->delete( $pageSeenKey );
281 * @param UserIdentity $user
282 * @param LinkTarget|PageIdentity $target
284 * @return WatchedItem|false
286 private function getCached( UserIdentity
$user, $target ) {
287 return $this->cache
->get( $this->getCacheKey( $user, $target ) );
291 * Helper method to deduplicate logic around queries that need to be modified
292 * if watchlist expiration is enabled
294 * @param SelectQueryBuilder $queryBuilder
295 * @param IReadableDatabase $db
297 private function modifyQueryBuilderForExpiry(
298 SelectQueryBuilder
$queryBuilder,
299 IReadableDatabase
$db
301 if ( $this->expiryEnabled
) {
302 $queryBuilder->where( $db->expr( 'we_expiry', '=', null )->or( 'we_expiry', '>', $db->timestamp() ) );
303 $queryBuilder->leftJoin( 'watchlist_expiry', null, 'wl_id = we_item' );
308 * Deletes ALL watched items for the given user when under
309 * $updateRowsPerQuery entries exist.
313 * @param UserIdentity $user
315 * @return bool true on success, false when too many items are watched
317 public function clearUserWatchedItems( UserIdentity
$user ): bool {
318 if ( $this->mustClearWatchedItemsUsingJobQueue( $user ) ) {
322 $dbw = $this->lbFactory
->getPrimaryDatabase();
324 if ( $this->expiryEnabled
) {
325 $ticket = $this->lbFactory
->getEmptyTransactionTicket( __METHOD__
);
326 // First fetch the wl_ids.
327 $wlIds = $dbw->newSelectQueryBuilder()
329 ->from( 'watchlist' )
330 ->where( [ 'wl_user' => $user->getId() ] )
331 ->caller( __METHOD__
)
332 ->fetchFieldValues();
334 // Delete rows from both the watchlist and watchlist_expiry tables.
335 $dbw->newDeleteQueryBuilder()
336 ->deleteFrom( 'watchlist' )
337 ->where( [ 'wl_id' => $wlIds ] )
338 ->caller( __METHOD__
)->execute();
340 $dbw->newDeleteQueryBuilder()
341 ->deleteFrom( 'watchlist_expiry' )
342 ->where( [ 'we_item' => $wlIds ] )
343 ->caller( __METHOD__
)->execute();
345 $this->lbFactory
->commitAndWaitForReplication( __METHOD__
, $ticket );
347 $dbw->newDeleteQueryBuilder()
348 ->deleteFrom( 'watchlist' )
349 ->where( [ 'wl_user' => $user->getId() ] )
350 ->caller( __METHOD__
)->execute();
353 $this->uncacheAllItemsForUser( $user );
358 public function mustClearWatchedItemsUsingJobQueue( UserIdentity
$user ): bool {
359 return $this->countWatchedItems( $user ) > $this->updateRowsPerQuery
;
362 private function uncacheAllItemsForUser( UserIdentity
$user ) {
363 $userId = $user->getId();
364 foreach ( $this->cacheIndex
as $ns => $dbKeyIndex ) {
365 foreach ( $dbKeyIndex as $dbKey => $userIndex ) {
366 if ( array_key_exists( $userId, $userIndex ) ) {
367 $this->cache
->delete( $userIndex[$userId] );
368 unset( $this->cacheIndex
[$ns][$dbKey][$userId] );
373 // Cleanup empty cache keys
374 foreach ( $this->cacheIndex
as $ns => $dbKeyIndex ) {
375 foreach ( $dbKeyIndex as $dbKey => $userIndex ) {
376 if ( empty( $this->cacheIndex
[$ns][$dbKey] ) ) {
377 unset( $this->cacheIndex
[$ns][$dbKey] );
380 if ( empty( $this->cacheIndex
[$ns] ) ) {
381 unset( $this->cacheIndex
[$ns] );
387 * Queues a job that will clear the users watchlist using the Job Queue.
391 * @param UserIdentity $user
393 public function clearUserWatchedItemsUsingJobQueue( UserIdentity
$user ) {
394 $job = ClearUserWatchlistJob
::newForUser( $user, $this->getMaxId() );
395 $this->queueGroup
->push( $job );
401 public function maybeEnqueueWatchlistExpiryJob(): void
{
402 if ( !$this->expiryEnabled
) {
403 // No need to purge expired entries if there are none
407 $max = mt_getrandmax();
408 if ( mt_rand( 0, $max ) < $max * $this->watchlistPurgeRate
) {
409 // The higher the watchlist purge rate, the more likely we are to enqueue a job.
410 $this->queueGroup
->lazyPush( new WatchlistExpiryJob() );
416 * @return int The maximum current wl_id
418 public function getMaxId(): int {
419 return (int)$this->lbFactory
->getReplicaDatabase()->newSelectQueryBuilder()
420 ->select( 'MAX(wl_id)' )
421 ->from( 'watchlist' )
422 ->caller( __METHOD__
)
428 * @param UserIdentity $user
431 public function countWatchedItems( UserIdentity
$user ): int {
432 $dbr = $this->lbFactory
->getReplicaDatabase();
433 $queryBuilder = $dbr->newSelectQueryBuilder()
434 ->select( 'COUNT(*)' )
435 ->from( 'watchlist' )
436 ->where( [ 'wl_user' => $user->getId() ] )
437 ->caller( __METHOD__
);
439 $this->modifyQueryBuilderForExpiry( $queryBuilder, $dbr );
441 return (int)$queryBuilder->fetchField();
446 * @param LinkTarget|PageIdentity $target deprecated passing LinkTarget since 1.36
449 public function countWatchers( $target ): int {
450 $dbr = $this->lbFactory
->getReplicaDatabase();
451 $queryBuilder = $dbr->newSelectQueryBuilder()
452 ->select( 'COUNT(*)' )
453 ->from( 'watchlist' )
455 'wl_namespace' => $target->getNamespace(),
456 'wl_title' => $target->getDBkey()
458 ->caller( __METHOD__
);
460 $this->modifyQueryBuilderForExpiry( $queryBuilder, $dbr );
462 return (int)$queryBuilder->fetchField();
467 * @param LinkTarget|PageIdentity $target deprecated passing LinkTarget since 1.36
468 * @param string|int $threshold
471 public function countVisitingWatchers( $target, $threshold ): int {
472 $dbr = $this->lbFactory
->getReplicaDatabase();
473 $queryBuilder = $dbr->newSelectQueryBuilder()
474 ->select( 'COUNT(*)' )
475 ->from( 'watchlist' )
477 'wl_namespace' => $target->getNamespace(),
478 'wl_title' => $target->getDBkey(),
479 $dbr->expr( 'wl_notificationtimestamp', '>=', $dbr->timestamp( $threshold ) )
480 ->or( 'wl_notificationtimestamp', '=', null )
482 ->caller( __METHOD__
);
484 $this->modifyQueryBuilderForExpiry( $queryBuilder, $dbr );
486 return (int)$queryBuilder->fetchField();
490 * @param UserIdentity $user
491 * @param LinkTarget[]|PageIdentity[] $titles deprecated passing LinkTarget[] since 1.36
494 public function removeWatchBatchForUser( UserIdentity
$user, array $titles ): bool {
495 if ( !$user->isRegistered() ||
$this->readOnlyMode
->isReadOnly() ) {
502 $rows = $this->getTitleDbKeysGroupedByNamespace( $titles );
503 $this->uncacheTitlesForUser( $user, $titles );
505 $dbw = $this->lbFactory
->getPrimaryDatabase();
506 $ticket = count( $titles ) > $this->updateRowsPerQuery ?
507 $this->lbFactory
->getEmptyTransactionTicket( __METHOD__
) : null;
510 // Batch delete items per namespace.
511 foreach ( $rows as $namespace => $namespaceTitles ) {
512 $rowBatches = array_chunk( $namespaceTitles, $this->updateRowsPerQuery
);
513 foreach ( $rowBatches as $toDelete ) {
514 // First fetch the wl_ids.
515 $wlIds = $dbw->newSelectQueryBuilder()
517 ->from( 'watchlist' )
520 'wl_user' => $user->getId(),
521 'wl_namespace' => $namespace,
522 'wl_title' => $toDelete
525 ->caller( __METHOD__
)
526 ->fetchFieldValues();
529 // Delete rows from both the watchlist and watchlist_expiry tables.
530 $dbw->newDeleteQueryBuilder()
531 ->deleteFrom( 'watchlist' )
532 ->where( [ 'wl_id' => $wlIds ] )
533 ->caller( __METHOD__
)->execute();
534 $affectedRows +
= $dbw->affectedRows();
536 if ( $this->expiryEnabled
) {
537 $dbw->newDeleteQueryBuilder()
538 ->deleteFrom( 'watchlist_expiry' )
539 ->where( [ 'we_item' => $wlIds ] )
540 ->caller( __METHOD__
)->execute();
541 $affectedRows +
= $dbw->affectedRows();
546 $this->lbFactory
->commitAndWaitForReplication( __METHOD__
, $ticket );
551 return (bool)$affectedRows;
556 * @param LinkTarget[]|PageIdentity[] $targets deprecated passing LinkTarget[] since 1.36
557 * @param array $options Supported options are:
558 * - 'minimumWatchers': filter for pages that have at least a minimum number of watchers
561 public function countWatchersMultiple( array $targets, array $options = [] ): array {
562 $linkTargets = array_map( static function ( $target ) {
563 if ( !$target instanceof LinkTarget
) {
564 return new TitleValue( $target->getNamespace(), $target->getDBkey() );
568 $lb = $this->linkBatchFactory
->newLinkBatch( $linkTargets );
569 $dbr = $this->lbFactory
->getReplicaDatabase();
570 $queryBuilder = $dbr->newSelectQueryBuilder();
572 ->select( [ 'wl_title', 'wl_namespace', 'watchers' => 'COUNT(*)' ] )
573 ->from( 'watchlist' )
574 ->where( [ $lb->constructSet( 'wl', $dbr ) ] )
575 ->groupBy( [ 'wl_namespace', 'wl_title' ] )
576 ->caller( __METHOD__
);
578 if ( array_key_exists( 'minimumWatchers', $options ) ) {
579 $queryBuilder->having( 'COUNT(*) >= ' . (int)$options['minimumWatchers'] );
582 $this->modifyQueryBuilderForExpiry( $queryBuilder, $dbr );
584 $res = $queryBuilder->fetchResultSet();
587 foreach ( $targets as $linkTarget ) {
588 $watchCounts[$linkTarget->getNamespace()][$linkTarget->getDBkey()] = 0;
591 foreach ( $res as $row ) {
592 $watchCounts[$row->wl_namespace
][$row->wl_title
] = (int)$row->watchers
;
600 * @param array $targetsWithVisitThresholds array of LinkTarget[]|PageIdentity[] (not type
601 * hinted since it annoys phan) - deprecated passing LinkTarget[] since 1.36
602 * @param int|null $minimumWatchers
603 * @return int[][] two dimensional array, first is namespace, second is database key,
604 * value is the number of watchers
606 public function countVisitingWatchersMultiple(
607 array $targetsWithVisitThresholds,
608 $minimumWatchers = null
610 if ( $targetsWithVisitThresholds === [] ) {
611 // No titles requested => no results returned
615 $dbr = $this->lbFactory
->getReplicaDatabase();
616 $queryBuilder = $dbr->newSelectQueryBuilder()
617 ->select( [ 'wl_namespace', 'wl_title', 'watchers' => 'COUNT(*)' ] )
618 ->from( 'watchlist' )
619 ->where( [ $this->getVisitingWatchersCondition( $dbr, $targetsWithVisitThresholds ) ] )
620 ->groupBy( [ 'wl_namespace', 'wl_title' ] )
621 ->caller( __METHOD__
);
622 if ( $minimumWatchers !== null ) {
623 $queryBuilder->having( 'COUNT(*) >= ' . (int)$minimumWatchers );
625 $this->modifyQueryBuilderForExpiry( $queryBuilder, $dbr );
627 $res = $queryBuilder->fetchResultSet();
630 foreach ( $targetsWithVisitThresholds as [ $target ] ) {
631 /** @var LinkTarget|PageIdentity $target */
632 $watcherCounts[$target->getNamespace()][$target->getDBkey()] = 0;
635 foreach ( $res as $row ) {
636 $watcherCounts[$row->wl_namespace
][$row->wl_title
] = (int)$row->watchers
;
639 return $watcherCounts;
643 * Generates condition for the query used in a batch count visiting watchers.
645 * @param IReadableDatabase $db
646 * @param array $targetsWithVisitThresholds array of pairs (LinkTarget|PageIdentity,
647 * last visit threshold) - deprecated passing LinkTarget since 1.36
650 private function getVisitingWatchersCondition(
651 IReadableDatabase
$db,
652 array $targetsWithVisitThresholds
654 $missingTargets = [];
655 $namespaceConds = [];
656 foreach ( $targetsWithVisitThresholds as [ $target, $threshold ] ) {
657 if ( $threshold === null ) {
658 $missingTargets[] = $target;
661 /** @var LinkTarget|PageIdentity $target */
662 $namespaceConds[$target->getNamespace()][] = $db->expr( 'wl_title', '=', $target->getDBkey() )
664 $db->expr( 'wl_notificationtimestamp', '>=', $db->timestamp( $threshold ) )
665 ->or( 'wl_notificationtimestamp', '=', null )
670 foreach ( $namespaceConds as $namespace => $pageConds ) {
671 $conds[] = $db->makeList( [
672 'wl_namespace = ' . $namespace,
673 '(' . $db->makeList( $pageConds, LIST_OR
) . ')'
677 if ( $missingTargets ) {
678 $lb = $this->linkBatchFactory
->newLinkBatch( $missingTargets );
679 $conds[] = $lb->constructSet( 'wl', $db );
682 return $db->makeList( $conds, LIST_OR
);
687 * @param UserIdentity $user
688 * @param LinkTarget|PageIdentity $target deprecated passing LinkTarget since 1.36
689 * @return WatchedItem|false
691 public function getWatchedItem( UserIdentity
$user, $target ) {
692 if ( !$user->isRegistered() ) {
696 $cached = $this->getCached( $user, $target );
697 if ( $cached && !$cached->isExpired() ) {
698 $this->statsFactory
->getCounter( 'WatchedItemStore_getWatchedItem_accesses_total' )
699 ->setLabel( 'status', 'hit' )
700 ->copyToStatsdAt( 'WatchedItemStore.getWatchedItem.cached' )
704 $this->statsFactory
->getCounter( 'WatchedItemStore_getWatchedItem_accesses_total' )
705 ->setLabel( 'status', 'miss' )
706 ->copyToStatsdAt( 'WatchedItemStore.getWatchedItem.load' )
708 return $this->loadWatchedItem( $user, $target );
713 * @param UserIdentity $user
714 * @param LinkTarget|PageIdentity $target deprecated passing LinkTarget since 1.36
715 * @return WatchedItem|false
717 public function loadWatchedItem( UserIdentity
$user, $target ) {
718 $item = $this->loadWatchedItemsBatch( $user, [ $target ] );
719 return $item ?
$item[0] : false;
724 * @param UserIdentity $user
725 * @param LinkTarget[]|PageIdentity[] $targets deprecated passing LinkTarget[] since 1.36
726 * @return WatchedItem[]|false
728 public function loadWatchedItemsBatch( UserIdentity
$user, array $targets ) {
729 // Only registered user can have a watchlist
730 if ( !$user->isRegistered() ) {
734 $dbr = $this->lbFactory
->getReplicaDatabase();
736 $rows = $this->fetchWatchedItems(
739 [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
749 foreach ( $rows as $row ) {
750 // TODO: convert to PageIdentity
751 $target = new TitleValue( (int)$row->wl_namespace
, $row->wl_title
);
752 $item = $this->getWatchedItemFromRow( $user, $target, $row );
753 $this->cache( $item );
762 * @param UserIdentity $user
763 * @param array $options Supported options are:
764 * - 'forWrite': whether to use the primary database instead of a replica
765 * - 'sort': how to sort the titles, either SORT_ASC or SORT_DESC
766 * - 'sortByExpiry': whether to also sort results by expiration, with temporarily watched titles
767 * above titles watched indefinitely and titles expiring soonest at the top
768 * @return WatchedItem[]
770 public function getWatchedItemsForUser( UserIdentity
$user, array $options = [] ): array {
771 $options +
= [ 'forWrite' => false ];
772 $vars = [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ];
774 if ( $options['forWrite'] ) {
775 $db = $this->lbFactory
->getPrimaryDatabase();
777 $db = $this->lbFactory
->getReplicaDatabase();
779 if ( array_key_exists( 'sort', $options ) ) {
781 ( in_array( $options['sort'], [ self
::SORT_ASC
, self
::SORT_DESC
] ) ),
782 '$options[\'sort\']',
783 'must be SORT_ASC or SORT_DESC'
785 $orderBy[] = "wl_namespace {$options['sort']}";
786 if ( $this->expiryEnabled
787 && array_key_exists( 'sortByExpiry', $options )
788 && $options['sortByExpiry']
790 // Add `wl_has_expiry` column to allow sorting by watched titles that have an expiration date first.
791 $vars['wl_has_expiry'] = $db->conditional( 'we_expiry IS NULL', '0', '1' );
792 // Display temporarily watched titles first.
793 // Order by expiration date, with the titles that will expire soonest at the top.
794 $orderBy[] = "wl_has_expiry DESC";
795 $orderBy[] = "we_expiry ASC";
798 $orderBy[] = "wl_title {$options['sort']}";
801 $res = $this->fetchWatchedItems(
809 foreach ( $res as $row ) {
810 // TODO: convert to PageIdentity
811 $target = new TitleValue( (int)$row->wl_namespace
, $row->wl_title
);
812 // @todo: Should we add these to the process cache?
813 $watchedItems[] = $this->getWatchedItemFromRow( $user, $target, $row );
816 return $watchedItems;
820 * Construct a new WatchedItem given a row from watchlist/watchlist_expiry.
821 * @param UserIdentity $user
822 * @param LinkTarget|PageIdentity $target deprecated passing LinkTarget since 1.36
823 * @param \stdClass $row
824 * @return WatchedItem
826 private function getWatchedItemFromRow(
831 return new WatchedItem(
834 $this->getLatestNotificationTimestamp(
835 $row->wl_notificationtimestamp
, $user, $target ),
836 wfTimestampOrNull( TS_ISO_8601
, $row->we_expiry ??
null )
841 * Fetches either a single or all watched items for the given user, or a specific set of items.
842 * If a $target is given, IDatabase::selectRow() is called, otherwise select().
843 * If $wgWatchlistExpiry is enabled, expired items are not returned.
845 * @param IReadableDatabase $db
846 * @param UserIdentity $user
847 * @param array $vars we_expiry is added when $wgWatchlistExpiry is enabled.
848 * @param array $orderBy array of columns
849 * @param LinkTarget|LinkTarget[]|PageIdentity|PageIdentity[]|null $target null if selecting all
850 * watched items - deprecated passing LinkTarget or LinkTarget[] since 1.36
851 * @return IResultWrapper|\stdClass|false
853 private function fetchWatchedItems(
854 IReadableDatabase
$db,
860 $dbMethod = 'select';
861 $queryBuilder = $db->newSelectQueryBuilder()
863 ->from( 'watchlist' )
864 ->where( [ 'wl_user' => $user->getId() ] )
865 ->caller( __METHOD__
);
867 if ( $target instanceof LinkTarget ||
$target instanceof PageIdentity
) {
868 $queryBuilder->where( [
869 'wl_namespace' => $target->getNamespace(),
870 'wl_title' => $target->getDBkey(),
872 $dbMethod = 'selectRow';
875 foreach ( $target as $linkTarget ) {
876 $titleConds[] = $db->makeList(
878 'wl_namespace' => $linkTarget->getNamespace(),
879 'wl_title' => $linkTarget->getDBkey(),
884 $queryBuilder->where( $db->makeList( $titleConds, $db::LIST_OR
) );
888 $this->modifyQueryBuilderForExpiry( $queryBuilder, $db );
889 if ( $this->expiryEnabled
) {
890 $queryBuilder->field( 'we_expiry' );
893 $queryBuilder->orderBy( $orderBy );
896 if ( $dbMethod == 'selectRow' ) {
897 return $queryBuilder->fetchRow();
899 return $queryBuilder->fetchResultSet();
904 * @param UserIdentity $user
905 * @param LinkTarget|PageIdentity $target deprecated passing LinkTarget since 1.36
908 public function isWatched( UserIdentity
$user, $target ): bool {
909 return (bool)$this->getWatchedItem( $user, $target );
913 * Check if the user is temporarily watching the page.
915 * @param UserIdentity $user
916 * @param LinkTarget|PageIdentity $target deprecated passing LinkTarget since 1.36
919 public function isTempWatched( UserIdentity
$user, $target ): bool {
920 $item = $this->getWatchedItem( $user, $target );
921 return $item && $item->getExpiry();
926 * @param UserIdentity $user
927 * @param LinkTarget[] $targets
928 * @return (string|null|false)[][] two dimensional array, first is namespace, second is database key,
929 * value is the notification timestamp or null, or false if not available
931 public function getNotificationTimestampsBatch( UserIdentity
$user, array $targets ): array {
933 foreach ( $targets as $target ) {
934 $timestamps[$target->getNamespace()][$target->getDBkey()] = false;
937 if ( !$user->isRegistered() ) {
942 foreach ( $targets as $target ) {
943 $cachedItem = $this->getCached( $user, $target );
945 $timestamps[$target->getNamespace()][$target->getDBkey()] =
946 $cachedItem->getNotificationTimestamp();
948 $targetsToLoad[] = $target;
952 if ( !$targetsToLoad ) {
956 $dbr = $this->lbFactory
->getReplicaDatabase();
958 $lb = $this->linkBatchFactory
->newLinkBatch( $targetsToLoad );
959 $res = $dbr->newSelectQueryBuilder()
960 ->select( [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ] )
961 ->from( 'watchlist' )
963 $lb->constructSet( 'wl', $dbr ),
964 'wl_user' => $user->getId(),
966 ->caller( __METHOD__
)
969 foreach ( $res as $row ) {
970 // TODO: convert to PageIdentity
971 $target = new TitleValue( (int)$row->wl_namespace
, $row->wl_title
);
972 $timestamps[$row->wl_namespace
][$row->wl_title
] =
973 $this->getLatestNotificationTimestamp(
974 $row->wl_notificationtimestamp
, $user, $target );
981 * @since 1.27 Method added.
982 * @since 1.35 Accepts $expiry parameter.
983 * @param UserIdentity $user
984 * @param LinkTarget|PageIdentity $target deprecated passing LinkTarget since 1.36
985 * @param string|null $expiry Optional expiry in any format acceptable to wfTimestamp().
986 * null will not create an expiry, or leave it unchanged should one already exist.
988 public function addWatch( UserIdentity
$user, $target, ?
string $expiry = null ) {
989 $this->addWatchBatchForUser( $user, [ $target ], $expiry );
991 if ( $this->expiryEnabled
&& !$expiry ) {
992 // When re-watching a page with a null $expiry, any existing expiry is left unchanged.
993 // However we must re-fetch the preexisting expiry or else the cached WatchedItem will
994 // incorrectly have a null expiry. Note that loadWatchedItem() does the caching.
996 $this->loadWatchedItem( $user, $target );
998 // Create a new WatchedItem and add it to the process cache.
999 // In this case we don't need to re-fetch the expiry.
1000 $expiry = ExpiryDef
::normalizeUsingMaxExpiry( $expiry, $this->maxExpiryDuration
, TS_ISO_8601
);
1001 $item = new WatchedItem(
1007 $this->cache( $item );
1012 * Add multiple items to the user's watchlist.
1013 * If you know you're adding a single page (and/or its talk page) use self::addWatch(),
1014 * since it will add the WatchedItem to the process cache.
1016 * @since 1.27 Method added.
1017 * @since 1.35 Accepts $expiry parameter.
1018 * @param UserIdentity $user
1019 * @param LinkTarget[] $targets
1020 * @param string|null $expiry Optional expiry in a format acceptable to wfTimestamp(),
1021 * null will not create expiries, or leave them unchanged should they already exist.
1022 * @return bool Whether database transactions were performed.
1024 public function addWatchBatchForUser(
1027 ?
string $expiry = null
1029 // Only registered user can have a watchlist
1030 if ( !$user->isRegistered() ||
$this->readOnlyMode
->isReadOnly() ) {
1037 $expiry = ExpiryDef
::normalizeUsingMaxExpiry( $expiry, $this->maxExpiryDuration
, TS_ISO_8601
);
1039 foreach ( $targets as $target ) {
1041 'wl_user' => $user->getId(),
1042 'wl_namespace' => $target->getNamespace(),
1043 'wl_title' => $target->getDBkey(),
1044 'wl_notificationtimestamp' => null,
1046 $this->uncache( $user, $target );
1049 $dbw = $this->lbFactory
->getPrimaryDatabase();
1050 $ticket = count( $targets ) > $this->updateRowsPerQuery ?
1051 $this->lbFactory
->getEmptyTransactionTicket( __METHOD__
) : null;
1053 $rowBatches = array_chunk( $rows, $this->updateRowsPerQuery
);
1054 foreach ( $rowBatches as $toInsert ) {
1055 // Use INSERT IGNORE to avoid overwriting the notification timestamp
1056 // if there's already an entry for this page
1057 $dbw->newInsertQueryBuilder()
1058 ->insertInto( 'watchlist' )
1061 ->caller( __METHOD__
)->execute();
1062 $affectedRows +
= $dbw->affectedRows();
1064 if ( $this->expiryEnabled
) {
1065 $affectedRows +
= $this->updateOrDeleteExpiries( $dbw, $user->getId(), $toInsert, $expiry );
1069 $this->lbFactory
->commitAndWaitForReplication( __METHOD__
, $ticket );
1073 return (bool)$affectedRows;
1077 * Insert/update expiries, or delete them if the expiry is 'infinity'.
1079 * @param IDatabase $dbw
1080 * @param int $userId
1081 * @param array $rows
1082 * @param string|null $expiry
1083 * @return int Number of affected rows.
1085 private function updateOrDeleteExpiries(
1089 ?
string $expiry = null
1092 // if expiry is null (shouldn't change), 0 rows affected.
1096 // Build the giant `(...) OR (...)` part to be used with WHERE.
1098 foreach ( $rows as $row ) {
1099 $conds[] = $dbw->makeList(
1101 'wl_user' => $userId,
1102 'wl_namespace' => $row['wl_namespace'],
1103 'wl_title' => $row['wl_title']
1108 $cond = $dbw->makeList( $conds, $dbw::LIST_OR
);
1110 if ( wfIsInfinity( $expiry ) ) {
1111 // Rows should be deleted rather than updated.
1121 return $dbw->affectedRows();
1124 return $this->updateExpiries( $dbw, $expiry, $cond );
1128 * Update the expiries for items found with the given $cond.
1129 * @param IDatabase $dbw
1130 * @param string $expiry
1131 * @param string $cond
1132 * @return int Number of affected rows.
1134 private function updateExpiries( IDatabase
$dbw, string $expiry, string $cond ): int {
1135 // First fetch the wl_ids from the watchlist table.
1136 // We'd prefer to do a INSERT/SELECT in the same query with IDatabase::insertSelect(),
1137 // but it doesn't allow us to use the "ON DUPLICATE KEY UPDATE" clause.
1138 $wlIds = $dbw->newSelectQueryBuilder()
1140 ->from( 'watchlist' )
1142 ->caller( __METHOD__
)
1143 ->fetchFieldValues();
1149 $expiry = $dbw->timestamp( $expiry );
1151 foreach ( $wlIds as $wlId ) {
1154 'we_expiry' => $expiry
1158 // Insert into watchlist_expiry, updating the expiry for duplicate rows.
1159 $dbw->newInsertQueryBuilder()
1160 ->insertInto( 'watchlist_expiry' )
1162 ->onDuplicateKeyUpdate()
1163 ->uniqueIndexFields( [ 'we_item' ] )
1164 ->set( [ 'we_expiry' => $expiry ] )
1165 ->caller( __METHOD__
)->execute();
1167 return $dbw->affectedRows();
1172 * @param UserIdentity $user
1173 * @param LinkTarget|PageIdentity $target deprecated passing LinkTarget since 1.36
1176 public function removeWatch( UserIdentity
$user, $target ): bool {
1177 return $this->removeWatchBatchForUser( $user, [ $target ] );
1181 * Set the "last viewed" timestamps for certain titles on a user's watchlist.
1183 * If the $targets parameter is omitted or set to [], this method simply wraps
1184 * resetAllNotificationTimestampsForUser(), and in that case you should instead call that method
1185 * directly; support for omitting $targets is for backwards compatibility.
1187 * If $targets is omitted or set to [], timestamps will be updated for every title on the user's
1188 * watchlist, and this will be done through a DeferredUpdate. If $targets is a non-empty array,
1189 * only the specified titles will be updated, and this will be done immediately (not deferred).
1192 * @param UserIdentity $user
1193 * @param string|int $timestamp Value to set the "last viewed" timestamp to (null to clear)
1194 * @param LinkTarget[] $targets Titles to set the timestamp for; [] means the entire watchlist
1197 public function setNotificationTimestampsForUser(
1202 // Only registered user can have a watchlist
1203 if ( !$user->isRegistered() ||
$this->readOnlyMode
->isReadOnly() ) {
1208 // Backwards compatibility
1209 $this->resetAllNotificationTimestampsForUser( $user, $timestamp );
1213 $rows = $this->getTitleDbKeysGroupedByNamespace( $targets );
1215 $dbw = $this->lbFactory
->getPrimaryDatabase();
1216 if ( $timestamp !== null ) {
1217 $timestamp = $dbw->timestamp( $timestamp );
1219 $ticket = $this->lbFactory
->getEmptyTransactionTicket( __METHOD__
);
1220 $affectedSinceWait = 0;
1222 // Batch update items per namespace
1223 foreach ( $rows as $namespace => $namespaceTitles ) {
1224 $rowBatches = array_chunk( $namespaceTitles, $this->updateRowsPerQuery
);
1225 foreach ( $rowBatches as $toUpdate ) {
1226 // First fetch the wl_ids.
1227 $wlIds = $dbw->newSelectQueryBuilder()
1229 ->from( 'watchlist' )
1231 'wl_user' => $user->getId(),
1232 'wl_namespace' => $namespace,
1233 'wl_title' => $toUpdate
1235 ->caller( __METHOD__
)
1236 ->fetchFieldValues();
1238 $wlIds = array_map( 'intval', $wlIds );
1239 $dbw->newUpdateQueryBuilder()
1240 ->update( 'watchlist' )
1241 ->set( [ 'wl_notificationtimestamp' => $timestamp ] )
1242 ->where( [ 'wl_id' => $wlIds ] )
1243 ->caller( __METHOD__
)->execute();
1245 $affectedSinceWait +
= $dbw->affectedRows();
1246 // Wait for replication every time we've touched updateRowsPerQuery rows
1247 if ( $affectedSinceWait >= $this->updateRowsPerQuery
) {
1248 $this->lbFactory
->commitAndWaitForReplication( __METHOD__
, $ticket );
1249 $affectedSinceWait = 0;
1255 $this->uncacheUser( $user );
1261 * @param string|null $timestamp
1262 * @param UserIdentity $user
1263 * @param LinkTarget|PageIdentity $target deprecated passing LinkTarget since 1.36
1264 * @return bool|string|null
1266 public function getLatestNotificationTimestamp(
1271 $timestamp = wfTimestampOrNull( TS_MW
, $timestamp );
1272 if ( $timestamp === null ) {
1273 return null; // no notification
1276 $seenTimestamps = $this->getPageSeenTimestamps( $user );
1277 if ( $seenTimestamps ) {
1278 $seenKey = $this->getPageSeenKey( $target );
1279 if ( isset( $seenTimestamps[$seenKey] ) && $seenTimestamps[$seenKey] >= $timestamp ) {
1280 // If a reset job did not yet run, then the "seen" timestamp will be higher
1289 * Schedule a DeferredUpdate that sets all of the "last viewed" timestamps for a given user
1290 * to the same value.
1291 * @param UserIdentity $user
1292 * @param string|int|null $timestamp Value to set all timestamps to, null to clear them
1294 public function resetAllNotificationTimestampsForUser( UserIdentity
$user, $timestamp = null ) {
1295 // Only registered user can have a watchlist
1296 if ( !$user->isRegistered() ) {
1300 // If the page is watched by the user (or may be watched), update the timestamp
1301 $job = new ClearWatchlistNotificationsJob( [
1302 'userId' => $user->getId(), 'timestamp' => $timestamp, 'casTime' => time()
1305 // Try to run this post-send
1306 // Calls DeferredUpdates::addCallableUpdate in normal operation
1308 $this->deferredUpdatesAddCallableUpdateCallback
,
1309 static function () use ( $job ) {
1316 * Update wl_notificationtimestamp for all watching users except the editor
1318 * @param UserIdentity $editor
1319 * @param LinkTarget|PageIdentity $target deprecated passing LinkTarget since 1.36
1320 * @param string|int $timestamp
1323 public function updateNotificationTimestamp(
1324 UserIdentity
$editor,
1328 $dbw = $this->lbFactory
->getPrimaryDatabase();
1329 $queryBuilder = $dbw->newSelectQueryBuilder()
1330 ->select( [ 'wl_id', 'wl_user' ] )
1331 ->from( 'watchlist' )
1334 'wl_user != ' . $editor->getId(),
1335 'wl_namespace' => $target->getNamespace(),
1336 'wl_title' => $target->getDBkey(),
1337 'wl_notificationtimestamp' => null,
1340 ->caller( __METHOD__
);
1342 $this->modifyQueryBuilderForExpiry( $queryBuilder, $dbw );
1344 $res = $queryBuilder->fetchResultSet();
1347 foreach ( $res as $row ) {
1348 $watchers[] = (int)$row->wl_user
;
1349 $wlIds[] = (int)$row->wl_id
;
1353 $fname = __METHOD__
;
1354 // Try to run this post-send
1355 // Calls DeferredUpdates::addCallableUpdate in normal operation
1357 $this->deferredUpdatesAddCallableUpdateCallback
,
1358 function () use ( $timestamp, $wlIds, $target, $fname ) {
1359 $dbw = $this->lbFactory
->getPrimaryDatabase();
1360 $ticket = $this->lbFactory
->getEmptyTransactionTicket( $fname );
1362 $wlIdsChunks = array_chunk( $wlIds, $this->updateRowsPerQuery
);
1363 foreach ( $wlIdsChunks as $wlIdsChunk ) {
1364 $dbw->newUpdateQueryBuilder()
1365 ->update( 'watchlist' )
1366 ->set( [ 'wl_notificationtimestamp' => $dbw->timestamp( $timestamp ) ] )
1367 ->where( [ 'wl_id' => $wlIdsChunk ] )
1368 ->caller( $fname )->execute();
1370 if ( count( $wlIdsChunks ) > 1 ) {
1371 $this->lbFactory
->commitAndWaitForReplication( $fname, $ticket );
1374 $this->uncacheLinkTarget( $target );
1376 DeferredUpdates
::POSTSEND
,
1386 * @param UserIdentity $user
1387 * @param LinkTarget|PageIdentity $title deprecated passing LinkTarget since 1.36
1388 * @param string $force
1392 public function resetNotificationTimestamp(
1400 // Only registered user can have a watchlist
1401 if ( !$user->isRegistered() ||
$this->readOnlyMode
->isReadOnly() ) {
1406 if ( $force != 'force' ) {
1407 $item = $this->getWatchedItem( $user, $title );
1408 if ( !$item ||
$item->getNotificationTimestamp() === null ) {
1413 // Get the timestamp (TS_MW) of this revision to track the latest one seen
1417 $latestRev = $this->revisionLookup
->getRevisionByTitle( $title );
1419 $id = $latestRev->getId();
1421 $seenTime = $latestRev->getTimestamp();
1424 if ( $seenTime === null ) {
1425 // @phan-suppress-next-line PhanTypeMismatchArgumentNullable getId does not return null here
1426 $seenTime = $this->revisionLookup
->getTimestampFromId( $id );
1429 // Mark the item as read immediately in lightweight storage
1430 $this->stash
->merge(
1431 $this->getPageSeenTimestampsKey( $user ),
1432 function ( $cache, $key, $current ) use ( $title, $seenTime ) {
1434 $value = new MapCacheLRU( 300 );
1435 } elseif ( is_array( $current ) ) {
1436 $value = MapCacheLRU
::newFromArray( $current, 300 );
1438 // Backwards compatibility for T282105
1441 $subKey = $this->getPageSeenKey( $title );
1443 if ( $seenTime > $value->get( $subKey ) ) {
1444 // Revision is newer than the last one seen
1445 $value->set( $subKey, $seenTime );
1447 $this->latestUpdateCache
->set( $key, $value->toArray(), BagOStuff
::TTL_PROC_LONG
);
1448 } elseif ( $seenTime === false ) {
1449 // Revision does not exist
1450 $value->set( $subKey, wfTimestamp( TS_MW
) );
1451 $this->latestUpdateCache
->set( $key,
1453 BagOStuff
::TTL_PROC_LONG
);
1455 return false; // nothing to update
1458 return $value->toArray();
1463 // If the page is watched by the user (or may be watched), update the timestamp
1464 // ActivityUpdateJob accepts both LinkTarget and PageReference
1465 $job = new ActivityUpdateJob(
1468 'type' => 'updateWatchlistNotification',
1469 'userid' => $user->getId(),
1470 'notifTime' => $this->getNotificationTimestamp( $user, $title, $item, $force, $oldid ),
1474 // Try to enqueue this post-send
1475 $this->queueGroup
->lazyPush( $job );
1477 $this->uncache( $user, $title );
1483 * @param UserIdentity $user
1484 * @return array|null The map contains prefixed title keys and TS_MW values
1486 private function getPageSeenTimestamps( UserIdentity
$user ) {
1487 $key = $this->getPageSeenTimestampsKey( $user );
1489 $cache = $this->latestUpdateCache
->getWithSetCallback(
1491 BagOStuff
::TTL_PROC_LONG
,
1492 function () use ( $key ) {
1493 return $this->stash
->get( $key ) ?
: null;
1496 // Backwards compatibility for T282105
1497 if ( $cache instanceof MapCacheLRU
) {
1498 $cache = $cache->toArray();
1503 private function getPageSeenTimestampsKey( UserIdentity
$user ): string {
1504 return $this->stash
->makeGlobalKey(
1505 'watchlist-recent-updates',
1506 $this->lbFactory
->getLocalDomainID(),
1512 * @param LinkTarget|PageIdentity $target
1515 private function getPageSeenKey( $target ): string {
1516 return "{$target->getNamespace()}:{$target->getDBkey()}";
1520 * @param UserIdentity $user
1521 * @param LinkTarget|PageIdentity $title deprecated passing LinkTarget since 1.36
1522 * @param WatchedItem|null $item
1523 * @param string $force
1524 * @param int|false $oldid The ID of the last revision that the user viewed
1525 * @return string|null|false
1527 private function getNotificationTimestamp(
1535 // No oldid given, assuming latest revision; clear the timestamp.
1539 $oldRev = $this->revisionLookup
->getRevisionById( $oldid );
1541 // Oldid given but does not exist (probably deleted)
1545 $nextRev = $this->revisionLookup
->getNextRevision( $oldRev );
1547 // Oldid given and is the latest revision for this title; clear the timestamp.
1551 $item ??
= $this->loadWatchedItem( $user, $title );
1553 // This can only happen if $force is enabled.
1557 // Oldid given and isn't the latest; update the timestamp.
1558 // This will result in no further notification emails being sent!
1559 $notificationTimestamp = $this->revisionLookup
->getTimestampFromId( $oldid );
1560 // @FIXME: this should use getTimestamp() for consistency with updates on new edits
1561 // $notificationTimestamp = $nextRev->getTimestamp(); // first unseen revision timestamp
1563 // We need to go one second to the future because of various strict comparisons
1564 // throughout the codebase
1565 $ts = new MWTimestamp( $notificationTimestamp );
1566 $ts->timestamp
->add( new DateInterval( 'PT1S' ) );
1567 $notificationTimestamp = $ts->getTimestamp( TS_MW
);
1569 if ( $notificationTimestamp < $item->getNotificationTimestamp() ) {
1570 if ( $force != 'force' ) {
1573 // This is a little silly…
1574 return $item->getNotificationTimestamp();
1578 return $notificationTimestamp;
1583 * @param UserIdentity $user
1584 * @param int|null $unreadLimit
1587 public function countUnreadNotifications( UserIdentity
$user, $unreadLimit = null ) {
1588 $queryBuilder = $this->lbFactory
->getReplicaDatabase()->newSelectQueryBuilder()
1590 ->from( 'watchlist' )
1592 'wl_user' => $user->getId(),
1593 'wl_notificationtimestamp IS NOT NULL'
1595 ->caller( __METHOD__
);
1596 if ( $unreadLimit !== null ) {
1597 $unreadLimit = (int)$unreadLimit;
1598 $queryBuilder->limit( $unreadLimit );
1601 $rowCount = $queryBuilder->fetchRowCount();
1603 if ( $unreadLimit === null ) {
1607 if ( $rowCount >= $unreadLimit ) {
1616 * @param LinkTarget|PageIdentity $oldTarget deprecated passing LinkTarget since 1.36
1617 * @param LinkTarget|PageIdentity $newTarget deprecated passing LinkTarget since 1.36
1619 public function duplicateAllAssociatedEntries( $oldTarget, $newTarget ) {
1620 // Duplicate first the subject page, then the talk page
1621 // TODO: convert to PageIdentity
1622 $this->duplicateEntry(
1623 new TitleValue( $this->nsInfo
->getSubject( $oldTarget->getNamespace() ), $oldTarget->getDBkey() ),
1624 new TitleValue( $this->nsInfo
->getSubject( $newTarget->getNamespace() ), $newTarget->getDBkey() )
1626 $this->duplicateEntry(
1627 new TitleValue( $this->nsInfo
->getTalk( $oldTarget->getNamespace() ), $oldTarget->getDBkey() ),
1628 new TitleValue( $this->nsInfo
->getTalk( $newTarget->getNamespace() ), $newTarget->getDBkey() )
1634 * @param LinkTarget|PageIdentity $oldTarget deprecated passing LinkTarget since 1.36
1635 * @param LinkTarget|PageIdentity $newTarget deprecated passing LinkTarget since 1.36
1637 public function duplicateEntry( $oldTarget, $newTarget ) {
1638 $dbw = $this->lbFactory
->getPrimaryDatabase();
1639 $result = $this->fetchWatchedItemsForPage( $dbw, $oldTarget );
1640 $newNamespace = $newTarget->getNamespace();
1641 $newDBkey = $newTarget->getDBkey();
1643 # Construct array to replace into the watchlist
1646 foreach ( $result as $row ) {
1648 'wl_user' => $row->wl_user
,
1649 'wl_namespace' => $newNamespace,
1650 'wl_title' => $newDBkey,
1651 'wl_notificationtimestamp' => $row->wl_notificationtimestamp
,
1654 if ( $this->expiryEnabled
&& $row->we_expiry
) {
1655 $expiries[$row->wl_user
] = $row->we_expiry
;
1663 // Perform a replace on the watchlist table rows.
1664 // Note that multi-row replace is very efficient for MySQL but may be inefficient for
1665 // some other DBMSes, mostly due to poor simulation by us.
1666 $dbw->newReplaceQueryBuilder()
1667 ->replaceInto( 'watchlist' )
1668 ->uniqueIndexFields( [ 'wl_user', 'wl_namespace', 'wl_title' ] )
1670 ->caller( __METHOD__
)->execute();
1672 if ( $this->expiryEnabled
) {
1673 $this->updateExpiriesAfterMove( $dbw, $expiries, $newNamespace, $newDBkey );
1678 * @param IReadableDatabase $dbr
1679 * @param LinkTarget|PageIdentity $target
1680 * @return IResultWrapper
1682 private function fetchWatchedItemsForPage(
1683 IReadableDatabase
$dbr,
1686 $queryBuilder = $dbr->newSelectQueryBuilder()
1687 ->select( [ 'wl_user', 'wl_notificationtimestamp' ] )
1688 ->from( 'watchlist' )
1690 'wl_namespace' => $target->getNamespace(),
1691 'wl_title' => $target->getDBkey(),
1693 ->caller( __METHOD__
)
1696 if ( $this->expiryEnabled
) {
1697 $queryBuilder->leftJoin( 'watchlist_expiry', null, [ 'wl_id = we_item' ] )
1698 ->field( 'we_expiry' );
1701 return $queryBuilder->fetchResultSet();
1705 * @param IDatabase $dbw
1706 * @param array $expiries
1707 * @param int $namespace
1708 * @param string $dbKey
1710 private function updateExpiriesAfterMove(
1716 DeferredUpdates
::addCallableUpdate(
1717 function ( $fname ) use ( $dbw, $expiries, $namespace, $dbKey ) {
1718 // First fetch new wl_ids.
1719 $res = $dbw->newSelectQueryBuilder()
1720 ->select( [ 'wl_user', 'wl_id' ] )
1721 ->from( 'watchlist' )
1723 'wl_namespace' => $namespace,
1724 'wl_title' => $dbKey,
1729 // Build new array to INSERT into multiple rows at once.
1731 foreach ( $res as $row ) {
1732 if ( !empty( $expiries[$row->wl_user
] ) ) {
1734 'we_item' => $row->wl_id
,
1735 'we_expiry' => $expiries[$row->wl_user
],
1740 // Batch the insertions.
1741 $batches = array_chunk( $expiryData, $this->updateRowsPerQuery
);
1742 foreach ( $batches as $toInsert ) {
1743 $dbw->newReplaceQueryBuilder()
1744 ->replaceInto( 'watchlist_expiry' )
1745 ->uniqueIndexFields( [ 'we_item' ] )
1751 DeferredUpdates
::POSTSEND
,
1757 * @param LinkTarget[]|PageIdentity[] $titles
1760 private function getTitleDbKeysGroupedByNamespace( array $titles ) {
1762 foreach ( $titles as $title ) {
1763 // Group titles by namespace.
1764 $rows[ $title->getNamespace() ][] = $title->getDBkey();
1770 * @param UserIdentity $user
1771 * @param LinkTarget[]|PageIdentity[] $titles
1773 private function uncacheTitlesForUser( UserIdentity
$user, array $titles ) {
1774 foreach ( $titles as $title ) {
1775 $this->uncache( $user, $title );
1782 public function countExpired(): int {
1783 $dbr = $this->lbFactory
->getReplicaDatabase();
1784 return $dbr->newSelectQueryBuilder()
1786 ->from( 'watchlist_expiry' )
1787 ->where( $dbr->expr( 'we_expiry', '<=', $dbr->timestamp() ) )
1788 ->caller( __METHOD__
)
1795 public function removeExpired( int $limit, bool $deleteOrphans = false ): void
{
1796 $dbr = $this->lbFactory
->getReplicaDatabase();
1797 $dbw = $this->lbFactory
->getPrimaryDatabase();
1798 $ticket = $this->lbFactory
->getEmptyTransactionTicket( __METHOD__
);
1800 // Get a batch of watchlist IDs to delete.
1801 $toDelete = $dbr->newSelectQueryBuilder()
1802 ->select( 'we_item' )
1803 ->from( 'watchlist_expiry' )
1804 ->where( $dbr->expr( 'we_expiry', '<=', $dbr->timestamp() ) )
1806 ->caller( __METHOD__
)
1807 ->fetchFieldValues();
1809 if ( count( $toDelete ) > 0 ) {
1810 // Delete them from the watchlist and watchlist_expiry table.
1811 $dbw->newDeleteQueryBuilder()
1812 ->deleteFrom( 'watchlist' )
1813 ->where( [ 'wl_id' => $toDelete ] )
1814 ->caller( __METHOD__
)->execute();
1815 $dbw->newDeleteQueryBuilder()
1816 ->deleteFrom( 'watchlist_expiry' )
1817 ->where( [ 'we_item' => $toDelete ] )
1818 ->caller( __METHOD__
)->execute();
1821 // Also delete any orphaned or null-expiry watchlist_expiry rows
1822 // (they should not exist, but might because not everywhere knows about the expiry table yet).
1823 if ( $deleteOrphans ) {
1824 $expiryToDelete = $dbr->newSelectQueryBuilder()
1825 ->select( 'we_item' )
1826 ->from( 'watchlist_expiry' )
1827 ->leftJoin( 'watchlist', null, 'wl_id = we_item' )
1828 ->where( $dbr->makeList(
1829 [ 'wl_id' => null, 'we_expiry' => null ],
1832 ->caller( __METHOD__
)
1833 ->fetchFieldValues();
1834 if ( count( $expiryToDelete ) > 0 ) {
1835 $dbw->newDeleteQueryBuilder()
1836 ->deleteFrom( 'watchlist_expiry' )
1837 ->where( [ 'we_item' => $expiryToDelete ] )
1838 ->caller( __METHOD__
)->execute();
1842 $this->lbFactory
->commitAndWaitForReplication( __METHOD__
, $ticket );
1845 /** @deprecated class alias since 1.43 */
1846 class_alias( WatchedItemStore
::class, 'WatchedItemStore' );