MediaWiki  master
WatchedItemStore.php
Go to the documentation of this file.
1 <?php
2 
3 use Liuggio\StatsdClient\Factory\StatsdDataFactoryInterface;
13 use Wikimedia\Assert\Assert;
19 use Wikimedia\ScopedCallback;
20 
30 
34  public const CONSTRUCTOR_OPTIONS = [
35  'UpdateRowsPerQuery',
36  'WatchlistExpiry',
37  'WatchlistExpiryMaxDuration',
38  'WatchlistPurgeRate',
39  ];
40 
44  private $lbFactory;
45 
49  private $loadBalancer;
50 
54  private $queueGroup;
55 
59  private $stash;
60 
64  private $readOnlyMode;
65 
69  private $cache;
70 
75 
83  private $cacheIndex = [];
84 
89 
94 
98  private $nsInfo;
99 
104 
108  private $stats;
109 
113  private $expiryEnabled;
114 
118  private $hookRunner;
119 
124 
126  private $userFactory;
127 
129  private $titleFactory;
130 
135 
138 
153  public function __construct(
154  ServiceOptions $options,
162  HookContainer $hookContainer,
166  ) {
167  $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
168  $this->updateRowsPerQuery = $options->get( 'UpdateRowsPerQuery' );
169  $this->expiryEnabled = $options->get( 'WatchlistExpiry' );
170  $this->maxExpiryDuration = $options->get( 'WatchlistExpiryMaxDuration' );
171  $this->watchlistPurgeRate = $options->get( 'WatchlistPurgeRate' );
172 
173  $this->lbFactory = $lbFactory;
174  $this->loadBalancer = $lbFactory->getMainLB();
175  $this->queueGroup = $queueGroup;
176  $this->stash = $stash;
177  $this->cache = $cache;
178  $this->readOnlyMode = $readOnlyMode;
179  $this->stats = new NullStatsdDataFactory();
180  $this->deferredUpdatesAddCallableUpdateCallback =
181  [ DeferredUpdates::class, 'addCallableUpdate' ];
182  $this->nsInfo = $nsInfo;
183  $this->revisionLookup = $revisionLookup;
184  $this->hookRunner = new HookRunner( $hookContainer );
185  $this->linkBatchFactory = $linkBatchFactory;
186  $this->userFactory = $userFactory;
187  $this->titleFactory = $titleFactory;
188 
189  $this->latestUpdateCache = new HashBagOStuff( [ 'maxKeys' => 3 ] );
190  }
191 
195  public function setStatsdDataFactory( StatsdDataFactoryInterface $stats ) {
196  $this->stats = $stats;
197  }
198 
210  public function overrideDeferredUpdatesAddCallableUpdateCallback( callable $callback ) {
211  if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
212  throw new MWException(
213  'Cannot override DeferredUpdates::addCallableUpdate callback in operation.'
214  );
215  }
217  $this->deferredUpdatesAddCallableUpdateCallback = $callback;
218  return new ScopedCallback( function () use ( $previousValue ) {
219  $this->deferredUpdatesAddCallableUpdateCallback = $previousValue;
220  } );
221  }
222 
228  private function getCacheKey( UserIdentity $user, $target ): string {
229  return $this->cache->makeKey(
230  (string)$target->getNamespace(),
231  $target->getDBkey(),
232  (string)$user->getId()
233  );
234  }
235 
239  private function cache( WatchedItem $item ) {
240  $user = $item->getUserIdentity();
241  $target = $item->getTarget();
242  $key = $this->getCacheKey( $user, $target );
243  $this->cache->set( $key, $item );
244  $this->cacheIndex[$target->getNamespace()][$target->getDBkey()][$user->getId()] = $key;
245  $this->stats->increment( 'WatchedItemStore.cache' );
246  }
247 
252  private function uncache( UserIdentity $user, $target ) {
253  $this->cache->delete( $this->getCacheKey( $user, $target ) );
254  unset( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()][$user->getId()] );
255  $this->stats->increment( 'WatchedItemStore.uncache' );
256  }
257 
261  private function uncacheLinkTarget( $target ) {
262  $this->stats->increment( 'WatchedItemStore.uncacheLinkTarget' );
263  if ( !isset( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()] ) ) {
264  return;
265  }
266  foreach ( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()] as $key ) {
267  $this->stats->increment( 'WatchedItemStore.uncacheLinkTarget.items' );
268  $this->cache->delete( $key );
269  }
270  }
271 
275  private function uncacheUser( UserIdentity $user ) {
276  $this->stats->increment( 'WatchedItemStore.uncacheUser' );
277  foreach ( $this->cacheIndex as $ns => $dbKeyArray ) {
278  foreach ( $dbKeyArray as $dbKey => $userArray ) {
279  if ( isset( $userArray[$user->getId()] ) ) {
280  $this->stats->increment( 'WatchedItemStore.uncacheUser.items' );
281  $this->cache->delete( $userArray[$user->getId()] );
282  }
283  }
284  }
285 
286  $pageSeenKey = $this->getPageSeenTimestampsKey( $user );
287  $this->latestUpdateCache->delete( $pageSeenKey );
288  $this->stash->delete( $pageSeenKey );
289  }
290 
297  private function getCached( UserIdentity $user, $target ) {
298  return $this->cache->get( $this->getCacheKey( $user, $target ) );
299  }
300 
306  private function getConnectionRef( $dbIndex ): IDatabase {
307  return $this->loadBalancer->getConnectionRef( $dbIndex, [ 'watchlist' ] );
308  }
309 
319  private function modifyForExpiry(
320  array &$tables,
321  array &$conds,
322  array &$joinConds,
323  IDatabase $db
324  ) {
325  if ( $this->expiryEnabled ) {
326  $tables[] = 'watchlist_expiry';
327  $conds[] = 'we_expiry IS NULL OR we_expiry > ' . $db->addQuotes( $db->timestamp() );
328  $joinConds[ 'watchlist_expiry' ] = [ 'LEFT JOIN', 'wl_id = we_item' ];
329  }
330  }
331 
342  public function clearUserWatchedItems( UserIdentity $user ): bool {
343  if ( $this->mustClearWatchedItemsUsingJobQueue( $user ) ) {
344  return false;
345  }
346 
347  $dbw = $this->loadBalancer->getConnectionRef( DB_PRIMARY );
348 
349  if ( $this->expiryEnabled ) {
350  $ticket = $this->lbFactory->getEmptyTransactionTicket( __METHOD__ );
351  // First fetch the wl_ids.
352  $wlIds = $dbw->selectFieldValues(
353  'watchlist',
354  'wl_id',
355  [ 'wl_user' => $user->getId() ],
356  __METHOD__
357  );
358 
359  if ( $wlIds ) {
360  // Delete rows from both the watchlist and watchlist_expiry tables.
361  $dbw->delete(
362  'watchlist',
363  [ 'wl_id' => $wlIds ],
364  __METHOD__
365  );
366 
367  $dbw->delete(
368  'watchlist_expiry',
369  [ 'we_item' => $wlIds ],
370  __METHOD__
371  );
372  }
373  $this->lbFactory->commitAndWaitForReplication( __METHOD__, $ticket );
374  } else {
375  $dbw->delete(
376  'watchlist',
377  [ 'wl_user' => $user->getId() ],
378  __METHOD__
379  );
380  }
381 
382  $this->uncacheAllItemsForUser( $user );
383 
384  return true;
385  }
386 
391  public function mustClearWatchedItemsUsingJobQueue( UserIdentity $user ): bool {
392  return $this->countWatchedItems( $user ) > $this->updateRowsPerQuery;
393  }
394 
398  private function uncacheAllItemsForUser( UserIdentity $user ) {
399  $userId = $user->getId();
400  foreach ( $this->cacheIndex as $ns => $dbKeyIndex ) {
401  foreach ( $dbKeyIndex as $dbKey => $userIndex ) {
402  if ( array_key_exists( $userId, $userIndex ) ) {
403  $this->cache->delete( $userIndex[$userId] );
404  unset( $this->cacheIndex[$ns][$dbKey][$userId] );
405  }
406  }
407  }
408 
409  // Cleanup empty cache keys
410  foreach ( $this->cacheIndex as $ns => $dbKeyIndex ) {
411  foreach ( $dbKeyIndex as $dbKey => $userIndex ) {
412  if ( empty( $this->cacheIndex[$ns][$dbKey] ) ) {
413  unset( $this->cacheIndex[$ns][$dbKey] );
414  }
415  }
416  if ( empty( $this->cacheIndex[$ns] ) ) {
417  unset( $this->cacheIndex[$ns] );
418  }
419  }
420  }
421 
430  $job = ClearUserWatchlistJob::newForUser( $user, $this->getMaxId() );
431  $this->queueGroup->push( $job );
432  }
433 
437  public function maybeEnqueueWatchlistExpiryJob(): void {
438  if ( !$this->expiryEnabled ) {
439  // No need to purge expired entries if there are none
440  return;
441  }
442 
443  $max = mt_getrandmax();
444  if ( mt_rand( 0, $max ) < $max * $this->watchlistPurgeRate ) {
445  // The higher the watchlist purge rate, the more likely we are to enqueue a job.
446  $this->queueGroup->push( new WatchlistExpiryJob() );
447  }
448  }
449 
454  public function getMaxId(): int {
455  $dbr = $this->getConnectionRef( DB_REPLICA );
456  return (int)$dbr->selectField(
457  'watchlist',
458  'MAX(wl_id)',
459  '',
460  __METHOD__
461  );
462  }
463 
469  public function countWatchedItems( UserIdentity $user ): int {
470  $dbr = $this->getConnectionRef( DB_REPLICA );
471  $tables = [ 'watchlist' ];
472  $conds = [ 'wl_user' => $user->getId() ];
473  $joinConds = [];
474 
475  $this->modifyForExpiry( $tables, $conds, $joinConds, $dbr );
476 
477  return (int)$dbr->selectField(
478  $tables,
479  'COUNT(*)',
480  $conds,
481  __METHOD__,
482  [],
483  $joinConds
484  );
485  }
486 
492  public function countWatchers( $target ): int {
493  $dbr = $this->getConnectionRef( DB_REPLICA );
494  $tables = [ 'watchlist' ];
495  $conds = [
496  'wl_namespace' => $target->getNamespace(),
497  'wl_title' => $target->getDBkey()
498  ];
499  $joinConds = [];
500 
501  $this->modifyForExpiry( $tables, $conds, $joinConds, $dbr );
502 
503  return (int)$dbr->selectField(
504  $tables,
505  'COUNT(*)',
506  $conds,
507  __METHOD__,
508  [],
509  $joinConds
510  );
511  }
512 
519  public function countVisitingWatchers( $target, $threshold ): int {
520  $dbr = $this->getConnectionRef( DB_REPLICA );
521  $tables = [ 'watchlist' ];
522  $conds = [
523  'wl_namespace' => $target->getNamespace(),
524  'wl_title' => $target->getDBkey(),
525  'wl_notificationtimestamp >= ' .
526  $dbr->addQuotes( $dbr->timestamp( $threshold ) ) .
527  ' OR wl_notificationtimestamp IS NULL'
528  ];
529  $joinConds = [];
530 
531  $this->modifyForExpiry( $tables, $conds, $joinConds, $dbr );
532 
533  return (int)$dbr->selectField(
534  $tables,
535  'COUNT(*)',
536  $conds,
537  __METHOD__,
538  [],
539  $joinConds
540  );
541  }
542 
548  public function removeWatchBatchForUser( UserIdentity $user, array $titles ): bool {
549  if ( $this->readOnlyMode->isReadOnly() ) {
550  return false;
551  }
552  if ( !$user->isRegistered() ) {
553  return false;
554  }
555  if ( !$titles ) {
556  return true;
557  }
558 
559  $rows = $this->getTitleDbKeysGroupedByNamespace( $titles );
560  $this->uncacheTitlesForUser( $user, $titles );
561 
562  $dbw = $this->getConnectionRef( DB_PRIMARY );
563  $ticket = count( $titles ) > $this->updateRowsPerQuery ?
564  $this->lbFactory->getEmptyTransactionTicket( __METHOD__ ) : null;
565  $affectedRows = 0;
566 
567  // Batch delete items per namespace.
568  foreach ( $rows as $namespace => $namespaceTitles ) {
569  $rowBatches = array_chunk( $namespaceTitles, $this->updateRowsPerQuery );
570  foreach ( $rowBatches as $toDelete ) {
571  // First fetch the wl_ids.
572  $wlIds = $dbw->selectFieldValues(
573  'watchlist',
574  'wl_id',
575  [
576  'wl_user' => $user->getId(),
577  'wl_namespace' => $namespace,
578  'wl_title' => $toDelete
579  ],
580  __METHOD__
581  );
582 
583  if ( $wlIds ) {
584  // Delete rows from both the watchlist and watchlist_expiry tables.
585  $dbw->delete(
586  'watchlist',
587  [ 'wl_id' => $wlIds ],
588  __METHOD__
589  );
590  $affectedRows += $dbw->affectedRows();
591 
592  if ( $this->expiryEnabled ) {
593  $dbw->delete(
594  'watchlist_expiry',
595  [ 'we_item' => $wlIds ],
596  __METHOD__
597  );
598  $affectedRows += $dbw->affectedRows();
599  }
600  }
601 
602  if ( $ticket ) {
603  $this->lbFactory->commitAndWaitForReplication( __METHOD__, $ticket );
604  }
605  }
606  }
607 
608  return (bool)$affectedRows;
609  }
610 
618  public function countWatchersMultiple( array $targets, array $options = [] ): array {
619  $dbOptions = [ 'GROUP BY' => [ 'wl_namespace', 'wl_title' ] ];
620 
621  if ( array_key_exists( 'minimumWatchers', $options ) ) {
622  $dbOptions['HAVING'] = 'COUNT(*) >= ' . (int)$options['minimumWatchers'];
623  }
624 
625  $linkTargets = array_map( static function ( $target ) {
626  if ( !$target instanceof LinkTarget ) {
627  return new TitleValue( $target->getNamespace(), $target->getDBkey() );
628  }
629  return $target;
630  }, $targets );
631  $lb = $this->linkBatchFactory->newLinkBatch( $linkTargets );
632  $dbr = $this->getConnectionRef( DB_REPLICA );
633 
634  $tables = [ 'watchlist' ];
635  $conds = [ $lb->constructSet( 'wl', $dbr ) ];
636  $joinConds = [];
637 
638  $this->modifyForExpiry( $tables, $conds, $joinConds, $dbr );
639 
640  $res = $dbr->select(
641  $tables,
642  [ 'wl_title', 'wl_namespace', 'watchers' => 'COUNT(*)' ],
643  $conds,
644  __METHOD__,
645  $dbOptions,
646  $joinConds
647  );
648 
649  $watchCounts = [];
650  foreach ( $targets as $linkTarget ) {
651  $watchCounts[$linkTarget->getNamespace()][$linkTarget->getDBkey()] = 0;
652  }
653 
654  foreach ( $res as $row ) {
655  $watchCounts[$row->wl_namespace][$row->wl_title] = (int)$row->watchers;
656  }
657 
658  return $watchCounts;
659  }
660 
670  array $targetsWithVisitThresholds,
671  $minimumWatchers = null
672  ): array {
673  if ( $targetsWithVisitThresholds === [] ) {
674  // No titles requested => no results returned
675  return [];
676  }
677 
678  $dbr = $this->getConnectionRef( DB_REPLICA );
679 
680  $conds = [ $this->getVisitingWatchersCondition( $dbr, $targetsWithVisitThresholds ) ];
681 
682  $dbOptions = [ 'GROUP BY' => [ 'wl_namespace', 'wl_title' ] ];
683  if ( $minimumWatchers !== null ) {
684  $dbOptions['HAVING'] = 'COUNT(*) >= ' . (int)$minimumWatchers;
685  }
686 
687  $tables = [ 'watchlist' ];
688  $joinConds = [];
689 
690  $this->modifyForExpiry( $tables, $conds, $joinConds, $dbr );
691 
692  $res = $dbr->select(
693  $tables,
694  [ 'wl_namespace', 'wl_title', 'watchers' => 'COUNT(*)' ],
695  $conds,
696  __METHOD__,
697  $dbOptions,
698  $joinConds
699  );
700 
701  $watcherCounts = [];
702  foreach ( $targetsWithVisitThresholds as list( $target ) ) {
704  $watcherCounts[$target->getNamespace()][$target->getDBkey()] = 0;
705  }
706 
707  foreach ( $res as $row ) {
708  $watcherCounts[$row->wl_namespace][$row->wl_title] = (int)$row->watchers;
709  }
710 
711  return $watcherCounts;
712  }
713 
723  IDatabase $db,
724  array $targetsWithVisitThresholds
725  ): string {
726  $missingTargets = [];
727  $namespaceConds = [];
728  foreach ( $targetsWithVisitThresholds as list( $target, $threshold ) ) {
729  if ( $threshold === null ) {
730  $missingTargets[] = $target;
731  continue;
732  }
734  $namespaceConds[$target->getNamespace()][] = $db->makeList( [
735  'wl_title = ' . $db->addQuotes( $target->getDBkey() ),
736  $db->makeList( [
737  'wl_notificationtimestamp >= ' . $db->addQuotes( $db->timestamp( $threshold ) ),
738  'wl_notificationtimestamp IS NULL'
739  ], LIST_OR )
740  ], LIST_AND );
741  }
742 
743  $conds = [];
744  foreach ( $namespaceConds as $namespace => $pageConds ) {
745  $conds[] = $db->makeList( [
746  'wl_namespace = ' . $namespace,
747  '(' . $db->makeList( $pageConds, LIST_OR ) . ')'
748  ], LIST_AND );
749  }
750 
751  if ( $missingTargets ) {
752  $lb = $this->linkBatchFactory->newLinkBatch( $missingTargets );
753  $conds[] = $lb->constructSet( 'wl', $db );
754  }
755 
756  return $db->makeList( $conds, LIST_OR );
757  }
758 
765  public function getWatchedItem( UserIdentity $user, $target ) {
766  if ( !$user->isRegistered() ) {
767  return false;
768  }
769 
770  $cached = $this->getCached( $user, $target );
771  if ( $cached && !$cached->isExpired() ) {
772  $this->stats->increment( 'WatchedItemStore.getWatchedItem.cached' );
773  return $cached;
774  }
775  $this->stats->increment( 'WatchedItemStore.getWatchedItem.load' );
776  return $this->loadWatchedItem( $user, $target );
777  }
778 
785  public function loadWatchedItem( UserIdentity $user, $target ) {
786  $item = $this->loadWatchedItemsBatch( $user, [ $target ] );
787  return $item ? $item[0] : false;
788  }
789 
796  public function loadWatchedItemsBatch( UserIdentity $user, array $targets ) {
797  // Only registered user can have a watchlist
798  if ( !$user->isRegistered() ) {
799  return false;
800  }
801 
802  $dbr = $this->getConnectionRef( DB_REPLICA );
803 
804  $rows = $this->fetchWatchedItems(
805  $dbr,
806  $user,
807  [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
808  [],
809  $targets
810  );
811 
812  if ( !$rows ) {
813  return false;
814  }
815 
816  $items = [];
817  foreach ( $rows as $row ) {
818  // TODO: convert to PageIdentity
819  $target = new TitleValue( (int)$row->wl_namespace, $row->wl_title );
820  $item = $this->getWatchedItemFromRow( $user, $target, $row );
821  $this->cache( $item );
822  $items[] = $item;
823  }
824 
825  return $items;
826  }
827 
838  public function getWatchedItemsForUser( UserIdentity $user, array $options = [] ): array {
839  $options += [ 'forWrite' => false ];
840  $vars = [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ];
841  $dbOptions = [];
842  $db = $this->getConnectionRef( $options['forWrite'] ? DB_PRIMARY : DB_REPLICA );
843  if ( array_key_exists( 'sort', $options ) ) {
844  Assert::parameter(
845  ( in_array( $options['sort'], [ self::SORT_ASC, self::SORT_DESC ] ) ),
846  '$options[\'sort\']',
847  'must be SORT_ASC or SORT_DESC'
848  );
849  $dbOptions['ORDER BY'][] = "wl_namespace {$options['sort']}";
850  if ( $this->expiryEnabled
851  && array_key_exists( 'sortByExpiry', $options )
852  && $options['sortByExpiry']
853  ) {
854  // Add `wl_has_expiry` column to allow sorting by watched titles that have an expiration date first.
855  $vars['wl_has_expiry'] = $db->conditional( 'we_expiry IS NULL', 0, 1 );
856  // Display temporarily watched titles first.
857  // Order by expiration date, with the titles that will expire soonest at the top.
858  $dbOptions['ORDER BY'][] = "wl_has_expiry DESC";
859  $dbOptions['ORDER BY'][] = "we_expiry ASC";
860  }
861 
862  $dbOptions['ORDER BY'][] = "wl_title {$options['sort']}";
863  }
864 
865  $res = $this->fetchWatchedItems(
866  $db,
867  $user,
868  $vars,
869  $dbOptions
870  );
871 
872  $watchedItems = [];
873  foreach ( $res as $row ) {
874  // TODO: convert to PageIdentity
875  $target = new TitleValue( (int)$row->wl_namespace, $row->wl_title );
876  // @todo: Should we add these to the process cache?
877  $watchedItems[] = $this->getWatchedItemFromRow( $user, $target, $row );
878  }
879 
880  return $watchedItems;
881  }
882 
890  private function getWatchedItemFromRow(
891  UserIdentity $user,
892  $target,
893  stdClass $row
894  ): WatchedItem {
895  return new WatchedItem(
896  $user,
897  $target,
899  $row->wl_notificationtimestamp, $user, $target ),
900  wfTimestampOrNull( TS_ISO_8601, $row->we_expiry ?? null )
901  );
902  }
903 
917  private function fetchWatchedItems(
918  IDatabase $db,
919  UserIdentity $user,
920  array $vars,
921  array $options = [],
922  $target = null
923  ) {
924  $dbMethod = 'select';
925  $conds = [ 'wl_user' => $user->getId() ];
926 
927  if ( $target ) {
928  if ( $target instanceof LinkTarget || $target instanceof PageIdentity ) {
929  $dbMethod = 'selectRow';
930  $conds = array_merge( $conds, [
931  'wl_namespace' => $target->getNamespace(),
932  'wl_title' => $target->getDBkey(),
933  ] );
934  } else {
935  $titleConds = [];
936  foreach ( $target as $linkTarget ) {
937  $titleConds[] = $db->makeList(
938  [
939  'wl_namespace' => $linkTarget->getNamespace(),
940  'wl_title' => $linkTarget->getDBkey(),
941  ],
943  );
944  }
945  $conds[] = $db->makeList( $titleConds, $db::LIST_OR );
946  }
947  }
948 
949  $tables = [ 'watchlist' ];
950  $joinConds = [];
951  $this->modifyForExpiry( $tables, $conds, $joinConds, $db );
952 
953  if ( $this->expiryEnabled ) {
954  $vars[] = 'we_expiry';
955  }
956 
957  return $db->{$dbMethod}(
958  $tables,
959  $vars,
960  $conds,
961  __METHOD__,
962  $options,
963  $joinConds
964  );
965  }
966 
973  public function isWatched( UserIdentity $user, $target ): bool {
974  return (bool)$this->getWatchedItem( $user, $target );
975  }
976 
984  public function isTempWatched( UserIdentity $user, $target ): bool {
985  $item = $this->getWatchedItem( $user, $target );
986  return $item && $item->getExpiry();
987  }
988 
996  public function getNotificationTimestampsBatch( UserIdentity $user, array $targets ): array {
997  $timestamps = [];
998  foreach ( $targets as $target ) {
999  $timestamps[$target->getNamespace()][$target->getDBkey()] = false;
1000  }
1001 
1002  if ( !$user->isRegistered() ) {
1003  return $timestamps;
1004  }
1005 
1006  $targetsToLoad = [];
1007  foreach ( $targets as $target ) {
1008  $cachedItem = $this->getCached( $user, $target );
1009  if ( $cachedItem ) {
1010  $timestamps[$target->getNamespace()][$target->getDBkey()] =
1011  $cachedItem->getNotificationTimestamp();
1012  } else {
1013  $targetsToLoad[] = $target;
1014  }
1015  }
1016 
1017  if ( !$targetsToLoad ) {
1018  return $timestamps;
1019  }
1020 
1021  $dbr = $this->getConnectionRef( DB_REPLICA );
1022 
1023  $lb = $this->linkBatchFactory->newLinkBatch( $targetsToLoad );
1024  $res = $dbr->select(
1025  'watchlist',
1026  [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
1027  [
1028  $lb->constructSet( 'wl', $dbr ),
1029  'wl_user' => $user->getId(),
1030  ],
1031  __METHOD__
1032  );
1033 
1034  foreach ( $res as $row ) {
1035  // TODO: convert to PageIdentity
1036  $target = new TitleValue( (int)$row->wl_namespace, $row->wl_title );
1037  $timestamps[$row->wl_namespace][$row->wl_title] =
1038  $this->getLatestNotificationTimestamp(
1039  $row->wl_notificationtimestamp, $user, $target );
1040  }
1041 
1042  return $timestamps;
1043  }
1044 
1053  public function addWatch( UserIdentity $user, $target, ?string $expiry = null ) {
1054  $this->addWatchBatchForUser( $user, [ $target ], $expiry );
1055 
1056  if ( $this->expiryEnabled && !$expiry ) {
1057  // When re-watching a page with a null $expiry, any existing expiry is left unchanged.
1058  // However we must re-fetch the preexisting expiry or else the cached WatchedItem will
1059  // incorrectly have a null expiry. Note that loadWatchedItem() does the caching.
1060  // See T259379
1061  $this->loadWatchedItem( $user, $target );
1062  } else {
1063  // Create a new WatchedItem and add it to the process cache.
1064  // In this case we don't need to re-fetch the expiry.
1065  $expiry = ExpiryDef::normalizeUsingMaxExpiry( $expiry, $this->maxExpiryDuration, TS_ISO_8601 );
1066  $item = new WatchedItem(
1067  $user,
1068  $target,
1069  null,
1070  $expiry
1071  );
1072  $this->cache( $item );
1073  }
1074  }
1075 
1089  public function addWatchBatchForUser(
1090  UserIdentity $user,
1091  array $targets,
1092  ?string $expiry = null
1093  ): bool {
1094  if ( $this->readOnlyMode->isReadOnly() ) {
1095  return false;
1096  }
1097  // Only registered user can have a watchlist
1098  if ( !$user->isRegistered() ) {
1099  return false;
1100  }
1101 
1102  if ( !$targets ) {
1103  return true;
1104  }
1105  $expiry = ExpiryDef::normalizeUsingMaxExpiry( $expiry, $this->maxExpiryDuration, TS_ISO_8601 );
1106  $rows = [];
1107  foreach ( $targets as $target ) {
1108  $rows[] = [
1109  'wl_user' => $user->getId(),
1110  'wl_namespace' => $target->getNamespace(),
1111  'wl_title' => $target->getDBkey(),
1112  'wl_notificationtimestamp' => null,
1113  ];
1114  $this->uncache( $user, $target );
1115  }
1116 
1117  $dbw = $this->getConnectionRef( DB_PRIMARY );
1118  $ticket = count( $targets ) > $this->updateRowsPerQuery ?
1119  $this->lbFactory->getEmptyTransactionTicket( __METHOD__ ) : null;
1120  $affectedRows = 0;
1121  $rowBatches = array_chunk( $rows, $this->updateRowsPerQuery );
1122  foreach ( $rowBatches as $toInsert ) {
1123  // Use INSERT IGNORE to avoid overwriting the notification timestamp
1124  // if there's already an entry for this page
1125  $dbw->insert( 'watchlist', $toInsert, __METHOD__, [ 'IGNORE' ] );
1126  $affectedRows += $dbw->affectedRows();
1127 
1128  if ( $this->expiryEnabled ) {
1129  $affectedRows += $this->updateOrDeleteExpiries( $dbw, $user->getId(), $toInsert, $expiry );
1130  }
1131 
1132  if ( $ticket ) {
1133  $this->lbFactory->commitAndWaitForReplication( __METHOD__, $ticket );
1134  }
1135  }
1136 
1137  return (bool)$affectedRows;
1138  }
1139 
1149  private function updateOrDeleteExpiries(
1150  IDatabase $dbw,
1151  int $userId,
1152  array $rows,
1153  ?string $expiry = null
1154  ): int {
1155  if ( !$expiry ) {
1156  // if expiry is null (shouldn't change), 0 rows affected.
1157  return 0;
1158  }
1159 
1160  // Build the giant `(...) OR (...)` part to be used with WHERE.
1161  $conds = [];
1162  foreach ( $rows as $row ) {
1163  $conds[] = $dbw->makeList(
1164  [
1165  'wl_user' => $userId,
1166  'wl_namespace' => $row['wl_namespace'],
1167  'wl_title' => $row['wl_title']
1168  ],
1170  );
1171  }
1172  $cond = $dbw->makeList( $conds, $dbw::LIST_OR );
1173 
1174  if ( wfIsInfinity( $expiry ) ) {
1175  // Rows should be deleted rather than updated.
1176  $dbw->deleteJoin(
1177  'watchlist_expiry',
1178  'watchlist',
1179  'we_item',
1180  'wl_id',
1181  [ $cond ],
1182  __METHOD__
1183  );
1184 
1185  return $dbw->affectedRows();
1186  }
1187 
1188  return $this->updateExpiries( $dbw, $expiry, $cond );
1189  }
1190 
1198  private function updateExpiries( IDatabase $dbw, string $expiry, string $cond ): int {
1199  // First fetch the wl_ids from the watchlist table.
1200  // We'd prefer to do a INSERT/SELECT in the same query with IDatabase::insertSelect(),
1201  // but it doesn't allow us to use the "ON DUPLICATE KEY UPDATE" clause.
1202  $wlIds = $dbw->selectFieldValues( 'watchlist', 'wl_id', $cond, __METHOD__ );
1203 
1204  $expiry = $dbw->timestamp( $expiry );
1205 
1206  $weRows = array_map( static function ( $wlId ) use ( $expiry ) {
1207  return [
1208  'we_item' => $wlId,
1209  'we_expiry' => $expiry
1210  ];
1211  }, $wlIds );
1212 
1213  // Insert into watchlist_expiry, updating the expiry for duplicate rows.
1214  $dbw->upsert(
1215  'watchlist_expiry',
1216  $weRows,
1217  'we_item',
1218  [ 'we_expiry' => $expiry ],
1219  __METHOD__
1220  );
1221 
1222  return $dbw->affectedRows();
1223  }
1224 
1231  public function removeWatch( UserIdentity $user, $target ): bool {
1232  return $this->removeWatchBatchForUser( $user, [ $target ] );
1233  }
1234 
1253  UserIdentity $user,
1254  $timestamp,
1255  array $targets = []
1256  ): bool {
1257  // Only registered user can have a watchlist
1258  if ( !$user->isRegistered() || $this->readOnlyMode->isReadOnly() ) {
1259  return false;
1260  }
1261 
1262  if ( !$targets ) {
1263  // Backwards compatibility
1264  $this->resetAllNotificationTimestampsForUser( $user, $timestamp );
1265  return true;
1266  }
1267 
1268  $rows = $this->getTitleDbKeysGroupedByNamespace( $targets );
1269 
1270  $dbw = $this->getConnectionRef( DB_PRIMARY );
1271  if ( $timestamp !== null ) {
1272  $timestamp = $dbw->timestamp( $timestamp );
1273  }
1274  $ticket = $this->lbFactory->getEmptyTransactionTicket( __METHOD__ );
1275  $affectedSinceWait = 0;
1276 
1277  // Batch update items per namespace
1278  foreach ( $rows as $namespace => $namespaceTitles ) {
1279  $rowBatches = array_chunk( $namespaceTitles, $this->updateRowsPerQuery );
1280  foreach ( $rowBatches as $toUpdate ) {
1281  $dbw->update(
1282  'watchlist',
1283  [ 'wl_notificationtimestamp' => $timestamp ],
1284  [
1285  'wl_user' => $user->getId(),
1286  'wl_namespace' => $namespace,
1287  'wl_title' => $toUpdate
1288  ],
1289  __METHOD__
1290  );
1291  $affectedSinceWait += $dbw->affectedRows();
1292  // Wait for replication every time we've touched updateRowsPerQuery rows
1293  if ( $affectedSinceWait >= $this->updateRowsPerQuery ) {
1294  $this->lbFactory->commitAndWaitForReplication( __METHOD__, $ticket );
1295  $affectedSinceWait = 0;
1296  }
1297  }
1298  }
1299 
1300  $this->uncacheUser( $user );
1301 
1302  return true;
1303  }
1304 
1312  $timestamp,
1313  UserIdentity $user,
1314  $target
1315  ) {
1316  $timestamp = wfTimestampOrNull( TS_MW, $timestamp );
1317  if ( $timestamp === null ) {
1318  return null; // no notification
1319  }
1320 
1321  $seenTimestamps = $this->getPageSeenTimestamps( $user );
1322  if (
1323  $seenTimestamps &&
1324  $seenTimestamps->get( $this->getPageSeenKey( $target ) ) >= $timestamp
1325  ) {
1326  // If a reset job did not yet run, then the "seen" timestamp will be higher
1327  return null;
1328  }
1329 
1330  return $timestamp;
1331  }
1332 
1339  public function resetAllNotificationTimestampsForUser( UserIdentity $user, $timestamp = null ) {
1340  // Only registered user can have a watchlist
1341  if ( !$user->isRegistered() ) {
1342  return;
1343  }
1344 
1345  // If the page is watched by the user (or may be watched), update the timestamp
1347  'userId' => $user->getId(), 'timestamp' => $timestamp, 'casTime' => time()
1348  ] );
1349 
1350  // Try to run this post-send
1351  // Calls DeferredUpdates::addCallableUpdate in normal operation
1352  call_user_func(
1353  $this->deferredUpdatesAddCallableUpdateCallback,
1354  static function () use ( $job ) {
1355  $job->run();
1356  }
1357  );
1358  }
1359 
1368  UserIdentity $editor,
1369  $target,
1370  $timestamp
1371  ): array {
1372  $dbw = $this->getConnectionRef( DB_PRIMARY );
1373  $selectTables = [ 'watchlist' ];
1374  $selectConds = [
1375  'wl_user != ' . $editor->getId(),
1376  'wl_namespace' => $target->getNamespace(),
1377  'wl_title' => $target->getDBkey(),
1378  'wl_notificationtimestamp IS NULL',
1379  ];
1380  $selectJoin = [];
1381 
1382  $this->modifyForExpiry( $selectTables, $selectConds, $selectJoin, $dbw );
1383 
1384  $uids = $dbw->selectFieldValues(
1385  $selectTables,
1386  'wl_user',
1387  $selectConds,
1388  __METHOD__,
1389  [],
1390  $selectJoin
1391  );
1392 
1393  $watchers = array_map( 'intval', $uids );
1394  if ( $watchers ) {
1395  // Update wl_notificationtimestamp for all watching users except the editor
1396  $fname = __METHOD__;
1397 
1398  // Try to run this post-send
1399  // Calls DeferredUpdates::addCallableUpdate in normal operation
1400  call_user_func(
1401  $this->deferredUpdatesAddCallableUpdateCallback,
1402  function () use ( $timestamp, $watchers, $target, $fname ) {
1403  $dbw = $this->getConnectionRef( DB_PRIMARY );
1404  $ticket = $this->lbFactory->getEmptyTransactionTicket( $fname );
1405 
1406  $watchersChunks = array_chunk( $watchers, $this->updateRowsPerQuery );
1407  foreach ( $watchersChunks as $watchersChunk ) {
1408  $dbw->update( 'watchlist',
1409  [ /* SET */
1410  'wl_notificationtimestamp' => $dbw->timestamp( $timestamp )
1411  ], [ /* WHERE - TODO Use wl_id T130067 */
1412  'wl_user' => $watchersChunk,
1413  'wl_namespace' => $target->getNamespace(),
1414  'wl_title' => $target->getDBkey(),
1415  ], $fname
1416  );
1417  if ( count( $watchersChunks ) > 1 ) {
1418  $this->lbFactory->commitAndWaitForReplication(
1419  $fname, $ticket, [ 'domain' => $dbw->getDomainID() ]
1420  );
1421  }
1422  }
1423  $this->uncacheLinkTarget( $target );
1424  },
1425  DeferredUpdates::POSTSEND,
1426  $dbw
1427  );
1428  }
1429 
1430  return $watchers;
1431  }
1432 
1442  UserIdentity $user,
1443  $title,
1444  $force = '',
1445  $oldid = 0
1446  ): bool {
1447  $time = time();
1448 
1449  // Only registered user can have a watchlist
1450  if ( $this->readOnlyMode->isReadOnly() || !$user->isRegistered() ) {
1451  return false;
1452  }
1453 
1454  // Hook expects User and Title, not UserIdentity and LinkTarget|PageIdentity
1455  $userObj = $this->userFactory->newFromUserIdentity( $user );
1456  if ( $title instanceof LinkTarget ) {
1457  $titleObj = $this->titleFactory->castFromLinkTarget( $title );
1458  } else {
1459  // instanceof PageIdentity
1460  $titleObj = $this->titleFactory->castFromPageIdentity( $title );
1461  }
1462  if ( !$this->hookRunner->onBeforeResetNotificationTimestamp(
1463  $userObj, $titleObj, $force, $oldid )
1464  ) {
1465  return false;
1466  }
1467  if ( !$userObj->equals( $user ) ) {
1468  $user = $userObj;
1469  }
1470  if ( !$titleObj->equals( $title ) ) {
1471  $title = $titleObj;
1472  }
1473 
1474  $item = null;
1475  if ( $force != 'force' ) {
1476  $item = $this->loadWatchedItem( $user, $title );
1477  if ( !$item || $item->getNotificationTimestamp() === null ) {
1478  return false;
1479  }
1480  }
1481 
1482  // Get the timestamp (TS_MW) of this revision to track the latest one seen
1483  $id = $oldid;
1484  $seenTime = null;
1485  if ( !$id ) {
1486  $latestRev = $this->revisionLookup->getRevisionByTitle( $title );
1487  if ( $latestRev ) {
1488  $id = $latestRev->getId();
1489  // Save a DB query
1490  $seenTime = $latestRev->getTimestamp();
1491  }
1492  }
1493  if ( $seenTime === null ) {
1494  $seenTime = $this->revisionLookup->getTimestampFromId( $id );
1495  }
1496 
1497  // Mark the item as read immediately in lightweight storage
1498  $this->stash->merge(
1499  $this->getPageSeenTimestampsKey( $user ),
1500  function ( $cache, $key, $current ) use ( $title, $seenTime ) {
1501  $value = $current ?: new MapCacheLRU( 300 );
1502  $subKey = $this->getPageSeenKey( $title );
1503 
1504  if ( $seenTime > $value->get( $subKey ) ) {
1505  // Revision is newer than the last one seen
1506  $value->set( $subKey, $seenTime );
1507  $this->latestUpdateCache->set( $key, $value, BagOStuff::TTL_PROC_LONG );
1508  } elseif ( $seenTime === false ) {
1509  // Revision does not exist
1510  $value->set( $subKey, wfTimestamp( TS_MW ) );
1511  $this->latestUpdateCache->set( $key, $value, BagOStuff::TTL_PROC_LONG );
1512  } else {
1513  return false; // nothing to update
1514  }
1515 
1516  return $value;
1517  },
1518  BagOStuff::TTL_HOUR
1519  );
1520 
1521  // If the page is watched by the user (or may be watched), update the timestamp
1522  $job = new ActivityUpdateJob(
1523  $titleObj,
1524  [
1525  'type' => 'updateWatchlistNotification',
1526  'userid' => $user->getId(),
1527  'notifTime' => $this->getNotificationTimestamp( $user, $title, $item, $force, $oldid ),
1528  'curTime' => $time
1529  ]
1530  );
1531  // Try to enqueue this post-send
1532  $this->queueGroup->lazyPush( $job );
1533 
1534  $this->uncache( $user, $title );
1535 
1536  return true;
1537  }
1538 
1543  private function getPageSeenTimestamps( UserIdentity $user ) {
1544  $key = $this->getPageSeenTimestampsKey( $user );
1545 
1546  return $this->latestUpdateCache->getWithSetCallback(
1547  $key,
1548  BagOStuff::TTL_PROC_LONG,
1549  function () use ( $key ) {
1550  return $this->stash->get( $key ) ?: null;
1551  }
1552  );
1553  }
1554 
1559  private function getPageSeenTimestampsKey( UserIdentity $user ): string {
1560  return $this->stash->makeGlobalKey(
1561  'watchlist-recent-updates',
1562  $this->lbFactory->getLocalDomainID(),
1563  $user->getId()
1564  );
1565  }
1566 
1571  private function getPageSeenKey( $target ): string {
1572  return "{$target->getNamespace()}:{$target->getDBkey()}";
1573  }
1574 
1583  private function getNotificationTimestamp(
1584  UserIdentity $user,
1585  $title,
1586  $item,
1587  $force,
1588  $oldid
1589  ) {
1590  if ( !$oldid ) {
1591  // No oldid given, assuming latest revision; clear the timestamp.
1592  return null;
1593  }
1594 
1595  $oldRev = $this->revisionLookup->getRevisionById( $oldid );
1596  if ( !$oldRev ) {
1597  // Oldid given but does not exist (probably deleted)
1598  return false;
1599  }
1600 
1601  $nextRev = $this->revisionLookup->getNextRevision( $oldRev );
1602  if ( !$nextRev ) {
1603  // Oldid given and is the latest revision for this title; clear the timestamp.
1604  return null;
1605  }
1606 
1607  if ( $item === null ) {
1608  $item = $this->loadWatchedItem( $user, $title );
1609  }
1610 
1611  if ( !$item ) {
1612  // This can only happen if $force is enabled.
1613  return null;
1614  }
1615 
1616  // Oldid given and isn't the latest; update the timestamp.
1617  // This will result in no further notification emails being sent!
1618  $notificationTimestamp = $this->revisionLookup->getTimestampFromId( $oldid );
1619  // @FIXME: this should use getTimestamp() for consistency with updates on new edits
1620  // $notificationTimestamp = $nextRev->getTimestamp(); // first unseen revision timestamp
1621 
1622  // We need to go one second to the future because of various strict comparisons
1623  // throughout the codebase
1624  $ts = new MWTimestamp( $notificationTimestamp );
1625  $ts->timestamp->add( new DateInterval( 'PT1S' ) );
1626  $notificationTimestamp = $ts->getTimestamp( TS_MW );
1627 
1628  if ( $notificationTimestamp < $item->getNotificationTimestamp() ) {
1629  if ( $force != 'force' ) {
1630  return false;
1631  } else {
1632  // This is a little silly‚Ķ
1633  return $item->getNotificationTimestamp();
1634  }
1635  }
1636 
1637  return $notificationTimestamp;
1638  }
1639 
1646  public function countUnreadNotifications( UserIdentity $user, $unreadLimit = null ) {
1647  $dbr = $this->getConnectionRef( DB_REPLICA );
1648 
1649  $queryOptions = [];
1650  if ( $unreadLimit !== null ) {
1651  $unreadLimit = (int)$unreadLimit;
1652  $queryOptions['LIMIT'] = $unreadLimit;
1653  }
1654 
1655  $conds = [
1656  'wl_user' => $user->getId(),
1657  'wl_notificationtimestamp IS NOT NULL'
1658  ];
1659 
1660  $rowCount = $dbr->selectRowCount( 'watchlist', '1', $conds, __METHOD__, $queryOptions );
1661 
1662  if ( $unreadLimit === null ) {
1663  return $rowCount;
1664  }
1665 
1666  if ( $rowCount >= $unreadLimit ) {
1667  return true;
1668  }
1669 
1670  return $rowCount;
1671  }
1672 
1678  public function duplicateAllAssociatedEntries( $oldTarget, $newTarget ) {
1679  // Duplicate first the subject page, then the talk page
1680  // TODO: convert to PageIdentity
1681  $this->duplicateEntry(
1682  new TitleValue( $this->nsInfo->getSubject( $oldTarget->getNamespace() ), $oldTarget->getDBkey() ),
1683  new TitleValue( $this->nsInfo->getSubject( $newTarget->getNamespace() ), $newTarget->getDBkey() )
1684  );
1685  $this->duplicateEntry(
1686  new TitleValue( $this->nsInfo->getTalk( $oldTarget->getNamespace() ), $oldTarget->getDBkey() ),
1687  new TitleValue( $this->nsInfo->getTalk( $newTarget->getNamespace() ), $newTarget->getDBkey() )
1688  );
1689  }
1690 
1696  public function duplicateEntry( $oldTarget, $newTarget ) {
1697  $dbw = $this->getConnectionRef( DB_PRIMARY );
1698  $result = $this->fetchWatchedItemsForPage( $dbw, $oldTarget );
1699  $newNamespace = $newTarget->getNamespace();
1700  $newDBkey = $newTarget->getDBkey();
1701 
1702  # Construct array to replace into the watchlist
1703  $values = [];
1704  $expiries = [];
1705  foreach ( $result as $row ) {
1706  $values[] = [
1707  'wl_user' => $row->wl_user,
1708  'wl_namespace' => $newNamespace,
1709  'wl_title' => $newDBkey,
1710  'wl_notificationtimestamp' => $row->wl_notificationtimestamp,
1711  ];
1712 
1713  if ( $this->expiryEnabled && $row->we_expiry ) {
1714  $expiries[$row->wl_user] = $row->we_expiry;
1715  }
1716  }
1717 
1718  if ( empty( $values ) ) {
1719  return;
1720  }
1721 
1722  // Perform a replace on the watchlist table rows.
1723  // Note that multi-row replace is very efficient for MySQL but may be inefficient for
1724  // some other DBMSes, mostly due to poor simulation by us.
1725  $dbw->replace(
1726  'watchlist',
1727  [ [ 'wl_user', 'wl_namespace', 'wl_title' ] ],
1728  $values,
1729  __METHOD__
1730  );
1731 
1732  if ( $this->expiryEnabled ) {
1733  $this->updateExpiriesAfterMove( $dbw, $expiries, $newNamespace, $newDBkey );
1734  }
1735  }
1736 
1742  private function fetchWatchedItemsForPage(
1743  IDatabase $dbw,
1744  $target
1745  ): IResultWrapper {
1746  $tables = [ 'watchlist' ];
1747  $fields = [ 'wl_user', 'wl_notificationtimestamp' ];
1748  $joins = [];
1749 
1750  if ( $this->expiryEnabled ) {
1751  $tables[] = 'watchlist_expiry';
1752  $fields[] = 'we_expiry';
1753  $joins['watchlist_expiry'] = [ 'LEFT JOIN', [ 'wl_id = we_item' ] ];
1754  }
1755 
1756  return $dbw->select(
1757  $tables,
1758  $fields,
1759  [
1760  'wl_namespace' => $target->getNamespace(),
1761  'wl_title' => $target->getDBkey(),
1762  ],
1763  __METHOD__,
1764  [ 'FOR UPDATE' ],
1765  $joins
1766  );
1767  }
1768 
1775  private function updateExpiriesAfterMove(
1776  IDatabase $dbw,
1777  array $expiries,
1778  int $namespace,
1779  string $dbKey
1780  ): void {
1781  $method = __METHOD__;
1783  function () use ( $dbw, $expiries, $namespace, $dbKey, $method ) {
1784  // First fetch new wl_ids.
1785  $res = $dbw->select(
1786  'watchlist',
1787  [ 'wl_user', 'wl_id' ],
1788  [
1789  'wl_namespace' => $namespace,
1790  'wl_title' => $dbKey,
1791  ],
1792  $method
1793  );
1794 
1795  // Build new array to INSERT into multiple rows at once.
1796  $expiryData = [];
1797  foreach ( $res as $row ) {
1798  if ( !empty( $expiries[$row->wl_user] ) ) {
1799  $expiryData[] = [
1800  'we_item' => $row->wl_id,
1801  'we_expiry' => $expiries[$row->wl_user],
1802  ];
1803  }
1804  }
1805 
1806  // Batch the insertions.
1807  $batches = array_chunk( $expiryData, $this->updateRowsPerQuery );
1808  foreach ( $batches as $toInsert ) {
1809  $dbw->replace(
1810  'watchlist_expiry',
1811  'we_item',
1812  $toInsert,
1813  $method
1814  );
1815  }
1816  },
1817  DeferredUpdates::POSTSEND,
1818  $dbw
1819  );
1820  }
1821 
1826  private function getTitleDbKeysGroupedByNamespace( array $titles ) {
1827  $rows = [];
1828  foreach ( $titles as $title ) {
1829  // Group titles by namespace.
1830  $rows[ $title->getNamespace() ][] = $title->getDBkey();
1831  }
1832  return $rows;
1833  }
1834 
1839  private function uncacheTitlesForUser( UserIdentity $user, array $titles ) {
1840  foreach ( $titles as $title ) {
1841  $this->uncache( $user, $title );
1842  }
1843  }
1844 
1848  public function countExpired(): int {
1849  $dbr = $this->getConnectionRef( DB_REPLICA );
1850  return $dbr->selectRowCount(
1851  'watchlist_expiry',
1852  '*',
1853  [ 'we_expiry <= ' . $dbr->addQuotes( $dbr->timestamp() ) ],
1854  __METHOD__
1855  );
1856  }
1857 
1861  public function removeExpired( int $limit, bool $deleteOrphans = false ): void {
1862  $dbr = $this->getConnectionRef( DB_REPLICA );
1863  $dbw = $this->getConnectionRef( DB_PRIMARY );
1864  $ticket = $this->lbFactory->getEmptyTransactionTicket( __METHOD__ );
1865 
1866  // Get a batch of watchlist IDs to delete.
1867  $toDelete = $dbr->selectFieldValues(
1868  'watchlist_expiry',
1869  'we_item',
1870  [ 'we_expiry <= ' . $dbr->addQuotes( $dbr->timestamp() ) ],
1871  __METHOD__,
1872  [ 'LIMIT' => $limit ]
1873  );
1874  if ( count( $toDelete ) > 0 ) {
1875  // Delete them from the watchlist and watchlist_expiry table.
1876  $dbw->delete(
1877  'watchlist',
1878  [ 'wl_id' => $toDelete ],
1879  __METHOD__
1880  );
1881  $dbw->delete(
1882  'watchlist_expiry',
1883  [ 'we_item' => $toDelete ],
1884  __METHOD__
1885  );
1886  }
1887 
1888  // Also delete any orphaned or null-expiry watchlist_expiry rows
1889  // (they should not exist, but might because not everywhere knows about the expiry table yet).
1890  if ( $deleteOrphans ) {
1891  $expiryToDelete = $dbr->selectFieldValues(
1892  [ 'watchlist_expiry', 'watchlist' ],
1893  'we_item',
1894  $dbr->makeList(
1895  [ 'wl_id' => null, 'we_expiry' => null ],
1897  ),
1898  __METHOD__,
1899  [],
1900  [ 'watchlist' => [ 'LEFT JOIN', 'wl_id = we_item' ] ]
1901  );
1902  if ( count( $expiryToDelete ) > 0 ) {
1903  $dbw->delete(
1904  'watchlist_expiry',
1905  [ 'we_item' => $expiryToDelete ],
1906  __METHOD__
1907  );
1908  }
1909  }
1910 
1911  $this->lbFactory->commitAndWaitForReplication( __METHOD__, $ticket );
1912  }
1913 }
LIST_OR
const LIST_OR
Definition: Defines.php:46
Page\PageIdentity
Interface for objects (potentially) representing an editable wiki page.
Definition: PageIdentity.php:64
MWTimestamp
Library for creating and parsing MW-style timestamps.
Definition: MWTimestamp.php:38
WatchedItemStore\countWatchers
countWatchers( $target)
Definition: WatchedItemStore.php:492
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:83
Wikimedia\Rdbms\IDatabase\affectedRows
affectedRows()
Get the number of rows affected by the last write query.
WatchedItemStore\uncacheAllItemsForUser
uncacheAllItemsForUser(UserIdentity $user)
Definition: WatchedItemStore.php:398
ActivityUpdateJob
Job for updating user activity like "last viewed" timestamps.
Definition: ActivityUpdateJob.php:36
HashBagOStuff
Simple store for keeping values in an associative array for the current process.
Definition: HashBagOStuff.php:32
WatchedItemStore\maybeEnqueueWatchlistExpiryJob
maybeEnqueueWatchlistExpiryJob()
Probabilistically add a job to purge the expired watchlist items, if watchlist expiration is enabled,...
Definition: WatchedItemStore.php:437
WatchedItemStore\getNotificationTimestamp
getNotificationTimestamp(UserIdentity $user, $title, $item, $force, $oldid)
Definition: WatchedItemStore.php:1583
WatchedItemStore\getPageSeenTimestamps
getPageSeenTimestamps(UserIdentity $user)
Definition: WatchedItemStore.php:1543
WatchedItemStore\removeWatch
removeWatch(UserIdentity $user, $target)
Definition: WatchedItemStore.php:1231
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:342
WatchedItemStore\uncacheLinkTarget
uncacheLinkTarget( $target)
Definition: WatchedItemStore.php:261
WatchedItemStore\getVisitingWatchersCondition
getVisitingWatchersCondition(IDatabase $db, array $targetsWithVisitThresholds)
Generates condition for the query used in a batch count visiting watchers.
Definition: WatchedItemStore.php:722
wfTimestamp
wfTimestamp( $outputtype=TS_UNIX, $ts=0)
Get a timestamp string in one of various formats.
Definition: GlobalFunctions.php:1691
WatchedItemStore\getNotificationTimestampsBatch
getNotificationTimestampsBatch(UserIdentity $user, array $targets)
Definition: WatchedItemStore.php:996
LIST_AND
const LIST_AND
Definition: Defines.php:43
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:98
WatchedItemStore\duplicateAllAssociatedEntries
duplicateAllAssociatedEntries( $oldTarget, $newTarget)
Definition: WatchedItemStore.php:1678
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:469
BagOStuff
Class representing a cache/ephemeral data store.
Definition: BagOStuff.php:86
MediaWiki\User\UserIdentity\getId
getId( $wikiId=self::LOCAL)
WatchedItemStore\modifyForExpiry
modifyForExpiry(array &$tables, array &$conds, array &$joinConds, IDatabase $db)
Helper method to deduplicate logic around queries that need to be modified if watchlist expiration is...
Definition: WatchedItemStore.php:319
WatchedItemStore\$loadBalancer
LoadBalancer $loadBalancer
Definition: WatchedItemStore.php:49
Wikimedia\Rdbms\ILBFactory\getMainLB
getMainLB( $domain=false)
Get the tracked load balancer instance for a main cluster.
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:917
$res
$res
Definition: testCompression.php:57
WatchedItemStore\getCached
getCached(UserIdentity $user, $target)
Definition: WatchedItemStore.php:297
WatchedItemStore\$titleFactory
TitleFactory $titleFactory
Definition: WatchedItemStore.php:129
WatchedItemStore\countExpired
countExpired()
Get the number of watchlist items that expire before the current time.1.35int
Definition: WatchedItemStore.php:1848
MediaWiki\User\UserIdentity
Interface for objects representing user identity.
Definition: UserIdentity.php:39
WatchedItemStore\$cache
HashBagOStuff $cache
Definition: WatchedItemStore.php:69
MediaWiki\Revision\RevisionLookup
Service for looking up page revisions.
Definition: RevisionLookup.php:38
WatchedItemStore\loadWatchedItemsBatch
loadWatchedItemsBatch(UserIdentity $user, array $targets)
Definition: WatchedItemStore.php:796
WatchedItemStore\getLatestNotificationTimestamp
getLatestNotificationTimestamp( $timestamp, UserIdentity $user, $target)
Definition: WatchedItemStore.php:1311
WatchedItemStore\CONSTRUCTOR_OPTIONS
const CONSTRUCTOR_OPTIONS
Definition: WatchedItemStore.php:34
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:44
Wikimedia\ParamValidator\TypeDef\ExpiryDef
Type definition for expiry timestamps.
Definition: ExpiryDef.php:17
WatchedItemStore\$readOnlyMode
ReadOnlyMode $readOnlyMode
Definition: WatchedItemStore.php:64
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:113
WatchedItemStore\overrideDeferredUpdatesAddCallableUpdateCallback
overrideDeferredUpdatesAddCallableUpdateCallback(callable $callback)
Overrides the DeferredUpdates::addCallableUpdate callback This is intended for use while testing and ...
Definition: WatchedItemStore.php:210
MWException
MediaWiki exception.
Definition: MWException.php:29
WatchedItemStore\getPageSeenTimestampsKey
getPageSeenTimestampsKey(UserIdentity $user)
Definition: WatchedItemStore.php:1559
MediaWiki\Config\ServiceOptions
A class for passing options to services.
Definition: ServiceOptions.php:27
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:26
WatchedItemStore\$queueGroup
JobQueueGroup $queueGroup
Definition: WatchedItemStore.php:54
MediaWiki\Cache\LinkBatchFactory
Definition: LinkBatchFactory.php:39
wfTimestampOrNull
wfTimestampOrNull( $outputtype=TS_UNIX, $ts=null)
Return a formatted timestamp, or null if input is null.
Definition: GlobalFunctions.php:1707
MapCacheLRU
Handles a simple LRU key/value map with a maximum number of entries.
Definition: MapCacheLRU.php:36
$title
$title
Definition: testCompression.php:38
WatchedItemStore\$maxExpiryDuration
string null $maxExpiryDuration
Maximum configured relative expiry.
Definition: WatchedItemStore.php:134
DB_REPLICA
const DB_REPLICA
Definition: defines.php:25
WatchedItemStore\getMaxId
getMaxId()
Definition: WatchedItemStore.php:454
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:1149
WatchedItem\getUserIdentity
getUserIdentity()
Definition: WatchedItem.php:110
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:1861
WatchedItemStore\getConnectionRef
getConnectionRef( $dbIndex)
Definition: WatchedItemStore.php:306
WatchedItemStore\$deferredUpdatesAddCallableUpdateCallback
callable null $deferredUpdatesAddCallableUpdateCallback
Definition: WatchedItemStore.php:88
WatchedItemStore\resetNotificationTimestamp
resetNotificationTimestamp(UserIdentity $user, $title, $force='', $oldid=0)
Definition: WatchedItemStore.php:1441
Wikimedia\Rdbms\LoadBalancer
Database connection, tracking, load balancing, and transaction manager for a cluster.
Definition: LoadBalancer.php:43
WatchedItemStore\getCacheKey
getCacheKey(UserIdentity $user, $target)
Definition: WatchedItemStore.php:228
WatchedItemStore\$revisionLookup
RevisionLookup $revisionLookup
Definition: WatchedItemStore.php:103
WatchedItemStore\countUnreadNotifications
countUnreadNotifications(UserIdentity $user, $unreadLimit=null)
Definition: WatchedItemStore.php:1646
StatsdAwareInterface
Describes a Statsd aware interface.
Definition: StatsdAwareInterface.php:13
WatchedItemStore\mustClearWatchedItemsUsingJobQueue
mustClearWatchedItemsUsingJobQueue(UserIdentity $user)
Definition: WatchedItemStore.php:391
DB_PRIMARY
const DB_PRIMARY
Definition: defines.php:27
WatchedItemStore\loadWatchedItem
loadWatchedItem(UserIdentity $user, $target)
Definition: WatchedItemStore.php:785
WatchedItemStore\addWatch
addWatch(UserIdentity $user, $target, ?string $expiry=null)
Definition: WatchedItemStore.php:1053
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:1339
wfIsInfinity
wfIsInfinity( $str)
Determine input string is represents as infinity.
Definition: GlobalFunctions.php:2547
WatchedItem
Representation of a pair of user and title for watchlist entries.
Definition: WatchedItem.php:36
WatchedItem\getTarget
getTarget()
Definition: WatchedItem.php:129
WatchedItemStore\getWatchedItemFromRow
getWatchedItemFromRow(UserIdentity $user, $target, stdClass $row)
Construct a new WatchedItem given a row from watchlist/watchlist_expiry.
Definition: WatchedItemStore.php:890
WatchedItemStore\setNotificationTimestampsForUser
setNotificationTimestampsForUser(UserIdentity $user, $timestamp, array $targets=[])
Set the "last viewed" timestamps for certain titles on a user's watchlist.
Definition: WatchedItemStore.php:1252
WatchedItemStore\$latestUpdateCache
HashBagOStuff $latestUpdateCache
Definition: WatchedItemStore.php:74
WatchedItemStore\uncacheTitlesForUser
uncacheTitlesForUser(UserIdentity $user, array $titles)
Definition: WatchedItemStore.php:1839
WatchedItemStore
Storage layer class for WatchedItems.
Definition: WatchedItemStore.php:29
WatchedItemStore\getPageSeenKey
getPageSeenKey( $target)
Definition: WatchedItemStore.php:1571
WatchlistExpiryJob
Definition: WatchlistExpiryJob.php:5
WatchedItemStore\isTempWatched
isTempWatched(UserIdentity $user, $target)
Check if the user is temporarily watching the page.
Definition: WatchedItemStore.php:984
$cache
$cache
Definition: mcc.php:33
WatchedItemStore\isWatched
isWatched(UserIdentity $user, $target)
Definition: WatchedItemStore.php:973
WatchedItemStore\updateExpiriesAfterMove
updateExpiriesAfterMove(IDatabase $dbw, array $expiries, int $namespace, string $dbKey)
Definition: WatchedItemStore.php:1775
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:108
WatchedItemStore\$stash
BagOStuff $stash
Definition: WatchedItemStore.php:59
$job
if(count( $args)< 1) $job
Definition: recompressTracked.php:49
WatchedItemStore\updateExpiries
updateExpiries(IDatabase $dbw, string $expiry, string $cond)
Update the expiries for items found with the given $cond.
Definition: WatchedItemStore.php:1198
TitleFactory
Creates Title objects.
Definition: TitleFactory.php:35
WatchedItemStore\$userFactory
UserFactory $userFactory
Definition: WatchedItemStore.php:126
WatchedItemStore\duplicateEntry
duplicateEntry( $oldTarget, $newTarget)
Definition: WatchedItemStore.php:1696
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:669
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:123
WatchedItemStore\fetchWatchedItemsForPage
fetchWatchedItemsForPage(IDatabase $dbw, $target)
Definition: WatchedItemStore.php:1742
WatchedItemStore\setStatsdDataFactory
setStatsdDataFactory(StatsdDataFactoryInterface $stats)
Definition: WatchedItemStore.php:195
WatchedItemStore\uncacheUser
uncacheUser(UserIdentity $user)
Definition: WatchedItemStore.php:275
WatchedItemStore\countVisitingWatchers
countVisitingWatchers( $target, $threshold)
Definition: WatchedItemStore.php:519
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, TitleFactory $titleFactory)
Definition: WatchedItemStore.php:153
MediaWiki\Config\ServiceOptions\get
get( $key)
Definition: ServiceOptions.php:93
MediaWiki\HookContainer\HookContainer
HookContainer class.
Definition: HookContainer.php:45
WatchedItemStore\$hookRunner
HookRunner $hookRunner
Definition: WatchedItemStore.php:118
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:552
WatchedItemStore\getWatchedItemsForUser
getWatchedItemsForUser(UserIdentity $user, array $options=[])
Definition: WatchedItemStore.php:838
WatchedItemStore\addWatchBatchForUser
addWatchBatchForUser(UserIdentity $user, array $targets, ?string $expiry=null)
Add multiple items to the user's watchlist.
Definition: WatchedItemStore.php:1089
WatchedItemStore\getWatchedItem
getWatchedItem(UserIdentity $user, $target)
Definition: WatchedItemStore.php:765
WatchedItemStore\updateNotificationTimestamp
updateNotificationTimestamp(UserIdentity $editor, $target, $timestamp)
Definition: WatchedItemStore.php:1367
WatchedItemStore\$watchlistPurgeRate
float $watchlistPurgeRate
corresponds to $wgWatchlistPurgeRate value
Definition: WatchedItemStore.php:137
WatchedItemStore\uncache
uncache(UserIdentity $user, $target)
Definition: WatchedItemStore.php:252
MediaWiki\Linker\LinkTarget
Definition: LinkTarget.php:26
WatchedItemStoreInterface
Definition: WatchedItemStoreInterface.php:31
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:1826
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:41
WatchedItemStore\$updateRowsPerQuery
int $updateRowsPerQuery
Definition: WatchedItemStore.php:93
WatchedItemStore\countWatchersMultiple
countWatchersMultiple(array $targets, array $options=[])
Definition: WatchedItemStore.php:618
MediaWiki\Config\ServiceOptions\assertRequiredOptions
assertRequiredOptions(array $expectedKeys)
Assert that the list of options provided in this instance exactly match $expectedKeys,...
Definition: ServiceOptions.php:71
Wikimedia\Rdbms\ILBFactory
An interface for generating database load balancers.
Definition: ILBFactory.php:33
WatchedItemStore\removeWatchBatchForUser
removeWatchBatchForUser(UserIdentity $user, array $titles)
Definition: WatchedItemStore.php:548
JobQueueGroup
Class to handle enqueueing of background jobs.
Definition: JobQueueGroup.php:32
WatchedItemStore\clearUserWatchedItemsUsingJobQueue
clearUserWatchedItemsUsingJobQueue(UserIdentity $user)
Queues a job that will clear the users watchlist using the Job Queue.
Definition: WatchedItemStore.php:429
WatchedItemStore\cache
cache(WatchedItem $item)
Definition: WatchedItemStore.php:239
TitleValue
Represents a page (or page fragment) title within MediaWiki.
Definition: TitleValue.php:40