MediaWiki  master
WatchedItemStore.php
Go to the documentation of this file.
1 <?php
2 
3 use Liuggio\StatsdClient\Factory\StatsdDataFactoryInterface;
12 use Wikimedia\Assert\Assert;
18 use Wikimedia\ScopedCallback;
19 
29 
33  public const CONSTRUCTOR_OPTIONS = [
34  'UpdateRowsPerQuery',
35  'WatchlistExpiry',
36  'WatchlistExpiryMaxDuration',
37  ];
38 
42  private $lbFactory;
43 
47  private $loadBalancer;
48 
52  private $queueGroup;
53 
57  private $stash;
58 
62  private $readOnlyMode;
63 
67  private $cache;
68 
73 
81  private $cacheIndex = [];
82 
87 
92 
96  private $nsInfo;
97 
102 
106  private $stats;
107 
111  private $expiryEnabled;
112 
116  private $hookRunner;
117 
122 
124  private $userFactory;
125 
130 
144  public function __construct(
145  ServiceOptions $options,
153  HookContainer $hookContainer,
156  ) {
157  $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
158  $this->updateRowsPerQuery = $options->get( 'UpdateRowsPerQuery' );
159  $this->expiryEnabled = $options->get( 'WatchlistExpiry' );
160  $this->maxExpiryDuration = $options->get( 'WatchlistExpiryMaxDuration' );
161 
162  $this->lbFactory = $lbFactory;
163  $this->loadBalancer = $lbFactory->getMainLB();
164  $this->queueGroup = $queueGroup;
165  $this->stash = $stash;
166  $this->cache = $cache;
167  $this->readOnlyMode = $readOnlyMode;
168  $this->stats = new NullStatsdDataFactory();
169  $this->deferredUpdatesAddCallableUpdateCallback =
170  [ DeferredUpdates::class, 'addCallableUpdate' ];
171  $this->nsInfo = $nsInfo;
172  $this->revisionLookup = $revisionLookup;
173  $this->hookRunner = new HookRunner( $hookContainer );
174  $this->linkBatchFactory = $linkBatchFactory;
175  $this->userFactory = $userFactory;
176 
177  $this->latestUpdateCache = new HashBagOStuff( [ 'maxKeys' => 3 ] );
178  }
179 
183  public function setStatsdDataFactory( StatsdDataFactoryInterface $stats ) {
184  $this->stats = $stats;
185  }
186 
198  public function overrideDeferredUpdatesAddCallableUpdateCallback( callable $callback ) {
199  if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
200  throw new MWException(
201  'Cannot override DeferredUpdates::addCallableUpdate callback in operation.'
202  );
203  }
205  $this->deferredUpdatesAddCallableUpdateCallback = $callback;
206  return new ScopedCallback( function () use ( $previousValue ) {
207  $this->deferredUpdatesAddCallableUpdateCallback = $previousValue;
208  } );
209  }
210 
211  private function getCacheKey( UserIdentity $user, LinkTarget $target ) {
212  return $this->cache->makeKey(
213  (string)$target->getNamespace(),
214  $target->getDBkey(),
215  (string)$user->getId()
216  );
217  }
218 
219  private function cache( WatchedItem $item ) {
220  $user = $item->getUserIdentity();
221  $target = $item->getLinkTarget();
222  $key = $this->getCacheKey( $user, $target );
223  $this->cache->set( $key, $item );
224  $this->cacheIndex[$target->getNamespace()][$target->getDBkey()][$user->getId()] = $key;
225  $this->stats->increment( 'WatchedItemStore.cache' );
226  }
227 
228  private function uncache( UserIdentity $user, LinkTarget $target ) {
229  $this->cache->delete( $this->getCacheKey( $user, $target ) );
230  unset( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()][$user->getId()] );
231  $this->stats->increment( 'WatchedItemStore.uncache' );
232  }
233 
234  private function uncacheLinkTarget( LinkTarget $target ) {
235  $this->stats->increment( 'WatchedItemStore.uncacheLinkTarget' );
236  if ( !isset( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()] ) ) {
237  return;
238  }
239  foreach ( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()] as $key ) {
240  $this->stats->increment( 'WatchedItemStore.uncacheLinkTarget.items' );
241  $this->cache->delete( $key );
242  }
243  }
244 
245  private function uncacheUser( UserIdentity $user ) {
246  $this->stats->increment( 'WatchedItemStore.uncacheUser' );
247  foreach ( $this->cacheIndex as $ns => $dbKeyArray ) {
248  foreach ( $dbKeyArray as $dbKey => $userArray ) {
249  if ( isset( $userArray[$user->getId()] ) ) {
250  $this->stats->increment( 'WatchedItemStore.uncacheUser.items' );
251  $this->cache->delete( $userArray[$user->getId()] );
252  }
253  }
254  }
255 
256  $pageSeenKey = $this->getPageSeenTimestampsKey( $user );
257  $this->latestUpdateCache->delete( $pageSeenKey );
258  $this->stash->delete( $pageSeenKey );
259  }
260 
267  private function getCached( UserIdentity $user, LinkTarget $target ) {
268  return $this->cache->get( $this->getCacheKey( $user, $target ) );
269  }
270 
276  private function getConnectionRef( $dbIndex ) {
277  return $this->loadBalancer->getConnectionRef( $dbIndex, [ 'watchlist' ] );
278  }
279 
290  public function clearUserWatchedItems( UserIdentity $user ) {
291  if ( $this->mustClearWatchedItemsUsingJobQueue( $user ) ) {
292  return false;
293  }
294 
295  $dbw = $this->loadBalancer->getConnectionRef( DB_MASTER );
296 
297  if ( $this->expiryEnabled ) {
298  $ticket = $this->lbFactory->getEmptyTransactionTicket( __METHOD__ );
299  // First fetch the wl_ids.
300  $wlIds = $dbw->selectFieldValues( 'watchlist', 'wl_id', [
301  'wl_user' => $user->getId()
302  ], __METHOD__ );
303 
304  if ( $wlIds ) {
305  // Delete rows from both the watchlist and watchlist_expiry tables.
306  $dbw->delete(
307  'watchlist',
308  [ 'wl_id' => $wlIds ],
309  __METHOD__
310  );
311 
312  $dbw->delete(
313  'watchlist_expiry',
314  [ 'we_item' => $wlIds ],
315  __METHOD__
316  );
317  }
318  $this->lbFactory->commitAndWaitForReplication( __METHOD__, $ticket );
319  } else {
320  $dbw->delete(
321  'watchlist',
322  [ 'wl_user' => $user->getId() ],
323  __METHOD__
324  );
325  }
326 
327  $this->uncacheAllItemsForUser( $user );
328 
329  return true;
330  }
331 
332  public function mustClearWatchedItemsUsingJobQueue( UserIdentity $user ): bool {
333  return $this->countWatchedItems( $user ) > $this->updateRowsPerQuery;
334  }
335 
336  private function uncacheAllItemsForUser( UserIdentity $user ) {
337  $userId = $user->getId();
338  foreach ( $this->cacheIndex as $ns => $dbKeyIndex ) {
339  foreach ( $dbKeyIndex as $dbKey => $userIndex ) {
340  if ( array_key_exists( $userId, $userIndex ) ) {
341  $this->cache->delete( $userIndex[$userId] );
342  unset( $this->cacheIndex[$ns][$dbKey][$userId] );
343  }
344  }
345  }
346 
347  // Cleanup empty cache keys
348  foreach ( $this->cacheIndex as $ns => $dbKeyIndex ) {
349  foreach ( $dbKeyIndex as $dbKey => $userIndex ) {
350  if ( empty( $this->cacheIndex[$ns][$dbKey] ) ) {
351  unset( $this->cacheIndex[$ns][$dbKey] );
352  }
353  }
354  if ( empty( $this->cacheIndex[$ns] ) ) {
355  unset( $this->cacheIndex[$ns] );
356  }
357  }
358  }
359 
368  $job = ClearUserWatchlistJob::newForUser( $user, $this->getMaxId() );
369  $this->queueGroup->push( $job );
370  }
371 
375  public function enqueueWatchlistExpiryJob( float $watchlistPurgeRate ): void {
376  $max = mt_getrandmax();
377  if ( mt_rand( 0, $max ) < $max * $watchlistPurgeRate ) {
378  // The higher the watchlist purge rate, the more likely we are to enqueue a job.
379  $this->queueGroup->push( new WatchlistExpiryJob() );
380  }
381  }
382 
387  public function getMaxId() {
388  $dbr = $this->getConnectionRef( DB_REPLICA );
389  return (int)$dbr->selectField(
390  'watchlist',
391  'MAX(wl_id)',
392  '',
393  __METHOD__
394  );
395  }
396 
402  public function countWatchedItems( UserIdentity $user ) {
403  $dbr = $this->getConnectionRef( DB_REPLICA );
404  $tables = [ 'watchlist' ];
405  $conds = [ 'wl_user' => $user->getId() ];
406  $joinConds = [];
407 
408  if ( $this->expiryEnabled ) {
409  $tables[] = 'watchlist_expiry';
410  $joinConds[ 'watchlist_expiry' ] = [ 'LEFT JOIN', 'wl_id = we_item' ];
411  $conds[] = 'we_expiry IS NULL OR we_expiry > ' . $dbr->addQuotes( $dbr->timestamp() );
412  }
413 
414  $return = (int)$dbr->selectField(
415  $tables,
416  'COUNT(*)',
417  $conds,
418  __METHOD__,
419  [],
420  $joinConds
421  );
422 
423  return $return;
424  }
425 
431  public function countWatchers( LinkTarget $target ) {
432  $dbr = $this->getConnectionRef( DB_REPLICA );
433  $tables = [ 'watchlist' ];
434  $conds = [
435  'wl_namespace' => $target->getNamespace(),
436  'wl_title' => $target->getDBkey()
437  ];
438  $joinConds = [];
439 
440  if ( $this->expiryEnabled ) {
441  $tables[] = 'watchlist_expiry';
442  $joinConds[ 'watchlist_expiry' ] = [ 'LEFT JOIN', 'wl_id = we_item' ];
443  $conds[] = 'we_expiry IS NULL OR we_expiry > ' . $dbr->addQuotes( $dbr->timestamp() );
444  }
445 
446  $return = (int)$dbr->selectField(
447  $tables,
448  'COUNT(*)',
449  $conds,
450  __METHOD__,
451  [],
452  $joinConds
453  );
454 
455  return $return;
456  }
457 
464  public function countVisitingWatchers( LinkTarget $target, $threshold ) {
465  $dbr = $this->getConnectionRef( DB_REPLICA );
466  $tables = [ 'watchlist' ];
467  $conds = [
468  'wl_namespace' => $target->getNamespace(),
469  'wl_title' => $target->getDBkey(),
470  'wl_notificationtimestamp >= ' .
471  $dbr->addQuotes( $dbr->timestamp( $threshold ) ) .
472  ' OR wl_notificationtimestamp IS NULL'
473  ];
474  $joinConds = [];
475 
476  if ( $this->expiryEnabled ) {
477  $tables[] = 'watchlist_expiry';
478  $joinConds[ 'watchlist_expiry' ] = [ 'LEFT JOIN', 'wl_id = we_item' ];
479  $conds[] = 'we_expiry IS NULL OR we_expiry > ' . $dbr->addQuotes( $dbr->timestamp() );
480  }
481 
482  $visitingWatchers = (int)$dbr->selectField(
483  $tables,
484  'COUNT(*)',
485  $conds,
486  __METHOD__,
487  [],
488  $joinConds
489  );
490 
491  return $visitingWatchers;
492  }
493 
499  public function removeWatchBatchForUser( UserIdentity $user, array $titles ) {
500  if ( $this->readOnlyMode->isReadOnly() ) {
501  return false;
502  }
503  if ( !$user->isRegistered() ) {
504  return false;
505  }
506  if ( !$titles ) {
507  return true;
508  }
509 
510  $rows = $this->getTitleDbKeysGroupedByNamespace( $titles );
511  $this->uncacheTitlesForUser( $user, $titles );
512 
513  $dbw = $this->getConnectionRef( DB_MASTER );
514  $ticket = count( $titles ) > $this->updateRowsPerQuery ?
515  $this->lbFactory->getEmptyTransactionTicket( __METHOD__ ) : null;
516  $affectedRows = 0;
517 
518  // Batch delete items per namespace.
519  foreach ( $rows as $namespace => $namespaceTitles ) {
520  $rowBatches = array_chunk( $namespaceTitles, $this->updateRowsPerQuery );
521  foreach ( $rowBatches as $toDelete ) {
522  // First fetch the wl_ids.
523  $wlIds = $dbw->selectFieldValues( 'watchlist', 'wl_id', [
524  'wl_user' => $user->getId(),
525  'wl_namespace' => $namespace,
526  'wl_title' => $toDelete
527  ], __METHOD__ );
528 
529  if ( $wlIds ) {
530  // Delete rows from both the watchlist and watchlist_expiry tables.
531  $dbw->delete(
532  'watchlist',
533  [ 'wl_id' => $wlIds ],
534  __METHOD__
535  );
536  $affectedRows += $dbw->affectedRows();
537 
538  if ( $this->expiryEnabled ) {
539  $dbw->delete(
540  'watchlist_expiry',
541  [ 'we_item' => $wlIds ],
542  __METHOD__
543  );
544  $affectedRows += $dbw->affectedRows();
545  }
546  }
547 
548  if ( $ticket ) {
549  $this->lbFactory->commitAndWaitForReplication( __METHOD__, $ticket );
550  }
551  }
552  }
553 
554  return (bool)$affectedRows;
555  }
556 
563  public function countWatchersMultiple( array $targets, array $options = [] ) {
564  $dbOptions = [ 'GROUP BY' => [ 'wl_namespace', 'wl_title' ] ];
565 
566  $dbr = $this->getConnectionRef( DB_REPLICA );
567 
568  if ( array_key_exists( 'minimumWatchers', $options ) ) {
569  $dbOptions['HAVING'] = 'COUNT(*) >= ' . (int)$options['minimumWatchers'];
570  }
571 
572  $lb = $this->linkBatchFactory->newLinkBatch( $targets );
573 
574  $tables = [ 'watchlist' ];
575  $conds = [ $lb->constructSet( 'wl', $dbr ) ];
576  $joinConds = [];
577 
578  if ( $this->expiryEnabled ) {
579  $tables[] = 'watchlist_expiry';
580  $joinConds[ 'watchlist_expiry' ] = [ 'LEFT JOIN', 'wl_id = we_item' ];
581  $conds[] = 'we_expiry IS NULL OR we_expiry > ' . $dbr->addQuotes( $dbr->timestamp() );
582  }
583 
584  $res = $dbr->select(
585  $tables,
586  [ 'wl_title', 'wl_namespace', 'watchers' => 'COUNT(*)' ],
587  $conds,
588  __METHOD__,
589  $dbOptions,
590  $joinConds
591  );
592 
593  $watchCounts = [];
594  foreach ( $targets as $linkTarget ) {
595  $watchCounts[$linkTarget->getNamespace()][$linkTarget->getDBkey()] = 0;
596  }
597 
598  foreach ( $res as $row ) {
599  $watchCounts[$row->wl_namespace][$row->wl_title] = (int)$row->watchers;
600  }
601 
602  return $watchCounts;
603  }
604 
612  array $targetsWithVisitThresholds,
613  $minimumWatchers = null
614  ) {
615  if ( $targetsWithVisitThresholds === [] ) {
616  // No titles requested => no results returned
617  return [];
618  }
619 
620  $dbr = $this->getConnectionRef( DB_REPLICA );
621 
622  $conds = [ $this->getVisitingWatchersCondition( $dbr, $targetsWithVisitThresholds ) ];
623 
624  $dbOptions = [ 'GROUP BY' => [ 'wl_namespace', 'wl_title' ] ];
625  if ( $minimumWatchers !== null ) {
626  $dbOptions['HAVING'] = 'COUNT(*) >= ' . (int)$minimumWatchers;
627  }
628 
629  $tables = [ 'watchlist' ];
630  $joinConds = [];
631 
632  if ( $this->expiryEnabled ) {
633  $tables[] = 'watchlist_expiry';
634  $joinConds[ 'watchlist_expiry' ] = [ 'LEFT JOIN', 'wl_id = we_item' ];
635  $conds[] = 'we_expiry IS NULL OR we_expiry > ' . $dbr->addQuotes( $dbr->timestamp() );
636  }
637 
638  $res = $dbr->select(
639  $tables,
640  [ 'wl_namespace', 'wl_title', 'watchers' => 'COUNT(*)' ],
641  $conds,
642  __METHOD__,
643  $dbOptions,
644  $joinConds
645  );
646 
647  $watcherCounts = [];
648  foreach ( $targetsWithVisitThresholds as list( $target ) ) {
650  $watcherCounts[$target->getNamespace()][$target->getDBkey()] = 0;
651  }
652 
653  foreach ( $res as $row ) {
654  $watcherCounts[$row->wl_namespace][$row->wl_title] = (int)$row->watchers;
655  }
656 
657  return $watcherCounts;
658  }
659 
668  IDatabase $db,
669  array $targetsWithVisitThresholds
670  ) {
671  $missingTargets = [];
672  $namespaceConds = [];
673  foreach ( $targetsWithVisitThresholds as list( $target, $threshold ) ) {
674  if ( $threshold === null ) {
675  $missingTargets[] = $target;
676  continue;
677  }
679  $namespaceConds[$target->getNamespace()][] = $db->makeList( [
680  'wl_title = ' . $db->addQuotes( $target->getDBkey() ),
681  $db->makeList( [
682  'wl_notificationtimestamp >= ' . $db->addQuotes( $db->timestamp( $threshold ) ),
683  'wl_notificationtimestamp IS NULL'
684  ], LIST_OR )
685  ], LIST_AND );
686  }
687 
688  $conds = [];
689  foreach ( $namespaceConds as $namespace => $pageConds ) {
690  $conds[] = $db->makeList( [
691  'wl_namespace = ' . $namespace,
692  '(' . $db->makeList( $pageConds, LIST_OR ) . ')'
693  ], LIST_AND );
694  }
695 
696  if ( $missingTargets ) {
697  $lb = $this->linkBatchFactory->newLinkBatch( $missingTargets );
698  $conds[] = $lb->constructSet( 'wl', $db );
699  }
700 
701  return $db->makeList( $conds, LIST_OR );
702  }
703 
710  public function getWatchedItem( UserIdentity $user, LinkTarget $target ) {
711  if ( !$user->isRegistered() ) {
712  return false;
713  }
714 
715  $cached = $this->getCached( $user, $target );
716  if ( $cached && !$cached->isExpired() ) {
717  $this->stats->increment( 'WatchedItemStore.getWatchedItem.cached' );
718  return $cached;
719  }
720  $this->stats->increment( 'WatchedItemStore.getWatchedItem.load' );
721  return $this->loadWatchedItem( $user, $target );
722  }
723 
730  public function loadWatchedItem( UserIdentity $user, LinkTarget $target ) {
731  $item = $this->loadWatchedItemsBatch( $user, [ $target ] );
732  return $item ? $item[0] : false;
733  }
734 
741  public function loadWatchedItemsBatch( UserIdentity $user, array $targets ) {
742  // Only registered user can have a watchlist
743  if ( !$user->isRegistered() ) {
744  return false;
745  }
746 
747  $dbr = $this->getConnectionRef( DB_REPLICA );
748 
749  $rows = $this->fetchWatchedItems(
750  $dbr,
751  $user,
752  [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
753  [],
754  $targets
755  );
756 
757  if ( !$rows ) {
758  return false;
759  }
760 
761  $items = [];
762  foreach ( $rows as $row ) {
763  $target = new TitleValue( (int)$row->wl_namespace, $row->wl_title );
764  $item = $this->getWatchedItemFromRow( $user, $target, $row );
765  $this->cache( $item );
766  $items[] = $item;
767  }
768 
769  return $items;
770  }
771 
778  public function getWatchedItemsForUser( UserIdentity $user, array $options = [] ) {
779  $options += [ 'forWrite' => false ];
780  $vars = [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ];
781  $dbOptions = [];
782  $db = $this->getConnectionRef( $options['forWrite'] ? DB_MASTER : DB_REPLICA );
783  if ( array_key_exists( 'sort', $options ) ) {
784  Assert::parameter(
785  ( in_array( $options['sort'], [ self::SORT_ASC, self::SORT_DESC ] ) ),
786  '$options[\'sort\']',
787  'must be SORT_ASC or SORT_DESC'
788  );
789  $dbOptions['ORDER BY'][] = "wl_namespace {$options['sort']}";
790  if ( $this->expiryEnabled
791  && array_key_exists( 'sortByExpiry', $options )
792  && $options['sortByExpiry']
793  ) {
794  // Add `wl_has_expiry` column to allow sorting by watched titles that have an expiration date first.
795  $vars['wl_has_expiry'] = $db->conditional( 'we_expiry IS NULL', 0, 1 );
796  // Display temporarily watched titles first.
797  // Order by expiration date, with the titles that will expire soonest at the top.
798  $dbOptions['ORDER BY'][] = "wl_has_expiry DESC";
799  $dbOptions['ORDER BY'][] = "we_expiry ASC";
800  }
801 
802  $dbOptions['ORDER BY'][] = "wl_title {$options['sort']}";
803  }
804 
805  $res = $this->fetchWatchedItems(
806  $db,
807  $user,
808  $vars,
809  $dbOptions
810  );
811 
812  $watchedItems = [];
813  foreach ( $res as $row ) {
814  $target = new TitleValue( (int)$row->wl_namespace, $row->wl_title );
815  // @todo: Should we add these to the process cache?
816  $watchedItems[] = $this->getWatchedItemFromRow( $user, $target, $row );
817  }
818 
819  return $watchedItems;
820  }
821 
829  private function getWatchedItemFromRow(
830  UserIdentity $user,
831  LinkTarget $target,
832  stdClass $row
833  ): WatchedItem {
834  return new WatchedItem(
835  $user,
836  $target,
838  $row->wl_notificationtimestamp, $user, $target ),
839  wfTimestampOrNull( TS_ISO_8601, $row->we_expiry ?? null )
840  );
841  }
842 
855  private function fetchWatchedItems(
856  IDatabase $db,
857  UserIdentity $user,
858  array $vars,
859  array $options = [],
860  $target = null
861  ) {
862  $dbMethod = 'select';
863  $conds = [ 'wl_user' => $user->getId() ];
864 
865  if ( $target ) {
866  if ( $target instanceof LinkTarget ) {
867  $dbMethod = 'selectRow';
868  $conds = array_merge( $conds, [
869  'wl_namespace' => $target->getNamespace(),
870  'wl_title' => $target->getDBkey(),
871  ] );
872  } else {
873  $titleConds = [];
874  foreach ( $target as $linkTarget ) {
875  $titleConds[] = $db->makeList(
876  [
877  'wl_namespace' => $linkTarget->getNamespace(),
878  'wl_title' => $linkTarget->getDBkey(),
879  ],
881  );
882  }
883  $conds[] = $db->makeList( $titleConds, $db::LIST_OR );
884  }
885  }
886 
887  if ( $this->expiryEnabled ) {
888  $vars[] = 'we_expiry';
889  $conds[] = 'we_expiry IS NULL OR we_expiry > ' . $db->addQuotes( $db->timestamp() );
890 
891  return $db->{$dbMethod}(
892  [ 'watchlist', 'watchlist_expiry' ],
893  $vars,
894  $conds,
895  __METHOD__,
896  $options,
897  [ 'watchlist_expiry' => [ 'LEFT JOIN', [ 'wl_id = we_item' ] ] ]
898  );
899  }
900 
901  return $db->{$dbMethod}(
902  'watchlist',
903  $vars,
904  $conds,
905  __METHOD__,
906  $options
907  );
908  }
909 
916  public function isWatched( UserIdentity $user, LinkTarget $target ) {
917  return (bool)$this->getWatchedItem( $user, $target );
918  }
919 
927  public function isTempWatched( UserIdentity $user, LinkTarget $target ): bool {
928  $item = $this->getWatchedItem( $user, $target );
929  return $item && $item->getExpiry();
930  }
931 
938  public function getNotificationTimestampsBatch( UserIdentity $user, array $targets ) {
939  $timestamps = [];
940  foreach ( $targets as $target ) {
941  $timestamps[$target->getNamespace()][$target->getDBkey()] = false;
942  }
943 
944  if ( !$user->isRegistered() ) {
945  return $timestamps;
946  }
947 
948  $targetsToLoad = [];
949  foreach ( $targets as $target ) {
950  $cachedItem = $this->getCached( $user, $target );
951  if ( $cachedItem ) {
952  $timestamps[$target->getNamespace()][$target->getDBkey()] =
953  $cachedItem->getNotificationTimestamp();
954  } else {
955  $targetsToLoad[] = $target;
956  }
957  }
958 
959  if ( !$targetsToLoad ) {
960  return $timestamps;
961  }
962 
963  $dbr = $this->getConnectionRef( DB_REPLICA );
964 
965  $lb = $this->linkBatchFactory->newLinkBatch( $targetsToLoad );
966  $res = $dbr->select(
967  'watchlist',
968  [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
969  [
970  $lb->constructSet( 'wl', $dbr ),
971  'wl_user' => $user->getId(),
972  ],
973  __METHOD__
974  );
975 
976  foreach ( $res as $row ) {
977  $target = new TitleValue( (int)$row->wl_namespace, $row->wl_title );
978  $timestamps[$row->wl_namespace][$row->wl_title] =
979  $this->getLatestNotificationTimestamp(
980  $row->wl_notificationtimestamp, $user, $target );
981  }
982 
983  return $timestamps;
984  }
985 
994  public function addWatch( UserIdentity $user, LinkTarget $target, ?string $expiry = null ) {
995  $this->addWatchBatchForUser( $user, [ $target ], $expiry );
996 
997  if ( $this->expiryEnabled && !$expiry ) {
998  // When re-watching a page with a null $expiry, any existing expiry is left unchanged.
999  // However we must re-fetch the preexisting expiry or else the cached WatchedItem will
1000  // incorrectly have a null expiry. Note that loadWatchedItem() does the caching.
1001  // See T259379
1002  $this->loadWatchedItem( $user, $target );
1003  } else {
1004  // Create a new WatchedItem and add it to the process cache.
1005  // In this case we don't need to re-fetch the expiry.
1006  $expiry = ExpiryDef::normalizeUsingMaxExpiry( $expiry, $this->maxExpiryDuration, TS_ISO_8601 );
1007  $item = new WatchedItem(
1008  $user,
1009  $target,
1010  null,
1011  $expiry
1012  );
1013  $this->cache( $item );
1014  }
1015  }
1016 
1030  public function addWatchBatchForUser(
1031  UserIdentity $user,
1032  array $targets,
1033  ?string $expiry = null
1034  ) {
1035  if ( $this->readOnlyMode->isReadOnly() ) {
1036  return false;
1037  }
1038  // Only registered user can have a watchlist
1039  if ( !$user->isRegistered() ) {
1040  return false;
1041  }
1042 
1043  if ( !$targets ) {
1044  return true;
1045  }
1046  $expiry = ExpiryDef::normalizeUsingMaxExpiry( $expiry, $this->maxExpiryDuration, TS_ISO_8601 );
1047  $rows = [];
1048  foreach ( $targets as $target ) {
1049  $rows[] = [
1050  'wl_user' => $user->getId(),
1051  'wl_namespace' => $target->getNamespace(),
1052  'wl_title' => $target->getDBkey(),
1053  'wl_notificationtimestamp' => null,
1054  ];
1055  $this->uncache( $user, $target );
1056  }
1057 
1058  $dbw = $this->getConnectionRef( DB_MASTER );
1059  $ticket = count( $targets ) > $this->updateRowsPerQuery ?
1060  $this->lbFactory->getEmptyTransactionTicket( __METHOD__ ) : null;
1061  $affectedRows = 0;
1062  $rowBatches = array_chunk( $rows, $this->updateRowsPerQuery );
1063  foreach ( $rowBatches as $toInsert ) {
1064  // Use INSERT IGNORE to avoid overwriting the notification timestamp
1065  // if there's already an entry for this page
1066  $dbw->insert( 'watchlist', $toInsert, __METHOD__, [ 'IGNORE' ] );
1067  $affectedRows += $dbw->affectedRows();
1068 
1069  if ( $this->expiryEnabled ) {
1070  $affectedRows += $this->updateOrDeleteExpiries( $dbw, $user->getId(), $toInsert, $expiry );
1071  }
1072 
1073  if ( $ticket ) {
1074  $this->lbFactory->commitAndWaitForReplication( __METHOD__, $ticket );
1075  }
1076  }
1077 
1078  return (bool)$affectedRows;
1079  }
1080 
1090  private function updateOrDeleteExpiries(
1091  IDatabase $dbw,
1092  int $userId,
1093  array $rows,
1094  ?string $expiry = null
1095  ): int {
1096  if ( !$expiry ) {
1097  // if expiry is null (shouldn't change), 0 rows affected.
1098  return 0;
1099  }
1100 
1101  // Build the giant `(...) OR (...)` part to be used with WHERE.
1102  $conds = [];
1103  foreach ( $rows as $row ) {
1104  $conds[] = $dbw->makeList(
1105  [
1106  'wl_user' => $userId,
1107  'wl_namespace' => $row['wl_namespace'],
1108  'wl_title' => $row['wl_title']
1109  ],
1111  );
1112  }
1113  $cond = $dbw->makeList( $conds, $dbw::LIST_OR );
1114 
1115  if ( wfIsInfinity( $expiry ) ) {
1116  // Rows should be deleted rather than updated.
1117  $dbw->deleteJoin(
1118  'watchlist_expiry',
1119  'watchlist',
1120  'we_item',
1121  'wl_id',
1122  [ $cond ],
1123  __METHOD__
1124  );
1125 
1126  return $dbw->affectedRows();
1127  }
1128 
1129  return $this->updateExpiries( $dbw, $expiry, $cond );
1130  }
1131 
1139  private function updateExpiries( IDatabase $dbw, string $expiry, string $cond ): int {
1140  // First fetch the wl_ids from the watchlist table.
1141  // We'd prefer to do a INSERT/SELECT in the same query with IDatabase::insertSelect(),
1142  // but it doesn't allow us to use the "ON DUPLICATE KEY UPDATE" clause.
1143  $wlIds = (array)$dbw->selectFieldValues( 'watchlist', 'wl_id', $cond, __METHOD__ );
1144 
1145  $expiry = $dbw->timestamp( $expiry );
1146 
1147  $weRows = array_map( function ( $wlId ) use ( $expiry, $dbw ) {
1148  return [
1149  'we_item' => $wlId,
1150  'we_expiry' => $expiry
1151  ];
1152  }, $wlIds );
1153 
1154  // Insert into watchlist_expiry, updating the expiry for duplicate rows.
1155  $dbw->upsert(
1156  'watchlist_expiry',
1157  $weRows,
1158  'we_item',
1159  [ 'we_expiry' => $expiry ],
1160  __METHOD__
1161  );
1162 
1163  return $dbw->affectedRows();
1164  }
1165 
1172  public function removeWatch( UserIdentity $user, LinkTarget $target ) {
1173  return $this->removeWatchBatchForUser( $user, [ $target ] );
1174  }
1175 
1194  UserIdentity $user, $timestamp, array $targets = []
1195  ) {
1196  // Only registered user can have a watchlist
1197  if ( !$user->isRegistered() || $this->readOnlyMode->isReadOnly() ) {
1198  return false;
1199  }
1200 
1201  if ( !$targets ) {
1202  // Backwards compatibility
1203  $this->resetAllNotificationTimestampsForUser( $user, $timestamp );
1204  return true;
1205  }
1206 
1207  $rows = $this->getTitleDbKeysGroupedByNamespace( $targets );
1208 
1209  $dbw = $this->getConnectionRef( DB_MASTER );
1210  if ( $timestamp !== null ) {
1211  $timestamp = $dbw->timestamp( $timestamp );
1212  }
1213  $ticket = $this->lbFactory->getEmptyTransactionTicket( __METHOD__ );
1214  $affectedSinceWait = 0;
1215 
1216  // Batch update items per namespace
1217  foreach ( $rows as $namespace => $namespaceTitles ) {
1218  $rowBatches = array_chunk( $namespaceTitles, $this->updateRowsPerQuery );
1219  foreach ( $rowBatches as $toUpdate ) {
1220  $dbw->update(
1221  'watchlist',
1222  [ 'wl_notificationtimestamp' => $timestamp ],
1223  [
1224  'wl_user' => $user->getId(),
1225  'wl_namespace' => $namespace,
1226  'wl_title' => $toUpdate
1227  ],
1228  __METHOD__
1229  );
1230  $affectedSinceWait += $dbw->affectedRows();
1231  // Wait for replication every time we've touched updateRowsPerQuery rows
1232  if ( $affectedSinceWait >= $this->updateRowsPerQuery ) {
1233  $this->lbFactory->commitAndWaitForReplication( __METHOD__, $ticket );
1234  $affectedSinceWait = 0;
1235  }
1236  }
1237  }
1238 
1239  $this->uncacheUser( $user );
1240 
1241  return true;
1242  }
1243 
1245  $timestamp, UserIdentity $user, LinkTarget $target
1246  ) {
1247  $timestamp = wfTimestampOrNull( TS_MW, $timestamp );
1248  if ( $timestamp === null ) {
1249  return null; // no notification
1250  }
1251 
1252  $seenTimestamps = $this->getPageSeenTimestamps( $user );
1253  if (
1254  $seenTimestamps &&
1255  $seenTimestamps->get( $this->getPageSeenKey( $target ) ) >= $timestamp
1256  ) {
1257  // If a reset job did not yet run, then the "seen" timestamp will be higher
1258  return null;
1259  }
1260 
1261  return $timestamp;
1262  }
1263 
1270  public function resetAllNotificationTimestampsForUser( UserIdentity $user, $timestamp = null ) {
1271  // Only registered user can have a watchlist
1272  if ( !$user->isRegistered() ) {
1273  return;
1274  }
1275 
1276  // If the page is watched by the user (or may be watched), update the timestamp
1278  'userId' => $user->getId(), 'timestamp' => $timestamp, 'casTime' => time()
1279  ] );
1280 
1281  // Try to run this post-send
1282  // Calls DeferredUpdates::addCallableUpdate in normal operation
1283  call_user_func(
1284  $this->deferredUpdatesAddCallableUpdateCallback,
1285  function () use ( $job ) {
1286  $job->run();
1287  }
1288  );
1289  }
1290 
1299  UserIdentity $editor, LinkTarget $target, $timestamp
1300  ) {
1301  $dbw = $this->getConnectionRef( DB_MASTER );
1302  $selectTables = [ 'watchlist' ];
1303  $selectConds = [
1304  'wl_user != ' . intval( $editor->getId() ),
1305  'wl_namespace' => $target->getNamespace(),
1306  'wl_title' => $target->getDBkey(),
1307  'wl_notificationtimestamp IS NULL',
1308  ];
1309  $selectJoin = [];
1310 
1311  if ( $this->expiryEnabled ) {
1312  $selectTables[] = 'watchlist_expiry';
1313  $selectConds[] = 'we_expiry IS NULL OR we_expiry > ' .
1314  $dbw->addQuotes( $dbw->timestamp() );
1315  $selectJoin = [ 'watchlist_expiry' => [ 'LEFT JOIN', 'wl_id = we_item' ] ];
1316  }
1317 
1318  $uids = $dbw->selectFieldValues(
1319  $selectTables,
1320  'wl_user',
1321  $selectConds,
1322  __METHOD__,
1323  [],
1324  $selectJoin
1325  );
1326 
1327  $watchers = array_map( 'intval', $uids );
1328  if ( $watchers ) {
1329  // Update wl_notificationtimestamp for all watching users except the editor
1330  $fname = __METHOD__;
1332  function () use ( $timestamp, $watchers, $target, $fname ) {
1333  $dbw = $this->getConnectionRef( DB_MASTER );
1334  $ticket = $this->lbFactory->getEmptyTransactionTicket( $fname );
1335 
1336  $watchersChunks = array_chunk( $watchers, $this->updateRowsPerQuery );
1337  foreach ( $watchersChunks as $watchersChunk ) {
1338  $dbw->update( 'watchlist',
1339  [ /* SET */
1340  'wl_notificationtimestamp' => $dbw->timestamp( $timestamp )
1341  ], [ /* WHERE - TODO Use wl_id T130067 */
1342  'wl_user' => $watchersChunk,
1343  'wl_namespace' => $target->getNamespace(),
1344  'wl_title' => $target->getDBkey(),
1345  ], $fname
1346  );
1347  if ( count( $watchersChunks ) > 1 ) {
1348  $this->lbFactory->commitAndWaitForReplication(
1349  $fname, $ticket, [ 'domain' => $dbw->getDomainID() ]
1350  );
1351  }
1352  }
1353  $this->uncacheLinkTarget( $target );
1354  },
1355  DeferredUpdates::POSTSEND,
1356  $dbw
1357  );
1358  }
1359 
1360  return $watchers;
1361  }
1362 
1372  UserIdentity $user, LinkTarget $title, $force = '', $oldid = 0
1373  ) {
1374  $time = time();
1375 
1376  // Only registered user can have a watchlist
1377  if ( $this->readOnlyMode->isReadOnly() || !$user->isRegistered() ) {
1378  return false;
1379  }
1380 
1381  // Hook expects User and Title, not UserIdentity and LinkTarget
1382  $userObj = $this->userFactory->newFromId( $user->getId() );
1383  $titleObj = Title::castFromLinkTarget( $title );
1384  if ( !$this->hookRunner->onBeforeResetNotificationTimestamp(
1385  $userObj, $titleObj, $force, $oldid )
1386  ) {
1387  return false;
1388  }
1389  if ( !$userObj->equals( $user ) ) {
1390  $user = $userObj;
1391  }
1392  if ( !$titleObj->equals( $title ) ) {
1393  $title = $titleObj;
1394  }
1395 
1396  $item = null;
1397  if ( $force != 'force' ) {
1398  $item = $this->loadWatchedItem( $user, $title );
1399  if ( !$item || $item->getNotificationTimestamp() === null ) {
1400  return false;
1401  }
1402  }
1403 
1404  // Get the timestamp (TS_MW) of this revision to track the latest one seen
1405  $id = $oldid;
1406  $seenTime = null;
1407  if ( !$id ) {
1408  $latestRev = $this->revisionLookup->getRevisionByTitle( $title );
1409  if ( $latestRev ) {
1410  $id = $latestRev->getId();
1411  // Save a DB query
1412  $seenTime = $latestRev->getTimestamp();
1413  }
1414  }
1415  if ( $seenTime === null ) {
1416  $seenTime = $this->revisionLookup->getTimestampFromId( $id );
1417  }
1418 
1419  // Mark the item as read immediately in lightweight storage
1420  $this->stash->merge(
1421  $this->getPageSeenTimestampsKey( $user ),
1422  function ( $cache, $key, $current ) use ( $title, $seenTime ) {
1423  $value = $current ?: new MapCacheLRU( 300 );
1424  $subKey = $this->getPageSeenKey( $title );
1425 
1426  if ( $seenTime > $value->get( $subKey ) ) {
1427  // Revision is newer than the last one seen
1428  $value->set( $subKey, $seenTime );
1429  $this->latestUpdateCache->set( $key, $value, BagOStuff::TTL_PROC_LONG );
1430  } elseif ( $seenTime === false ) {
1431  // Revision does not exist
1432  $value->set( $subKey, wfTimestamp( TS_MW ) );
1433  $this->latestUpdateCache->set( $key, $value, BagOStuff::TTL_PROC_LONG );
1434  } else {
1435  return false; // nothing to update
1436  }
1437 
1438  return $value;
1439  },
1440  BagOStuff::TTL_HOUR
1441  );
1442 
1443  // If the page is watched by the user (or may be watched), update the timestamp
1444  $job = new ActivityUpdateJob(
1445  $title,
1446  [
1447  'type' => 'updateWatchlistNotification',
1448  'userid' => $user->getId(),
1449  'notifTime' => $this->getNotificationTimestamp( $user, $title, $item, $force, $oldid ),
1450  'curTime' => $time
1451  ]
1452  );
1453  // Try to enqueue this post-send
1454  $this->queueGroup->lazyPush( $job );
1455 
1456  $this->uncache( $user, $title );
1457 
1458  return true;
1459  }
1460 
1465  private function getPageSeenTimestamps( UserIdentity $user ) {
1466  $key = $this->getPageSeenTimestampsKey( $user );
1467 
1468  return $this->latestUpdateCache->getWithSetCallback(
1469  $key,
1470  BagOStuff::TTL_PROC_LONG,
1471  function () use ( $key ) {
1472  return $this->stash->get( $key ) ?: null;
1473  }
1474  );
1475  }
1476 
1481  private function getPageSeenTimestampsKey( UserIdentity $user ) {
1482  return $this->stash->makeGlobalKey(
1483  'watchlist-recent-updates',
1484  $this->lbFactory->getLocalDomainID(),
1485  $user->getId()
1486  );
1487  }
1488 
1493  private function getPageSeenKey( LinkTarget $target ) {
1494  return "{$target->getNamespace()}:{$target->getDBkey()}";
1495  }
1496 
1505  private function getNotificationTimestamp(
1506  UserIdentity $user, LinkTarget $title, $item, $force, $oldid
1507  ) {
1508  if ( !$oldid ) {
1509  // No oldid given, assuming latest revision; clear the timestamp.
1510  return null;
1511  }
1512 
1513  $oldRev = $this->revisionLookup->getRevisionById( $oldid );
1514  if ( !$oldRev ) {
1515  // Oldid given but does not exist (probably deleted)
1516  return false;
1517  }
1518 
1519  $nextRev = $this->revisionLookup->getNextRevision( $oldRev );
1520  if ( !$nextRev ) {
1521  // Oldid given and is the latest revision for this title; clear the timestamp.
1522  return null;
1523  }
1524 
1525  if ( $item === null ) {
1526  $item = $this->loadWatchedItem( $user, $title );
1527  }
1528 
1529  if ( !$item ) {
1530  // This can only happen if $force is enabled.
1531  return null;
1532  }
1533 
1534  // Oldid given and isn't the latest; update the timestamp.
1535  // This will result in no further notification emails being sent!
1536  $notificationTimestamp = $this->revisionLookup->getTimestampFromId( $oldid );
1537  // @FIXME: this should use getTimestamp() for consistency with updates on new edits
1538  // $notificationTimestamp = $nextRev->getTimestamp(); // first unseen revision timestamp
1539 
1540  // We need to go one second to the future because of various strict comparisons
1541  // throughout the codebase
1542  $ts = new MWTimestamp( $notificationTimestamp );
1543  $ts->timestamp->add( new DateInterval( 'PT1S' ) );
1544  $notificationTimestamp = $ts->getTimestamp( TS_MW );
1545 
1546  if ( $notificationTimestamp < $item->getNotificationTimestamp() ) {
1547  if ( $force != 'force' ) {
1548  return false;
1549  } else {
1550  // This is a little silly‚Ķ
1551  return $item->getNotificationTimestamp();
1552  }
1553  }
1554 
1555  return $notificationTimestamp;
1556  }
1557 
1564  public function countUnreadNotifications( UserIdentity $user, $unreadLimit = null ) {
1565  $dbr = $this->getConnectionRef( DB_REPLICA );
1566 
1567  $queryOptions = [];
1568  if ( $unreadLimit !== null ) {
1569  $unreadLimit = (int)$unreadLimit;
1570  $queryOptions['LIMIT'] = $unreadLimit;
1571  }
1572 
1573  $conds = [
1574  'wl_user' => $user->getId(),
1575  'wl_notificationtimestamp IS NOT NULL'
1576  ];
1577 
1578  $rowCount = $dbr->selectRowCount( 'watchlist', '1', $conds, __METHOD__, $queryOptions );
1579 
1580  if ( $unreadLimit === null ) {
1581  return $rowCount;
1582  }
1583 
1584  if ( $rowCount >= $unreadLimit ) {
1585  return true;
1586  }
1587 
1588  return $rowCount;
1589  }
1590 
1596  public function duplicateAllAssociatedEntries( LinkTarget $oldTarget, LinkTarget $newTarget ) {
1597  // Duplicate first the subject page, then the talk page
1598  $this->duplicateEntry(
1599  $this->nsInfo->getSubjectPage( $oldTarget ),
1600  $this->nsInfo->getSubjectPage( $newTarget )
1601  );
1602  $this->duplicateEntry(
1603  $this->nsInfo->getTalkPage( $oldTarget ),
1604  $this->nsInfo->getTalkPage( $newTarget )
1605  );
1606  }
1607 
1613  public function duplicateEntry( LinkTarget $oldTarget, LinkTarget $newTarget ) {
1614  $dbw = $this->getConnectionRef( DB_MASTER );
1615  $result = $this->fetchWatchedItemsForPage( $dbw, $oldTarget );
1616  $newNamespace = $newTarget->getNamespace();
1617  $newDBkey = $newTarget->getDBkey();
1618 
1619  # Construct array to replace into the watchlist
1620  $values = [];
1621  $expiries = [];
1622  foreach ( $result as $row ) {
1623  $values[] = [
1624  'wl_user' => $row->wl_user,
1625  'wl_namespace' => $newNamespace,
1626  'wl_title' => $newDBkey,
1627  'wl_notificationtimestamp' => $row->wl_notificationtimestamp,
1628  ];
1629 
1630  if ( $this->expiryEnabled && $row->we_expiry ) {
1631  $expiries[$row->wl_user] = $row->we_expiry;
1632  }
1633  }
1634 
1635  if ( empty( $values ) ) {
1636  return;
1637  }
1638 
1639  // Perform a replace on the watchlist table rows.
1640  // Note that multi-row replace is very efficient for MySQL but may be inefficient for
1641  // some other DBMSes, mostly due to poor simulation by us.
1642  $dbw->replace(
1643  'watchlist',
1644  [ [ 'wl_user', 'wl_namespace', 'wl_title' ] ],
1645  $values,
1646  __METHOD__
1647  );
1648 
1649  if ( $this->expiryEnabled ) {
1650  $this->updateExpiriesAfterMove( $dbw, $expiries, $newNamespace, $newDBkey );
1651  }
1652  }
1653 
1659  private function fetchWatchedItemsForPage(
1660  IDatabase $dbw,
1661  LinkTarget $target
1662  ) : IResultWrapper {
1663  $tables = [ 'watchlist' ];
1664  $fields = [ 'wl_user', 'wl_notificationtimestamp' ];
1665  $joins = [];
1666 
1667  if ( $this->expiryEnabled ) {
1668  $tables[] = 'watchlist_expiry';
1669  $fields[] = 'we_expiry';
1670  $joins['watchlist_expiry'] = [ 'LEFT JOIN', [ 'wl_id = we_item' ] ];
1671  }
1672 
1673  return $dbw->select(
1674  $tables,
1675  $fields,
1676  [
1677  'wl_namespace' => $target->getNamespace(),
1678  'wl_title' => $target->getDBkey(),
1679  ],
1680  __METHOD__,
1681  [ 'FOR UPDATE' ],
1682  $joins
1683  );
1684  }
1685 
1692  private function updateExpiriesAfterMove(
1693  IDatabase $dbw,
1694  array $expiries,
1695  int $namespace,
1696  string $dbKey
1697  ): void {
1698  $method = __METHOD__;
1700  function () use ( $dbw, $expiries, $namespace, $dbKey, $method ) {
1701  // First fetch new wl_ids.
1702  $res = $dbw->select(
1703  'watchlist',
1704  [ 'wl_user', 'wl_id' ],
1705  [
1706  'wl_namespace' => $namespace,
1707  'wl_title' => $dbKey,
1708  ],
1709  $method
1710  );
1711 
1712  // Build new array to INSERT into multiple rows at once.
1713  $expiryData = [];
1714  foreach ( $res as $row ) {
1715  if ( !empty( $expiries[$row->wl_user] ) ) {
1716  $expiryData[] = [
1717  'we_item' => $row->wl_id,
1718  'we_expiry' => $expiries[$row->wl_user],
1719  ];
1720  }
1721  }
1722 
1723  // Batch the insertions.
1724  $batches = array_chunk( $expiryData, $this->updateRowsPerQuery );
1725  foreach ( $batches as $toInsert ) {
1726  $dbw->replace(
1727  'watchlist_expiry',
1728  'we_item',
1729  $toInsert,
1730  $method
1731  );
1732  }
1733  },
1734  DeferredUpdates::POSTSEND,
1735  $dbw
1736  );
1737  }
1738 
1743  private function getTitleDbKeysGroupedByNamespace( array $titles ) {
1744  $rows = [];
1745  foreach ( $titles as $title ) {
1746  // Group titles by namespace.
1747  $rows[ $title->getNamespace() ][] = $title->getDBkey();
1748  }
1749  return $rows;
1750  }
1751 
1756  private function uncacheTitlesForUser( UserIdentity $user, array $titles ) {
1757  foreach ( $titles as $title ) {
1758  $this->uncache( $user, $title );
1759  }
1760  }
1761 
1765  public function countExpired(): int {
1766  $dbr = $this->getConnectionRef( DB_REPLICA );
1767  return $dbr->selectRowCount(
1768  'watchlist_expiry',
1769  '*',
1770  [ 'we_expiry <= ' . $dbr->addQuotes( $dbr->timestamp() ) ],
1771  __METHOD__
1772  );
1773  }
1774 
1778  public function removeExpired( int $limit, bool $deleteOrphans = false ): void {
1779  $dbr = $this->getConnectionRef( DB_REPLICA );
1780  $dbw = $this->getConnectionRef( DB_MASTER );
1781  $ticket = $this->lbFactory->getEmptyTransactionTicket( __METHOD__ );
1782 
1783  // Get a batch of watchlist IDs to delete.
1784  $toDelete = $dbr->selectFieldValues(
1785  'watchlist_expiry',
1786  'we_item',
1787  [ 'we_expiry <= ' . $dbr->addQuotes( $dbr->timestamp() ) ],
1788  __METHOD__,
1789  [ 'LIMIT' => $limit ]
1790  );
1791  if ( count( $toDelete ) > 0 ) {
1792  // Delete them from the watchlist and watchlist_expiry table.
1793  $dbw->delete(
1794  'watchlist',
1795  [ 'wl_id' => $toDelete ],
1796  __METHOD__
1797  );
1798  $dbw->delete(
1799  'watchlist_expiry',
1800  [ 'we_item' => $toDelete ],
1801  __METHOD__
1802  );
1803  }
1804 
1805  // Also delete any orphaned or null-expiry watchlist_expiry rows
1806  // (they should not exist, but might because not everywhere knows about the expiry table yet).
1807  if ( $deleteOrphans ) {
1808  $expiryToDelete = $dbr->selectFieldValues(
1809  [ 'watchlist_expiry', 'watchlist' ],
1810  'we_item',
1811  $dbr->makeList(
1812  [ 'wl_id' => null, 'we_expiry' => null ],
1814  ),
1815  __METHOD__,
1816  [],
1817  [ 'watchlist' => [ 'LEFT JOIN', 'wl_id = we_item' ] ]
1818  );
1819  if ( count( $expiryToDelete ) > 0 ) {
1820  $dbw->delete(
1821  'watchlist_expiry',
1822  [ 'we_item' => $expiryToDelete ],
1823  __METHOD__
1824  );
1825  }
1826  }
1827 
1828  $this->lbFactory->commitAndWaitForReplication( __METHOD__, $ticket );
1829  }
1830 }
LIST_OR
const LIST_OR
Definition: Defines.php:45
WatchedItemStore\getLatestNotificationTimestamp
getLatestNotificationTimestamp( $timestamp, UserIdentity $user, LinkTarget $target)
Convert $timestamp to TS_MW or return null if the page was visited since then by $user.
Definition: WatchedItemStore.php:1244
MWTimestamp
Library for creating and parsing MW-style timestamps.
Definition: MWTimestamp.php:34
WatchedItemStore\$cacheIndex
array[][] $cacheIndex
Looks like $cacheIndex[Namespace ID][Target DB Key][User Id] => 'key' The index is needed so that on ...
Definition: WatchedItemStore.php:81
Wikimedia\Rdbms\IDatabase\affectedRows
affectedRows()
Get the number of rows affected by the last write query.
WatchedItemStore\uncacheAllItemsForUser
uncacheAllItemsForUser(UserIdentity $user)
Definition: WatchedItemStore.php:336
ActivityUpdateJob
Job for updating user activity like "last viewed" timestamps.
Definition: ActivityUpdateJob.php:36
WatchedItemStore\loadWatchedItem
loadWatchedItem(UserIdentity $user, LinkTarget $target)
Definition: WatchedItemStore.php:730
WatchedItemStore\updateNotificationTimestamp
updateNotificationTimestamp(UserIdentity $editor, LinkTarget $target, $timestamp)
Definition: WatchedItemStore.php:1298
HashBagOStuff
Simple store for keeping values in an associative array for the current process.
Definition: HashBagOStuff.php:32
WatchedItemStore\getPageSeenTimestamps
getPageSeenTimestamps(UserIdentity $user)
Definition: WatchedItemStore.php:1465
Wikimedia\Rdbms\IDatabase\replace
replace( $table, $uniqueKeys, $rows, $fname=__METHOD__)
Insert row(s) into a table, deleting all conflicting rows beforehand.
WatchedItemStore\clearUserWatchedItems
clearUserWatchedItems(UserIdentity $user)
Deletes ALL watched items for the given user when under $updateRowsPerQuery entries exist.
Definition: WatchedItemStore.php:290
WatchedItemStore\getVisitingWatchersCondition
getVisitingWatchersCondition(IDatabase $db, array $targetsWithVisitThresholds)
Generates condition for the query used in a batch count visiting watchers.
Definition: WatchedItemStore.php:667
wfTimestamp
wfTimestamp( $outputtype=TS_UNIX, $ts=0)
Get a timestamp string in one of various formats.
Definition: GlobalFunctions.php:1831
WatchedItemStore\getNotificationTimestampsBatch
getNotificationTimestampsBatch(UserIdentity $user, array $targets)
Definition: WatchedItemStore.php:938
LIST_AND
const LIST_AND
Definition: Defines.php:42
ReadOnlyMode
A service class for fetching the wiki's current read-only mode.
Definition: ReadOnlyMode.php:11
ClearUserWatchlistJob\newForUser
static newForUser(UserIdentity $user, $maxWatchlistId)
Definition: ClearUserWatchlistJob.php:32
Wikimedia\Rdbms\IDatabase\upsert
upsert( $table, array $rows, $uniqueKeys, array $set, $fname=__METHOD__)
Upsert the given row(s) into a table.
WatchedItemStore\$nsInfo
NamespaceInfo $nsInfo
Definition: WatchedItemStore.php:96
WatchedItemStore\countVisitingWatchers
countVisitingWatchers(LinkTarget $target, $threshold)
Definition: WatchedItemStore.php:464
NullStatsdDataFactory
Definition: NullStatsdDataFactory.php:10
ClearWatchlistNotificationsJob
Job for clearing all of the "last viewed" timestamps for a user's watchlist, or setting them all to t...
Definition: ClearWatchlistNotificationsJob.php:36
WatchedItemStore\countWatchedItems
countWatchedItems(UserIdentity $user)
Definition: WatchedItemStore.php:402
WatchedItemStore\duplicateAllAssociatedEntries
duplicateAllAssociatedEntries(LinkTarget $oldTarget, LinkTarget $newTarget)
Definition: WatchedItemStore.php:1596
BagOStuff
Class representing a cache/ephemeral data store.
Definition: BagOStuff.php:71
WatchedItemStore\addWatch
addWatch(UserIdentity $user, LinkTarget $target, ?string $expiry=null)
Definition: WatchedItemStore.php:994
WatchedItemStore\$loadBalancer
LoadBalancer $loadBalancer
Definition: WatchedItemStore.php:47
Wikimedia\Rdbms\ILBFactory\getMainLB
getMainLB( $domain=false)
Get a cached (tracked) load balancer object.
WatchedItemStore\enqueueWatchlistExpiryJob
enqueueWatchlistExpiryJob(float $watchlistPurgeRate)
Probabilistically add a job to purge the expired watchlist items.1.35The value of the $wgWatchlistPur...
Definition: WatchedItemStore.php:375
WatchedItemStore\fetchWatchedItems
fetchWatchedItems(IDatabase $db, UserIdentity $user, array $vars, array $options=[], $target=null)
Fetches either a single or all watched items for the given user, or a specific set of items.
Definition: WatchedItemStore.php:855
$res
$res
Definition: testCompression.php:57
WatchedItemStore\getNotificationTimestamp
getNotificationTimestamp(UserIdentity $user, LinkTarget $title, $item, $force, $oldid)
Definition: WatchedItemStore.php:1505
WatchedItemStore\countExpired
countExpired()
Get the number of watchlist items that expire before the current time.1.35int
Definition: WatchedItemStore.php:1765
MediaWiki\User\UserIdentity
Interface for objects representing user identity.
Definition: UserIdentity.php:32
WatchedItemStore\$cache
HashBagOStuff $cache
Definition: WatchedItemStore.php:67
Revision\RevisionLookup
Service for looking up page revisions.
Definition: RevisionLookup.php:38
WatchedItemStore\loadWatchedItemsBatch
loadWatchedItemsBatch(UserIdentity $user, array $targets)
Definition: WatchedItemStore.php:741
WatchedItemStore\CONSTRUCTOR_OPTIONS
const CONSTRUCTOR_OPTIONS
Definition: WatchedItemStore.php:33
Wikimedia\Rdbms\IDatabase
Basic database interface for live and lazy-loaded relation database handles.
Definition: IDatabase.php:38
$dbr
$dbr
Definition: testCompression.php:54
WatchedItemStore\$lbFactory
ILBFactory $lbFactory
Definition: WatchedItemStore.php:42
Wikimedia\ParamValidator\TypeDef\ExpiryDef
Type definition for expiry timestamps.
Definition: ExpiryDef.php:17
MediaWiki\Linker\LinkTarget\getNamespace
getNamespace()
Get the namespace index.
WatchedItemStore\$readOnlyMode
ReadOnlyMode $readOnlyMode
Definition: WatchedItemStore.php:62
Wikimedia\Rdbms\IDatabase\timestamp
timestamp( $ts=0)
Convert a timestamp in one of the formats accepted by ConvertibleTimestamp to the format used for ins...
WatchedItemStore\$expiryEnabled
bool $expiryEnabled
Correlates to $wgWatchlistExpiry feature flag.
Definition: WatchedItemStore.php:111
WatchedItemStore\overrideDeferredUpdatesAddCallableUpdateCallback
overrideDeferredUpdatesAddCallableUpdateCallback(callable $callback)
Overrides the DeferredUpdates::addCallableUpdate callback This is intended for use while testing and ...
Definition: WatchedItemStore.php:198
MWException
MediaWiki exception.
Definition: MWException.php:29
WatchedItemStore\getPageSeenTimestampsKey
getPageSeenTimestampsKey(UserIdentity $user)
Definition: WatchedItemStore.php:1481
MediaWiki\Config\ServiceOptions
A class for passing options to services.
Definition: ServiceOptions.php:27
WatchedItemStore\isTempWatched
isTempWatched(UserIdentity $user, LinkTarget $target)
Check if the user is temporarily watching the page.
Definition: WatchedItemStore.php:927
Wikimedia\Rdbms\IDatabase\deleteJoin
deleteJoin( $delTable, $joinTable, $delVar, $joinVar, $conds, $fname=__METHOD__)
DELETE where the condition is a join.
MediaWiki\User\UserIdentity\isRegistered
isRegistered()
Wikimedia\Rdbms\IResultWrapper
Result wrapper for grabbing data queried from an IDatabase object.
Definition: IResultWrapper.php:24
WatchedItemStore\resetNotificationTimestamp
resetNotificationTimestamp(UserIdentity $user, LinkTarget $title, $force='', $oldid=0)
Definition: WatchedItemStore.php:1371
WatchedItemStore\$queueGroup
JobQueueGroup $queueGroup
Definition: WatchedItemStore.php:52
MediaWiki\Cache\LinkBatchFactory
Definition: LinkBatchFactory.php:38
wfTimestampOrNull
wfTimestampOrNull( $outputtype=TS_UNIX, $ts=null)
Return a formatted timestamp, or null if input is null.
Definition: GlobalFunctions.php:1847
WatchedItemStore\duplicateEntry
duplicateEntry(LinkTarget $oldTarget, LinkTarget $newTarget)
Definition: WatchedItemStore.php:1613
MapCacheLRU
Handles a simple LRU key/value map with a maximum number of entries.
Definition: MapCacheLRU.php:37
WatchedItemStore\removeWatch
removeWatch(UserIdentity $user, LinkTarget $target)
Definition: WatchedItemStore.php:1172
$title
$title
Definition: testCompression.php:38
WatchedItemStore\$maxExpiryDuration
string null $maxExpiryDuration
Maximum configured relative expiry.
Definition: WatchedItemStore.php:129
DB_REPLICA
const DB_REPLICA
Definition: defines.php:25
WatchedItemStore\getMaxId
getMaxId()
Definition: WatchedItemStore.php:387
WatchedItemStore\updateOrDeleteExpiries
updateOrDeleteExpiries(IDatabase $dbw, int $userId, array $rows, ?string $expiry=null)
Insert/update expiries, or delete them if the expiry is 'infinity'.
Definition: WatchedItemStore.php:1090
DB_MASTER
const DB_MASTER
Definition: defines.php:26
WatchedItemStore\isWatched
isWatched(UserIdentity $user, LinkTarget $target)
Definition: WatchedItemStore.php:916
WatchedItemStore\uncacheLinkTarget
uncacheLinkTarget(LinkTarget $target)
Definition: WatchedItemStore.php:234
WatchedItem\getUserIdentity
getUserIdentity()
Definition: WatchedItem.php:117
WatchedItemStore\getWatchedItemFromRow
getWatchedItemFromRow(UserIdentity $user, LinkTarget $target, stdClass $row)
Construct a new WatchedItem given a row from watchlist/watchlist_expiry.
Definition: WatchedItemStore.php:829
WatchedItemStore\removeExpired
removeExpired(int $limit, bool $deleteOrphans=false)
Remove some number of expired watchlist items.1.35The number of items to remove. Whether to also dele...
Definition: WatchedItemStore.php:1778
WatchedItemStore\getPageSeenKey
getPageSeenKey(LinkTarget $target)
Definition: WatchedItemStore.php:1493
WatchedItemStore\getConnectionRef
getConnectionRef( $dbIndex)
Definition: WatchedItemStore.php:276
WatchedItemStore\$deferredUpdatesAddCallableUpdateCallback
callable null $deferredUpdatesAddCallableUpdateCallback
Definition: WatchedItemStore.php:86
Wikimedia\Rdbms\LoadBalancer
Database connection, tracking, load balancing, and transaction manager for a cluster.
Definition: LoadBalancer.php:42
WatchedItemStore\$revisionLookup
RevisionLookup $revisionLookup
Definition: WatchedItemStore.php:101
WatchedItemStore\countUnreadNotifications
countUnreadNotifications(UserIdentity $user, $unreadLimit=null)
Definition: WatchedItemStore.php:1564
StatsdAwareInterface
Describes a Statsd aware interface.
Definition: StatsdAwareInterface.php:13
WatchedItemStore\mustClearWatchedItemsUsingJobQueue
mustClearWatchedItemsUsingJobQueue(UserIdentity $user)
Does the size of the users watchlist require clearUserWatchedItemsUsingJobQueue() to be used instead ...
Definition: WatchedItemStore.php:332
WatchedItemStore\fetchWatchedItemsForPage
fetchWatchedItemsForPage(IDatabase $dbw, LinkTarget $target)
Definition: WatchedItemStore.php:1659
WatchedItemStore\resetAllNotificationTimestampsForUser
resetAllNotificationTimestampsForUser(UserIdentity $user, $timestamp=null)
Schedule a DeferredUpdate that sets all of the "last viewed" timestamps for a given user to the same ...
Definition: WatchedItemStore.php:1270
wfIsInfinity
wfIsInfinity( $str)
Determine input string is represents as infinity.
Definition: GlobalFunctions.php:2763
WatchedItem
Representation of a pair of user and title for watchlist entries.
Definition: WatchedItem.php:35
MediaWiki\Linker\LinkTarget\getDBkey
getDBkey()
Get the main part with underscores.
WatchedItemStore\setNotificationTimestampsForUser
setNotificationTimestampsForUser(UserIdentity $user, $timestamp, array $targets=[])
Set the "last viewed" timestamps for certain titles on a user's watchlist.
Definition: WatchedItemStore.php:1193
WatchedItemStore\getWatchedItem
getWatchedItem(UserIdentity $user, LinkTarget $target)
Definition: WatchedItemStore.php:710
WatchedItemStore\$latestUpdateCache
HashBagOStuff $latestUpdateCache
Definition: WatchedItemStore.php:72
WatchedItemStore\uncacheTitlesForUser
uncacheTitlesForUser(UserIdentity $user, array $titles)
Definition: WatchedItemStore.php:1756
WatchedItemStore
Storage layer class for WatchedItems.
Definition: WatchedItemStore.php:28
WatchlistExpiryJob
Definition: WatchlistExpiryJob.php:5
WatchedItemStore\getCached
getCached(UserIdentity $user, LinkTarget $target)
Definition: WatchedItemStore.php:267
WatchedItemStore\getCacheKey
getCacheKey(UserIdentity $user, LinkTarget $target)
Definition: WatchedItemStore.php:211
WatchedItemStore\uncache
uncache(UserIdentity $user, LinkTarget $target)
Definition: WatchedItemStore.php:228
MediaWiki\User\UserIdentity\getId
getId()
$cache
$cache
Definition: mcc.php:33
WatchedItemStore\updateExpiriesAfterMove
updateExpiriesAfterMove(IDatabase $dbw, array $expiries, int $namespace, string $dbKey)
Definition: WatchedItemStore.php:1692
Wikimedia\Rdbms\IDatabase\addQuotes
addQuotes( $s)
Escape and quote a raw value string for use in a SQL query.
WatchedItemStore\$stats
StatsdDataFactoryInterface $stats
Definition: WatchedItemStore.php:106
WatchedItemStore\$stash
BagOStuff $stash
Definition: WatchedItemStore.php:57
WatchedItemStore\countWatchers
countWatchers(LinkTarget $target)
Definition: WatchedItemStore.php:431
WatchedItemStore\__construct
__construct(ServiceOptions $options, ILBFactory $lbFactory, JobQueueGroup $queueGroup, BagOStuff $stash, HashBagOStuff $cache, ReadOnlyMode $readOnlyMode, NamespaceInfo $nsInfo, RevisionLookup $revisionLookup, HookContainer $hookContainer, LinkBatchFactory $linkBatchFactory, UserFactory $userFactory)
Definition: WatchedItemStore.php:144
$job
if(count( $args)< 1) $job
Definition: recompressTracked.php:50
WatchedItemStore\updateExpiries
updateExpiries(IDatabase $dbw, string $expiry, string $cond)
Update the expiries for items found with the given $cond.
Definition: WatchedItemStore.php:1139
WatchedItemStore\$userFactory
UserFactory $userFactory
Definition: WatchedItemStore.php:124
WatchedItem\getLinkTarget
getLinkTarget()
Definition: WatchedItem.php:124
Wikimedia\Rdbms\IDatabase\selectFieldValues
selectFieldValues( $table, $var, $cond='', $fname=__METHOD__, $options=[], $join_conds=[])
A SELECT wrapper which returns a list of single field values from result rows.
WatchedItemStore\countVisitingWatchersMultiple
countVisitingWatchersMultiple(array $targetsWithVisitThresholds, $minimumWatchers=null)
Definition: WatchedItemStore.php:611
Wikimedia\Rdbms\IDatabase\select
select( $table, $vars, $conds='', $fname=__METHOD__, $options=[], $join_conds=[])
Execute a SELECT query constructed using the various parameters provided.
WatchedItemStore\$linkBatchFactory
LinkBatchFactory $linkBatchFactory
Definition: WatchedItemStore.php:121
WatchedItemStore\setStatsdDataFactory
setStatsdDataFactory(StatsdDataFactoryInterface $stats)
Definition: WatchedItemStore.php:183
WatchedItemStore\uncacheUser
uncacheUser(UserIdentity $user)
Definition: WatchedItemStore.php:245
MediaWiki\Config\ServiceOptions\get
get( $key)
Definition: ServiceOptions.php:88
MediaWiki\HookContainer\HookContainer
HookContainer class.
Definition: HookContainer.php:45
WatchedItemStore\$hookRunner
HookRunner $hookRunner
Definition: WatchedItemStore.php:116
NamespaceInfo
This is a utility class for dealing with namespaces that encodes all the "magic" behaviors of them ba...
Definition: NamespaceInfo.php:35
MediaWiki\HookContainer\HookRunner
This class provides an implementation of the core hook interfaces, forwarding hook calls to HookConta...
Definition: HookRunner.php:571
WatchedItemStore\getWatchedItemsForUser
getWatchedItemsForUser(UserIdentity $user, array $options=[])
Definition: WatchedItemStore.php:778
WatchedItemStore\addWatchBatchForUser
addWatchBatchForUser(UserIdentity $user, array $targets, ?string $expiry=null)
Add multiple items to the user's watchlist.
Definition: WatchedItemStore.php:1030
Title\castFromLinkTarget
static castFromLinkTarget( $linkTarget)
Same as newFromLinkTarget, but if passed null, returns null.
Definition: Title.php:313
MediaWiki\Linker\LinkTarget
Definition: LinkTarget.php:26
WatchedItemStoreInterface
Definition: WatchedItemStoreInterface.php:30
DeferredUpdates\addCallableUpdate
static addCallableUpdate( $callable, $stage=self::POSTSEND, $dbw=null)
Add an update to the pending update queue that invokes the specified callback when run.
Definition: DeferredUpdates.php:145
WatchedItemStore\getTitleDbKeysGroupedByNamespace
getTitleDbKeysGroupedByNamespace(array $titles)
Definition: WatchedItemStore.php:1743
Wikimedia\Rdbms\IDatabase\makeList
makeList(array $a, $mode=self::LIST_COMMA)
Makes an encoded list of strings from an array.
MediaWiki\User\UserFactory
Creates User objects.
Definition: UserFactory.php:40
WatchedItemStore\$updateRowsPerQuery
int $updateRowsPerQuery
Definition: WatchedItemStore.php:91
WatchedItemStore\countWatchersMultiple
countWatchersMultiple(array $targets, array $options=[])
Definition: WatchedItemStore.php:563
MediaWiki\Config\ServiceOptions\assertRequiredOptions
assertRequiredOptions(array $expectedKeys)
Assert that the list of options provided in this instance exactly match $expectedKeys,...
Definition: ServiceOptions.php:66
Wikimedia\Rdbms\ILBFactory
An interface for generating database load balancers.
Definition: ILBFactory.php:33
WatchedItemStore\removeWatchBatchForUser
removeWatchBatchForUser(UserIdentity $user, array $titles)
Definition: WatchedItemStore.php:499
JobQueueGroup
Class to handle enqueueing of background jobs.
Definition: JobQueueGroup.php:30
WatchedItemStore\clearUserWatchedItemsUsingJobQueue
clearUserWatchedItemsUsingJobQueue(UserIdentity $user)
Queues a job that will clear the users watchlist using the Job Queue.
Definition: WatchedItemStore.php:367
WatchedItemStore\cache
cache(WatchedItem $item)
Definition: WatchedItemStore.php:219
TitleValue
Represents a page (or page fragment) title within MediaWiki.
Definition: TitleValue.php:40