3 use Wikimedia\Rdbms\IDatabase
;
4 use Liuggio\StatsdClient\Factory\StatsdDataFactoryInterface
;
5 use MediaWiki\Linker\LinkTarget
;
6 use Wikimedia\Assert\Assert
;
7 use Wikimedia\ScopedCallback
;
8 use Wikimedia\Rdbms\ILBFactory
;
9 use Wikimedia\Rdbms\LoadBalancer
;
12 * Storage layer class for WatchedItems.
13 * Database interaction & caching
14 * TODO caching should be factored out into a CachingWatchedItemStore class
19 class WatchedItemStore
implements WatchedItemStoreInterface
, StatsdAwareInterface
{
29 private $loadBalancer;
44 private $readOnlyMode;
54 private $latestUpdateCache;
57 * @var array[] Looks like $cacheIndex[Namespace ID][Target DB Key][User Id] => 'key'
58 * The index is needed so that on mass changes all relevant items can be un-cached.
59 * For example: Clearing a users watchlist of all items or updating notification timestamps
60 * for all users watching a single target.
62 private $cacheIndex = [];
67 private $deferredUpdatesAddCallableUpdateCallback;
72 private $revisionGetTimestampFromIdCallback;
77 private $updateRowsPerQuery;
80 * @var StatsdDataFactoryInterface
85 * @param ILBFactory $lbFactory
86 * @param JobQueueGroup $queueGroup
87 * @param BagOStuff $stash
88 * @param HashBagOStuff $cache
89 * @param ReadOnlyMode $readOnlyMode
90 * @param int $updateRowsPerQuery
92 public function __construct(
93 ILBFactory
$lbFactory,
94 JobQueueGroup
$queueGroup,
97 ReadOnlyMode
$readOnlyMode,
100 $this->lbFactory
= $lbFactory;
101 $this->loadBalancer
= $lbFactory->getMainLB();
102 $this->queueGroup
= $queueGroup;
103 $this->stash
= $stash;
104 $this->cache
= $cache;
105 $this->readOnlyMode
= $readOnlyMode;
106 $this->stats
= new NullStatsdDataFactory();
107 $this->deferredUpdatesAddCallableUpdateCallback
=
108 [ DeferredUpdates
::class, 'addCallableUpdate' ];
109 $this->revisionGetTimestampFromIdCallback
=
110 [ Revision
::class, 'getTimestampFromId' ];
111 $this->updateRowsPerQuery
= $updateRowsPerQuery;
113 $this->latestUpdateCache
= new HashBagOStuff( [ 'maxKeys' => 3 ] );
117 * @param StatsdDataFactoryInterface $stats
119 public function setStatsdDataFactory( StatsdDataFactoryInterface
$stats ) {
120 $this->stats
= $stats;
124 * Overrides the DeferredUpdates::addCallableUpdate callback
125 * This is intended for use while testing and will fail if MW_PHPUNIT_TEST is not defined.
127 * @param callable $callback
129 * @see DeferredUpdates::addCallableUpdate for callback signiture
131 * @return ScopedCallback to reset the overridden value
132 * @throws MWException
134 public function overrideDeferredUpdatesAddCallableUpdateCallback( callable
$callback ) {
135 if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
136 throw new MWException(
137 'Cannot override DeferredUpdates::addCallableUpdate callback in operation.'
140 $previousValue = $this->deferredUpdatesAddCallableUpdateCallback
;
141 $this->deferredUpdatesAddCallableUpdateCallback
= $callback;
142 return new ScopedCallback( function () use ( $previousValue ) {
143 $this->deferredUpdatesAddCallableUpdateCallback
= $previousValue;
148 * Overrides the Revision::getTimestampFromId callback
149 * This is intended for use while testing and will fail if MW_PHPUNIT_TEST is not defined.
151 * @param callable $callback
152 * @see Revision::getTimestampFromId for callback signiture
154 * @return ScopedCallback to reset the overridden value
155 * @throws MWException
157 public function overrideRevisionGetTimestampFromIdCallback( callable
$callback ) {
158 if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
159 throw new MWException(
160 'Cannot override Revision::getTimestampFromId callback in operation.'
163 $previousValue = $this->revisionGetTimestampFromIdCallback
;
164 $this->revisionGetTimestampFromIdCallback
= $callback;
165 return new ScopedCallback( function () use ( $previousValue ) {
166 $this->revisionGetTimestampFromIdCallback
= $previousValue;
170 private function getCacheKey( User
$user, LinkTarget
$target ) {
171 return $this->cache
->makeKey(
172 (string)$target->getNamespace(),
174 (string)$user->getId()
178 private function cache( WatchedItem
$item ) {
179 $user = $item->getUser();
180 $target = $item->getLinkTarget();
181 $key = $this->getCacheKey( $user, $target );
182 $this->cache
->set( $key, $item );
183 $this->cacheIndex
[$target->getNamespace()][$target->getDBkey()][$user->getId()] = $key;
184 $this->stats
->increment( 'WatchedItemStore.cache' );
187 private function uncache( User
$user, LinkTarget
$target ) {
188 $this->cache
->delete( $this->getCacheKey( $user, $target ) );
189 unset( $this->cacheIndex
[$target->getNamespace()][$target->getDBkey()][$user->getId()] );
190 $this->stats
->increment( 'WatchedItemStore.uncache' );
193 private function uncacheLinkTarget( LinkTarget
$target ) {
194 $this->stats
->increment( 'WatchedItemStore.uncacheLinkTarget' );
195 if ( !isset( $this->cacheIndex
[$target->getNamespace()][$target->getDBkey()] ) ) {
198 foreach ( $this->cacheIndex
[$target->getNamespace()][$target->getDBkey()] as $key ) {
199 $this->stats
->increment( 'WatchedItemStore.uncacheLinkTarget.items' );
200 $this->cache
->delete( $key );
204 private function uncacheUser( User
$user ) {
205 $this->stats
->increment( 'WatchedItemStore.uncacheUser' );
206 foreach ( $this->cacheIndex
as $ns => $dbKeyArray ) {
207 foreach ( $dbKeyArray as $dbKey => $userArray ) {
208 if ( isset( $userArray[$user->getId()] ) ) {
209 $this->stats
->increment( 'WatchedItemStore.uncacheUser.items' );
210 $this->cache
->delete( $userArray[$user->getId()] );
218 * @param LinkTarget $target
220 * @return WatchedItem|false
222 private function getCached( User
$user, LinkTarget
$target ) {
223 return $this->cache
->get( $this->getCacheKey( $user, $target ) );
227 * Return an array of conditions to select or update the appropriate database
231 * @param LinkTarget $target
235 private function dbCond( User
$user, LinkTarget
$target ) {
237 'wl_user' => $user->getId(),
238 'wl_namespace' => $target->getNamespace(),
239 'wl_title' => $target->getDBkey(),
244 * @param int $dbIndex DB_MASTER or DB_REPLICA
247 * @throws MWException
249 private function getConnectionRef( $dbIndex ) {
250 return $this->loadBalancer
->getConnectionRef( $dbIndex, [ 'watchlist' ] );
254 * Deletes ALL watched items for the given user when under
255 * $updateRowsPerQuery entries exist.
261 * @return bool true on success, false when too many items are watched
263 public function clearUserWatchedItems( User
$user ) {
264 if ( $this->countWatchedItems( $user ) > $this->updateRowsPerQuery
) {
268 $dbw = $this->loadBalancer
->getConnectionRef( DB_MASTER
);
271 [ 'wl_user' => $user->getId() ],
274 $this->uncacheAllItemsForUser( $user );
279 private function uncacheAllItemsForUser( User
$user ) {
280 $userId = $user->getId();
281 foreach ( $this->cacheIndex
as $ns => $dbKeyIndex ) {
282 foreach ( $dbKeyIndex as $dbKey => $userIndex ) {
283 if ( array_key_exists( $userId, $userIndex ) ) {
284 $this->cache
->delete( $userIndex[$userId] );
285 unset( $this->cacheIndex
[$ns][$dbKey][$userId] );
290 // Cleanup empty cache keys
291 foreach ( $this->cacheIndex
as $ns => $dbKeyIndex ) {
292 foreach ( $dbKeyIndex as $dbKey => $userIndex ) {
293 if ( empty( $this->cacheIndex
[$ns][$dbKey] ) ) {
294 unset( $this->cacheIndex
[$ns][$dbKey] );
297 if ( empty( $this->cacheIndex
[$ns] ) ) {
298 unset( $this->cacheIndex
[$ns] );
304 * Queues a job that will clear the users watchlist using the Job Queue.
310 public function clearUserWatchedItemsUsingJobQueue( User
$user ) {
311 $job = ClearUserWatchlistJob
::newForUser( $user, $this->getMaxId() );
312 $this->queueGroup
->push( $job );
317 * @return int The maximum current wl_id
319 public function getMaxId() {
320 $dbr = $this->getConnectionRef( DB_REPLICA
);
321 return (int)$dbr->selectField(
334 public function countWatchedItems( User
$user ) {
335 $dbr = $this->getConnectionRef( DB_REPLICA
);
336 $return = (int)$dbr->selectField(
340 'wl_user' => $user->getId()
350 * @param LinkTarget $target
353 public function countWatchers( LinkTarget
$target ) {
354 $dbr = $this->getConnectionRef( DB_REPLICA
);
355 $return = (int)$dbr->selectField(
359 'wl_namespace' => $target->getNamespace(),
360 'wl_title' => $target->getDBkey(),
370 * @param LinkTarget $target
371 * @param string|int $threshold
374 public function countVisitingWatchers( LinkTarget
$target, $threshold ) {
375 $dbr = $this->getConnectionRef( DB_REPLICA
);
376 $visitingWatchers = (int)$dbr->selectField(
380 'wl_namespace' => $target->getNamespace(),
381 'wl_title' => $target->getDBkey(),
382 'wl_notificationtimestamp >= ' .
383 $dbr->addQuotes( $dbr->timestamp( $threshold ) ) .
384 ' OR wl_notificationtimestamp IS NULL'
389 return $visitingWatchers;
394 * @param TitleValue[] $titles
396 * @throws MWException
398 public function removeWatchBatchForUser( User
$user, array $titles ) {
399 if ( $this->readOnlyMode
->isReadOnly() ) {
402 if ( $user->isAnon() ) {
409 $rows = $this->getTitleDbKeysGroupedByNamespace( $titles );
410 $this->uncacheTitlesForUser( $user, $titles );
412 $dbw = $this->getConnectionRef( DB_MASTER
);
413 $ticket = count( $titles ) > $this->updateRowsPerQuery ?
414 $this->lbFactory
->getEmptyTransactionTicket( __METHOD__
) : null;
417 // Batch delete items per namespace.
418 foreach ( $rows as $namespace => $namespaceTitles ) {
419 $rowBatches = array_chunk( $namespaceTitles, $this->updateRowsPerQuery
);
420 foreach ( $rowBatches as $toDelete ) {
421 $dbw->delete( 'watchlist', [
422 'wl_user' => $user->getId(),
423 'wl_namespace' => $namespace,
424 'wl_title' => $toDelete
426 $affectedRows +
= $dbw->affectedRows();
428 $this->lbFactory
->commitAndWaitForReplication( __METHOD__
, $ticket );
433 return (bool)$affectedRows;
438 * @param LinkTarget[] $targets
439 * @param array $options
442 public function countWatchersMultiple( array $targets, array $options = [] ) {
443 $dbOptions = [ 'GROUP BY' => [ 'wl_namespace', 'wl_title' ] ];
445 $dbr = $this->getConnectionRef( DB_REPLICA
);
447 if ( array_key_exists( 'minimumWatchers', $options ) ) {
448 $dbOptions['HAVING'] = 'COUNT(*) >= ' . (int)$options['minimumWatchers'];
451 $lb = new LinkBatch( $targets );
454 [ 'wl_title', 'wl_namespace', 'watchers' => 'COUNT(*)' ],
455 [ $lb->constructSet( 'wl', $dbr ) ],
461 foreach ( $targets as $linkTarget ) {
462 $watchCounts[$linkTarget->getNamespace()][$linkTarget->getDBkey()] = 0;
465 foreach ( $res as $row ) {
466 $watchCounts[$row->wl_namespace
][$row->wl_title
] = (int)$row->watchers
;
474 * @param array $targetsWithVisitThresholds
475 * @param int|null $minimumWatchers
478 public function countVisitingWatchersMultiple(
479 array $targetsWithVisitThresholds,
480 $minimumWatchers = null
482 if ( $targetsWithVisitThresholds === [] ) {
483 // No titles requested => no results returned
487 $dbr = $this->getConnectionRef( DB_REPLICA
);
489 $conds = $this->getVisitingWatchersCondition( $dbr, $targetsWithVisitThresholds );
491 $dbOptions = [ 'GROUP BY' => [ 'wl_namespace', 'wl_title' ] ];
492 if ( $minimumWatchers !== null ) {
493 $dbOptions['HAVING'] = 'COUNT(*) >= ' . (int)$minimumWatchers;
497 [ 'wl_namespace', 'wl_title', 'watchers' => 'COUNT(*)' ],
504 foreach ( $targetsWithVisitThresholds as list( $target ) ) {
505 /* @var LinkTarget $target */
506 $watcherCounts[$target->getNamespace()][$target->getDBkey()] = 0;
509 foreach ( $res as $row ) {
510 $watcherCounts[$row->wl_namespace
][$row->wl_title
] = (int)$row->watchers
;
513 return $watcherCounts;
517 * Generates condition for the query used in a batch count visiting watchers.
519 * @param IDatabase $db
520 * @param array $targetsWithVisitThresholds array of pairs (LinkTarget, last visit threshold)
523 private function getVisitingWatchersCondition(
525 array $targetsWithVisitThresholds
527 $missingTargets = [];
528 $namespaceConds = [];
529 foreach ( $targetsWithVisitThresholds as list( $target, $threshold ) ) {
530 if ( $threshold === null ) {
531 $missingTargets[] = $target;
534 /* @var LinkTarget $target */
535 $namespaceConds[$target->getNamespace()][] = $db->makeList( [
536 'wl_title = ' . $db->addQuotes( $target->getDBkey() ),
538 'wl_notificationtimestamp >= ' . $db->addQuotes( $db->timestamp( $threshold ) ),
539 'wl_notificationtimestamp IS NULL'
545 foreach ( $namespaceConds as $namespace => $pageConds ) {
546 $conds[] = $db->makeList( [
547 'wl_namespace = ' . $namespace,
548 '(' . $db->makeList( $pageConds, LIST_OR
) . ')'
552 if ( $missingTargets ) {
553 $lb = new LinkBatch( $missingTargets );
554 $conds[] = $lb->constructSet( 'wl', $db );
557 return $db->makeList( $conds, LIST_OR
);
563 * @param LinkTarget $target
566 public function getWatchedItem( User
$user, LinkTarget
$target ) {
567 if ( $user->isAnon() ) {
571 $cached = $this->getCached( $user, $target );
573 $this->stats
->increment( 'WatchedItemStore.getWatchedItem.cached' );
576 $this->stats
->increment( 'WatchedItemStore.getWatchedItem.load' );
577 return $this->loadWatchedItem( $user, $target );
583 * @param LinkTarget $target
584 * @return WatchedItem|bool
586 public function loadWatchedItem( User
$user, LinkTarget
$target ) {
587 // Only loggedin user can have a watchlist
588 if ( $user->isAnon() ) {
592 $dbr = $this->getConnectionRef( DB_REPLICA
);
594 $row = $dbr->selectRow(
596 'wl_notificationtimestamp',
597 $this->dbCond( $user, $target ),
605 $item = new WatchedItem(
608 $this->getLatestNotificationTimestamp( $row->wl_notificationtimestamp
, $user, $target )
610 $this->cache( $item );
618 * @param array $options
619 * @return WatchedItem[]
621 public function getWatchedItemsForUser( User
$user, array $options = [] ) {
622 $options +
= [ 'forWrite' => false ];
625 if ( array_key_exists( 'sort', $options ) ) {
627 ( in_array( $options['sort'], [ self
::SORT_ASC
, self
::SORT_DESC
] ) ),
628 '$options[\'sort\']',
629 'must be SORT_ASC or SORT_DESC'
631 $dbOptions['ORDER BY'] = [
632 "wl_namespace {$options['sort']}",
633 "wl_title {$options['sort']}"
636 $db = $this->getConnectionRef( $options['forWrite'] ? DB_MASTER
: DB_REPLICA
);
640 [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
641 [ 'wl_user' => $user->getId() ],
647 foreach ( $res as $row ) {
648 $target = new TitleValue( (int)$row->wl_namespace
, $row->wl_title
);
649 // @todo: Should we add these to the process cache?
650 $watchedItems[] = new WatchedItem(
652 new TitleValue( (int)$row->wl_namespace
, $row->wl_title
),
653 $this->getLatestNotificationTimestamp(
654 $row->wl_notificationtimestamp
, $user, $target )
658 return $watchedItems;
664 * @param LinkTarget $target
667 public function isWatched( User
$user, LinkTarget
$target ) {
668 return (bool)$this->getWatchedItem( $user, $target );
674 * @param LinkTarget[] $targets
677 public function getNotificationTimestampsBatch( User
$user, array $targets ) {
679 foreach ( $targets as $target ) {
680 $timestamps[$target->getNamespace()][$target->getDBkey()] = false;
683 if ( $user->isAnon() ) {
688 foreach ( $targets as $target ) {
689 $cachedItem = $this->getCached( $user, $target );
691 $timestamps[$target->getNamespace()][$target->getDBkey()] =
692 $cachedItem->getNotificationTimestamp();
694 $targetsToLoad[] = $target;
698 if ( !$targetsToLoad ) {
702 $dbr = $this->getConnectionRef( DB_REPLICA
);
704 $lb = new LinkBatch( $targetsToLoad );
707 [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
709 $lb->constructSet( 'wl', $dbr ),
710 'wl_user' => $user->getId(),
715 foreach ( $res as $row ) {
716 $target = new TitleValue( (int)$row->wl_namespace
, $row->wl_title
);
717 $timestamps[$row->wl_namespace
][$row->wl_title
] =
718 $this->getLatestNotificationTimestamp(
719 $row->wl_notificationtimestamp
, $user, $target );
728 * @param LinkTarget $target
729 * @throws MWException
731 public function addWatch( User
$user, LinkTarget
$target ) {
732 $this->addWatchBatchForUser( $user, [ $target ] );
738 * @param LinkTarget[] $targets
740 * @throws MWException
742 public function addWatchBatchForUser( User
$user, array $targets ) {
743 if ( $this->readOnlyMode
->isReadOnly() ) {
746 // Only logged-in user can have a watchlist
747 if ( $user->isAnon() ) {
757 foreach ( $targets as $target ) {
759 'wl_user' => $user->getId(),
760 'wl_namespace' => $target->getNamespace(),
761 'wl_title' => $target->getDBkey(),
762 'wl_notificationtimestamp' => null,
764 $items[] = new WatchedItem(
769 $this->uncache( $user, $target );
772 $dbw = $this->getConnectionRef( DB_MASTER
);
773 $ticket = count( $targets ) > $this->updateRowsPerQuery ?
774 $this->lbFactory
->getEmptyTransactionTicket( __METHOD__
) : null;
776 $rowBatches = array_chunk( $rows, $this->updateRowsPerQuery
);
777 foreach ( $rowBatches as $toInsert ) {
778 // Use INSERT IGNORE to avoid overwriting the notification timestamp
779 // if there's already an entry for this page
780 $dbw->insert( 'watchlist', $toInsert, __METHOD__
, 'IGNORE' );
781 $affectedRows +
= $dbw->affectedRows();
783 $this->lbFactory
->commitAndWaitForReplication( __METHOD__
, $ticket );
786 // Update process cache to ensure skin doesn't claim that the current
787 // page is unwatched in the response of action=watch itself (T28292).
788 // This would otherwise be re-queried from a replica by isWatched().
789 foreach ( $items as $item ) {
790 $this->cache( $item );
793 return (bool)$affectedRows;
799 * @param LinkTarget $target
801 * @throws MWException
803 public function removeWatch( User
$user, LinkTarget
$target ) {
804 return $this->removeWatchBatchForUser( $user, [ $target ] );
810 * @param string|int $timestamp
811 * @param LinkTarget[] $targets
814 public function setNotificationTimestampsForUser( User
$user, $timestamp, array $targets = [] ) {
815 // Only loggedin user can have a watchlist
816 if ( $user->isAnon() ) {
820 $dbw = $this->getConnectionRef( DB_MASTER
);
822 $conds = [ 'wl_user' => $user->getId() ];
824 $batch = new LinkBatch( $targets );
825 $conds[] = $batch->constructSet( 'wl', $dbw );
828 if ( $timestamp !== null ) {
829 $timestamp = $dbw->timestamp( $timestamp );
834 [ 'wl_notificationtimestamp' => $timestamp ],
839 $this->uncacheUser( $user );
844 public function getLatestNotificationTimestamp( $timestamp, User
$user, LinkTarget
$target ) {
845 $timestamp = wfTimestampOrNull( TS_MW
, $timestamp );
846 if ( $timestamp === null ) {
847 return null; // no notification
850 $seenTimestamps = $this->getPageSeenTimestamps( $user );
853 $seenTimestamps->get( $this->getPageSeenKey( $target ) ) >= $timestamp
855 // If a reset job did not yet run, then the "seen" timestamp will be higher
862 public function resetAllNotificationTimestampsForUser( User
$user ) {
863 // Only loggedin user can have a watchlist
864 if ( $user->isAnon() ) {
868 // If the page is watched by the user (or may be watched), update the timestamp
869 $job = new ClearWatchlistNotificationsJob(
870 $user->getUserPage(),
871 [ 'userId' => $user->getId(), 'casTime' => time() ]
874 // Try to run this post-send
875 // Calls DeferredUpdates::addCallableUpdate in normal operation
877 $this->deferredUpdatesAddCallableUpdateCallback
,
878 function () use ( $job ) {
886 * @param User $editor
887 * @param LinkTarget $target
888 * @param string|int $timestamp
891 public function updateNotificationTimestamp( User
$editor, LinkTarget
$target, $timestamp ) {
892 $dbw = $this->getConnectionRef( DB_MASTER
);
893 $uids = $dbw->selectFieldValues(
897 'wl_user != ' . intval( $editor->getId() ),
898 'wl_namespace' => $target->getNamespace(),
899 'wl_title' => $target->getDBkey(),
900 'wl_notificationtimestamp IS NULL',
905 $watchers = array_map( 'intval', $uids );
907 // Update wl_notificationtimestamp for all watching users except the editor
909 DeferredUpdates
::addCallableUpdate(
910 function () use ( $timestamp, $watchers, $target, $fname ) {
911 $dbw = $this->getConnectionRef( DB_MASTER
);
912 $ticket = $this->lbFactory
->getEmptyTransactionTicket( $fname );
914 $watchersChunks = array_chunk( $watchers, $this->updateRowsPerQuery
);
915 foreach ( $watchersChunks as $watchersChunk ) {
916 $dbw->update( 'watchlist',
918 'wl_notificationtimestamp' => $dbw->timestamp( $timestamp )
919 ], [ /* WHERE - TODO Use wl_id T130067 */
920 'wl_user' => $watchersChunk,
921 'wl_namespace' => $target->getNamespace(),
922 'wl_title' => $target->getDBkey(),
925 if ( count( $watchersChunks ) > 1 ) {
926 $this->lbFactory
->commitAndWaitForReplication(
927 $fname, $ticket, [ 'domain' => $dbw->getDomainID() ]
931 $this->uncacheLinkTarget( $target );
933 DeferredUpdates
::POSTSEND
,
944 * @param Title $title
945 * @param string $force
949 public function resetNotificationTimestamp( User
$user, Title
$title, $force = '', $oldid = 0 ) {
952 // Only loggedin user can have a watchlist
953 if ( $this->readOnlyMode
->isReadOnly() ||
$user->isAnon() ) {
957 if ( !Hooks
::run( 'BeforeResetNotificationTimestamp', [ &$user, &$title, $force, &$oldid ] ) ) {
962 if ( $force != 'force' ) {
963 $item = $this->loadWatchedItem( $user, $title );
964 if ( !$item ||
$item->getNotificationTimestamp() === null ) {
969 // Mark the item as read immediately in lightweight storage
971 $this->getPageSeenTimestampsKey( $user ),
972 function ( $cache, $key, $current ) use ( $time, $title ) {
973 $value = $current ?
: new MapCacheLRU( 300 );
974 $value->set( $this->getPageSeenKey( $title ), wfTimestamp( TS_MW
, $time ) );
976 $this->latestUpdateCache
->set( $key, $value, IExpiringStore
::TTL_PROC_LONG
);
980 IExpiringStore
::TTL_HOUR
983 // If the page is watched by the user (or may be watched), update the timestamp
984 $job = new ActivityUpdateJob(
987 'type' => 'updateWatchlistNotification',
988 'userid' => $user->getId(),
989 'notifTime' => $this->getNotificationTimestamp( $user, $title, $item, $force, $oldid ),
993 // Try to enqueue this post-send
994 $this->queueGroup
->lazyPush( $job );
996 $this->uncache( $user, $title );
1003 * @return MapCacheLRU|null
1005 private function getPageSeenTimestamps( User
$user ) {
1006 $key = $this->getPageSeenTimestampsKey( $user );
1008 return $this->latestUpdateCache
->getWithSetCallback(
1010 IExpiringStore
::TTL_PROC_LONG
,
1011 function () use ( $key ) {
1012 return $this->stash
->get( $key ) ?
: null;
1021 private function getPageSeenTimestampsKey( User
$user ) {
1022 return $this->stash
->makeGlobalKey(
1023 'watchlist-recent-updates',
1024 $this->lbFactory
->getLocalDomainID(),
1030 * @param LinkTarget $target
1033 private function getPageSeenKey( LinkTarget
$target ) {
1034 return "{$target->getNamespace()}:{$target->getDBkey()}";
1037 private function getNotificationTimestamp( User
$user, Title
$title, $item, $force, $oldid ) {
1039 // No oldid given, assuming latest revision; clear the timestamp.
1043 if ( !$title->getNextRevisionID( $oldid ) ) {
1044 // Oldid given and is the latest revision for this title; clear the timestamp.
1048 if ( $item === null ) {
1049 $item = $this->loadWatchedItem( $user, $title );
1053 // This can only happen if $force is enabled.
1057 // Oldid given and isn't the latest; update the timestamp.
1058 // This will result in no further notification emails being sent!
1059 // Calls Revision::getTimestampFromId in normal operation
1060 $notificationTimestamp = call_user_func(
1061 $this->revisionGetTimestampFromIdCallback
,
1066 // We need to go one second to the future because of various strict comparisons
1067 // throughout the codebase
1068 $ts = new MWTimestamp( $notificationTimestamp );
1069 $ts->timestamp
->add( new DateInterval( 'PT1S' ) );
1070 $notificationTimestamp = $ts->getTimestamp( TS_MW
);
1072 if ( $notificationTimestamp < $item->getNotificationTimestamp() ) {
1073 if ( $force != 'force' ) {
1076 // This is a little silly…
1077 return $item->getNotificationTimestamp();
1081 return $notificationTimestamp;
1087 * @param int|null $unreadLimit
1090 public function countUnreadNotifications( User
$user, $unreadLimit = null ) {
1091 $dbr = $this->getConnectionRef( DB_REPLICA
);
1094 if ( $unreadLimit !== null ) {
1095 $unreadLimit = (int)$unreadLimit;
1096 $queryOptions['LIMIT'] = $unreadLimit;
1100 'wl_user' => $user->getId(),
1101 'wl_notificationtimestamp IS NOT NULL'
1104 $rowCount = $dbr->selectRowCount( 'watchlist', '1', $conds, __METHOD__
, $queryOptions );
1106 if ( $unreadLimit === null ) {
1110 if ( $rowCount >= $unreadLimit ) {
1119 * @param LinkTarget $oldTarget
1120 * @param LinkTarget $newTarget
1122 public function duplicateAllAssociatedEntries( LinkTarget
$oldTarget, LinkTarget
$newTarget ) {
1123 $oldTarget = Title
::newFromLinkTarget( $oldTarget );
1124 $newTarget = Title
::newFromLinkTarget( $newTarget );
1126 $this->duplicateEntry( $oldTarget->getSubjectPage(), $newTarget->getSubjectPage() );
1127 $this->duplicateEntry( $oldTarget->getTalkPage(), $newTarget->getTalkPage() );
1132 * @param LinkTarget $oldTarget
1133 * @param LinkTarget $newTarget
1135 public function duplicateEntry( LinkTarget
$oldTarget, LinkTarget
$newTarget ) {
1136 $dbw = $this->getConnectionRef( DB_MASTER
);
1138 $result = $dbw->select(
1140 [ 'wl_user', 'wl_notificationtimestamp' ],
1142 'wl_namespace' => $oldTarget->getNamespace(),
1143 'wl_title' => $oldTarget->getDBkey(),
1149 $newNamespace = $newTarget->getNamespace();
1150 $newDBkey = $newTarget->getDBkey();
1152 # Construct array to replace into the watchlist
1154 foreach ( $result as $row ) {
1156 'wl_user' => $row->wl_user
,
1157 'wl_namespace' => $newNamespace,
1158 'wl_title' => $newDBkey,
1159 'wl_notificationtimestamp' => $row->wl_notificationtimestamp
,
1163 if ( !empty( $values ) ) {
1165 # Note that multi-row replace is very efficient for MySQL but may be inefficient for
1166 # some other DBMSes, mostly due to poor simulation by us
1169 [ [ 'wl_user', 'wl_namespace', 'wl_title' ] ],
1177 * @param TitleValue[] $titles
1180 private function getTitleDbKeysGroupedByNamespace( array $titles ) {
1182 foreach ( $titles as $title ) {
1183 // Group titles by namespace.
1184 $rows[ $title->getNamespace() ][] = $title->getDBkey();
1191 * @param Title[] $titles
1193 private function uncacheTitlesForUser( User
$user, array $titles ) {
1194 foreach ( $titles as $title ) {
1195 $this->uncache( $user, $title );