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 
127  private $titleFactory;
128 
133 
148  public function __construct(
149  ServiceOptions $options,
157  HookContainer $hookContainer,
161  ) {
162  $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
163  $this->updateRowsPerQuery = $options->get( 'UpdateRowsPerQuery' );
164  $this->expiryEnabled = $options->get( 'WatchlistExpiry' );
165  $this->maxExpiryDuration = $options->get( 'WatchlistExpiryMaxDuration' );
166 
167  $this->lbFactory = $lbFactory;
168  $this->loadBalancer = $lbFactory->getMainLB();
169  $this->queueGroup = $queueGroup;
170  $this->stash = $stash;
171  $this->cache = $cache;
172  $this->readOnlyMode = $readOnlyMode;
173  $this->stats = new NullStatsdDataFactory();
174  $this->deferredUpdatesAddCallableUpdateCallback =
175  [ DeferredUpdates::class, 'addCallableUpdate' ];
176  $this->nsInfo = $nsInfo;
177  $this->revisionLookup = $revisionLookup;
178  $this->hookRunner = new HookRunner( $hookContainer );
179  $this->linkBatchFactory = $linkBatchFactory;
180  $this->userFactory = $userFactory;
181  $this->titleFactory = $titleFactory;
182 
183  $this->latestUpdateCache = new HashBagOStuff( [ 'maxKeys' => 3 ] );
184  }
185 
189  public function setStatsdDataFactory( StatsdDataFactoryInterface $stats ) {
190  $this->stats = $stats;
191  }
192 
204  public function overrideDeferredUpdatesAddCallableUpdateCallback( callable $callback ) {
205  if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
206  throw new MWException(
207  'Cannot override DeferredUpdates::addCallableUpdate callback in operation.'
208  );
209  }
211  $this->deferredUpdatesAddCallableUpdateCallback = $callback;
212  return new ScopedCallback( function () use ( $previousValue ) {
213  $this->deferredUpdatesAddCallableUpdateCallback = $previousValue;
214  } );
215  }
216 
217  private function getCacheKey( UserIdentity $user, LinkTarget $target ) {
218  return $this->cache->makeKey(
219  (string)$target->getNamespace(),
220  $target->getDBkey(),
221  (string)$user->getId()
222  );
223  }
224 
225  private function cache( WatchedItem $item ) {
226  $user = $item->getUserIdentity();
227  $target = $item->getLinkTarget();
228  $key = $this->getCacheKey( $user, $target );
229  $this->cache->set( $key, $item );
230  $this->cacheIndex[$target->getNamespace()][$target->getDBkey()][$user->getId()] = $key;
231  $this->stats->increment( 'WatchedItemStore.cache' );
232  }
233 
234  private function uncache( UserIdentity $user, LinkTarget $target ) {
235  $this->cache->delete( $this->getCacheKey( $user, $target ) );
236  unset( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()][$user->getId()] );
237  $this->stats->increment( 'WatchedItemStore.uncache' );
238  }
239 
240  private function uncacheLinkTarget( LinkTarget $target ) {
241  $this->stats->increment( 'WatchedItemStore.uncacheLinkTarget' );
242  if ( !isset( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()] ) ) {
243  return;
244  }
245  foreach ( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()] as $key ) {
246  $this->stats->increment( 'WatchedItemStore.uncacheLinkTarget.items' );
247  $this->cache->delete( $key );
248  }
249  }
250 
251  private function uncacheUser( UserIdentity $user ) {
252  $this->stats->increment( 'WatchedItemStore.uncacheUser' );
253  foreach ( $this->cacheIndex as $ns => $dbKeyArray ) {
254  foreach ( $dbKeyArray as $dbKey => $userArray ) {
255  if ( isset( $userArray[$user->getId()] ) ) {
256  $this->stats->increment( 'WatchedItemStore.uncacheUser.items' );
257  $this->cache->delete( $userArray[$user->getId()] );
258  }
259  }
260  }
261 
262  $pageSeenKey = $this->getPageSeenTimestampsKey( $user );
263  $this->latestUpdateCache->delete( $pageSeenKey );
264  $this->stash->delete( $pageSeenKey );
265  }
266 
273  private function getCached( UserIdentity $user, LinkTarget $target ) {
274  return $this->cache->get( $this->getCacheKey( $user, $target ) );
275  }
276 
282  private function getConnectionRef( $dbIndex ) {
283  return $this->loadBalancer->getConnectionRef( $dbIndex, [ 'watchlist' ] );
284  }
285 
296  public function clearUserWatchedItems( UserIdentity $user ) {
297  if ( $this->mustClearWatchedItemsUsingJobQueue( $user ) ) {
298  return false;
299  }
300 
301  $dbw = $this->loadBalancer->getConnectionRef( DB_MASTER );
302 
303  if ( $this->expiryEnabled ) {
304  $ticket = $this->lbFactory->getEmptyTransactionTicket( __METHOD__ );
305  // First fetch the wl_ids.
306  $wlIds = $dbw->selectFieldValues( 'watchlist', 'wl_id', [
307  'wl_user' => $user->getId()
308  ], __METHOD__ );
309 
310  if ( $wlIds ) {
311  // Delete rows from both the watchlist and watchlist_expiry tables.
312  $dbw->delete(
313  'watchlist',
314  [ 'wl_id' => $wlIds ],
315  __METHOD__
316  );
317 
318  $dbw->delete(
319  'watchlist_expiry',
320  [ 'we_item' => $wlIds ],
321  __METHOD__
322  );
323  }
324  $this->lbFactory->commitAndWaitForReplication( __METHOD__, $ticket );
325  } else {
326  $dbw->delete(
327  'watchlist',
328  [ 'wl_user' => $user->getId() ],
329  __METHOD__
330  );
331  }
332 
333  $this->uncacheAllItemsForUser( $user );
334 
335  return true;
336  }
337 
338  public function mustClearWatchedItemsUsingJobQueue( UserIdentity $user ): bool {
339  return $this->countWatchedItems( $user ) > $this->updateRowsPerQuery;
340  }
341 
342  private function uncacheAllItemsForUser( UserIdentity $user ) {
343  $userId = $user->getId();
344  foreach ( $this->cacheIndex as $ns => $dbKeyIndex ) {
345  foreach ( $dbKeyIndex as $dbKey => $userIndex ) {
346  if ( array_key_exists( $userId, $userIndex ) ) {
347  $this->cache->delete( $userIndex[$userId] );
348  unset( $this->cacheIndex[$ns][$dbKey][$userId] );
349  }
350  }
351  }
352 
353  // Cleanup empty cache keys
354  foreach ( $this->cacheIndex as $ns => $dbKeyIndex ) {
355  foreach ( $dbKeyIndex as $dbKey => $userIndex ) {
356  if ( empty( $this->cacheIndex[$ns][$dbKey] ) ) {
357  unset( $this->cacheIndex[$ns][$dbKey] );
358  }
359  }
360  if ( empty( $this->cacheIndex[$ns] ) ) {
361  unset( $this->cacheIndex[$ns] );
362  }
363  }
364  }
365 
374  $job = ClearUserWatchlistJob::newForUser( $user, $this->getMaxId() );
375  $this->queueGroup->push( $job );
376  }
377 
381  public function enqueueWatchlistExpiryJob( float $watchlistPurgeRate ): void {
382  $max = mt_getrandmax();
383  if ( mt_rand( 0, $max ) < $max * $watchlistPurgeRate ) {
384  // The higher the watchlist purge rate, the more likely we are to enqueue a job.
385  $this->queueGroup->push( new WatchlistExpiryJob() );
386  }
387  }
388 
393  public function getMaxId() {
394  $dbr = $this->getConnectionRef( DB_REPLICA );
395  return (int)$dbr->selectField(
396  'watchlist',
397  'MAX(wl_id)',
398  '',
399  __METHOD__
400  );
401  }
402 
408  public function countWatchedItems( UserIdentity $user ) {
409  $dbr = $this->getConnectionRef( DB_REPLICA );
410  $tables = [ 'watchlist' ];
411  $conds = [ 'wl_user' => $user->getId() ];
412  $joinConds = [];
413 
414  if ( $this->expiryEnabled ) {
415  $tables[] = 'watchlist_expiry';
416  $joinConds[ 'watchlist_expiry' ] = [ 'LEFT JOIN', 'wl_id = we_item' ];
417  $conds[] = 'we_expiry IS NULL OR we_expiry > ' . $dbr->addQuotes( $dbr->timestamp() );
418  }
419 
420  $return = (int)$dbr->selectField(
421  $tables,
422  'COUNT(*)',
423  $conds,
424  __METHOD__,
425  [],
426  $joinConds
427  );
428 
429  return $return;
430  }
431 
437  public function countWatchers( LinkTarget $target ) {
438  $dbr = $this->getConnectionRef( DB_REPLICA );
439  $tables = [ 'watchlist' ];
440  $conds = [
441  'wl_namespace' => $target->getNamespace(),
442  'wl_title' => $target->getDBkey()
443  ];
444  $joinConds = [];
445 
446  if ( $this->expiryEnabled ) {
447  $tables[] = 'watchlist_expiry';
448  $joinConds[ 'watchlist_expiry' ] = [ 'LEFT JOIN', 'wl_id = we_item' ];
449  $conds[] = 'we_expiry IS NULL OR we_expiry > ' . $dbr->addQuotes( $dbr->timestamp() );
450  }
451 
452  $return = (int)$dbr->selectField(
453  $tables,
454  'COUNT(*)',
455  $conds,
456  __METHOD__,
457  [],
458  $joinConds
459  );
460 
461  return $return;
462  }
463 
470  public function countVisitingWatchers( LinkTarget $target, $threshold ) {
471  $dbr = $this->getConnectionRef( DB_REPLICA );
472  $tables = [ 'watchlist' ];
473  $conds = [
474  'wl_namespace' => $target->getNamespace(),
475  'wl_title' => $target->getDBkey(),
476  'wl_notificationtimestamp >= ' .
477  $dbr->addQuotes( $dbr->timestamp( $threshold ) ) .
478  ' OR wl_notificationtimestamp IS NULL'
479  ];
480  $joinConds = [];
481 
482  if ( $this->expiryEnabled ) {
483  $tables[] = 'watchlist_expiry';
484  $joinConds[ 'watchlist_expiry' ] = [ 'LEFT JOIN', 'wl_id = we_item' ];
485  $conds[] = 'we_expiry IS NULL OR we_expiry > ' . $dbr->addQuotes( $dbr->timestamp() );
486  }
487 
488  $visitingWatchers = (int)$dbr->selectField(
489  $tables,
490  'COUNT(*)',
491  $conds,
492  __METHOD__,
493  [],
494  $joinConds
495  );
496 
497  return $visitingWatchers;
498  }
499 
505  public function removeWatchBatchForUser( UserIdentity $user, array $titles ) {
506  if ( $this->readOnlyMode->isReadOnly() ) {
507  return false;
508  }
509  if ( !$user->isRegistered() ) {
510  return false;
511  }
512  if ( !$titles ) {
513  return true;
514  }
515 
516  $rows = $this->getTitleDbKeysGroupedByNamespace( $titles );
517  $this->uncacheTitlesForUser( $user, $titles );
518 
519  $dbw = $this->getConnectionRef( DB_MASTER );
520  $ticket = count( $titles ) > $this->updateRowsPerQuery ?
521  $this->lbFactory->getEmptyTransactionTicket( __METHOD__ ) : null;
522  $affectedRows = 0;
523 
524  // Batch delete items per namespace.
525  foreach ( $rows as $namespace => $namespaceTitles ) {
526  $rowBatches = array_chunk( $namespaceTitles, $this->updateRowsPerQuery );
527  foreach ( $rowBatches as $toDelete ) {
528  // First fetch the wl_ids.
529  $wlIds = $dbw->selectFieldValues( 'watchlist', 'wl_id', [
530  'wl_user' => $user->getId(),
531  'wl_namespace' => $namespace,
532  'wl_title' => $toDelete
533  ], __METHOD__ );
534 
535  if ( $wlIds ) {
536  // Delete rows from both the watchlist and watchlist_expiry tables.
537  $dbw->delete(
538  'watchlist',
539  [ 'wl_id' => $wlIds ],
540  __METHOD__
541  );
542  $affectedRows += $dbw->affectedRows();
543 
544  if ( $this->expiryEnabled ) {
545  $dbw->delete(
546  'watchlist_expiry',
547  [ 'we_item' => $wlIds ],
548  __METHOD__
549  );
550  $affectedRows += $dbw->affectedRows();
551  }
552  }
553 
554  if ( $ticket ) {
555  $this->lbFactory->commitAndWaitForReplication( __METHOD__, $ticket );
556  }
557  }
558  }
559 
560  return (bool)$affectedRows;
561  }
562 
569  public function countWatchersMultiple( array $targets, array $options = [] ) {
570  $dbOptions = [ 'GROUP BY' => [ 'wl_namespace', 'wl_title' ] ];
571 
572  $dbr = $this->getConnectionRef( DB_REPLICA );
573 
574  if ( array_key_exists( 'minimumWatchers', $options ) ) {
575  $dbOptions['HAVING'] = 'COUNT(*) >= ' . (int)$options['minimumWatchers'];
576  }
577 
578  $lb = $this->linkBatchFactory->newLinkBatch( $targets );
579 
580  $tables = [ 'watchlist' ];
581  $conds = [ $lb->constructSet( 'wl', $dbr ) ];
582  $joinConds = [];
583 
584  if ( $this->expiryEnabled ) {
585  $tables[] = 'watchlist_expiry';
586  $joinConds[ 'watchlist_expiry' ] = [ 'LEFT JOIN', 'wl_id = we_item' ];
587  $conds[] = 'we_expiry IS NULL OR we_expiry > ' . $dbr->addQuotes( $dbr->timestamp() );
588  }
589 
590  $res = $dbr->select(
591  $tables,
592  [ 'wl_title', 'wl_namespace', 'watchers' => 'COUNT(*)' ],
593  $conds,
594  __METHOD__,
595  $dbOptions,
596  $joinConds
597  );
598 
599  $watchCounts = [];
600  foreach ( $targets as $linkTarget ) {
601  $watchCounts[$linkTarget->getNamespace()][$linkTarget->getDBkey()] = 0;
602  }
603 
604  foreach ( $res as $row ) {
605  $watchCounts[$row->wl_namespace][$row->wl_title] = (int)$row->watchers;
606  }
607 
608  return $watchCounts;
609  }
610 
618  array $targetsWithVisitThresholds,
619  $minimumWatchers = null
620  ) {
621  if ( $targetsWithVisitThresholds === [] ) {
622  // No titles requested => no results returned
623  return [];
624  }
625 
626  $dbr = $this->getConnectionRef( DB_REPLICA );
627 
628  $conds = [ $this->getVisitingWatchersCondition( $dbr, $targetsWithVisitThresholds ) ];
629 
630  $dbOptions = [ 'GROUP BY' => [ 'wl_namespace', 'wl_title' ] ];
631  if ( $minimumWatchers !== null ) {
632  $dbOptions['HAVING'] = 'COUNT(*) >= ' . (int)$minimumWatchers;
633  }
634 
635  $tables = [ 'watchlist' ];
636  $joinConds = [];
637 
638  if ( $this->expiryEnabled ) {
639  $tables[] = 'watchlist_expiry';
640  $joinConds[ 'watchlist_expiry' ] = [ 'LEFT JOIN', 'wl_id = we_item' ];
641  $conds[] = 'we_expiry IS NULL OR we_expiry > ' . $dbr->addQuotes( $dbr->timestamp() );
642  }
643 
644  $res = $dbr->select(
645  $tables,
646  [ 'wl_namespace', 'wl_title', 'watchers' => 'COUNT(*)' ],
647  $conds,
648  __METHOD__,
649  $dbOptions,
650  $joinConds
651  );
652 
653  $watcherCounts = [];
654  foreach ( $targetsWithVisitThresholds as list( $target ) ) {
656  $watcherCounts[$target->getNamespace()][$target->getDBkey()] = 0;
657  }
658 
659  foreach ( $res as $row ) {
660  $watcherCounts[$row->wl_namespace][$row->wl_title] = (int)$row->watchers;
661  }
662 
663  return $watcherCounts;
664  }
665 
674  IDatabase $db,
675  array $targetsWithVisitThresholds
676  ) {
677  $missingTargets = [];
678  $namespaceConds = [];
679  foreach ( $targetsWithVisitThresholds as list( $target, $threshold ) ) {
680  if ( $threshold === null ) {
681  $missingTargets[] = $target;
682  continue;
683  }
685  $namespaceConds[$target->getNamespace()][] = $db->makeList( [
686  'wl_title = ' . $db->addQuotes( $target->getDBkey() ),
687  $db->makeList( [
688  'wl_notificationtimestamp >= ' . $db->addQuotes( $db->timestamp( $threshold ) ),
689  'wl_notificationtimestamp IS NULL'
690  ], LIST_OR )
691  ], LIST_AND );
692  }
693 
694  $conds = [];
695  foreach ( $namespaceConds as $namespace => $pageConds ) {
696  $conds[] = $db->makeList( [
697  'wl_namespace = ' . $namespace,
698  '(' . $db->makeList( $pageConds, LIST_OR ) . ')'
699  ], LIST_AND );
700  }
701 
702  if ( $missingTargets ) {
703  $lb = $this->linkBatchFactory->newLinkBatch( $missingTargets );
704  $conds[] = $lb->constructSet( 'wl', $db );
705  }
706 
707  return $db->makeList( $conds, LIST_OR );
708  }
709 
716  public function getWatchedItem( UserIdentity $user, LinkTarget $target ) {
717  if ( !$user->isRegistered() ) {
718  return false;
719  }
720 
721  $cached = $this->getCached( $user, $target );
722  if ( $cached && !$cached->isExpired() ) {
723  $this->stats->increment( 'WatchedItemStore.getWatchedItem.cached' );
724  return $cached;
725  }
726  $this->stats->increment( 'WatchedItemStore.getWatchedItem.load' );
727  return $this->loadWatchedItem( $user, $target );
728  }
729 
736  public function loadWatchedItem( UserIdentity $user, LinkTarget $target ) {
737  $item = $this->loadWatchedItemsBatch( $user, [ $target ] );
738  return $item ? $item[0] : false;
739  }
740 
747  public function loadWatchedItemsBatch( UserIdentity $user, array $targets ) {
748  // Only registered user can have a watchlist
749  if ( !$user->isRegistered() ) {
750  return false;
751  }
752 
753  $dbr = $this->getConnectionRef( DB_REPLICA );
754 
755  $rows = $this->fetchWatchedItems(
756  $dbr,
757  $user,
758  [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
759  [],
760  $targets
761  );
762 
763  if ( !$rows ) {
764  return false;
765  }
766 
767  $items = [];
768  foreach ( $rows as $row ) {
769  $target = new TitleValue( (int)$row->wl_namespace, $row->wl_title );
770  $item = $this->getWatchedItemFromRow( $user, $target, $row );
771  $this->cache( $item );
772  $items[] = $item;
773  }
774 
775  return $items;
776  }
777 
784  public function getWatchedItemsForUser( UserIdentity $user, array $options = [] ) {
785  $options += [ 'forWrite' => false ];
786  $vars = [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ];
787  $dbOptions = [];
788  $db = $this->getConnectionRef( $options['forWrite'] ? DB_MASTER : DB_REPLICA );
789  if ( array_key_exists( 'sort', $options ) ) {
790  Assert::parameter(
791  ( in_array( $options['sort'], [ self::SORT_ASC, self::SORT_DESC ] ) ),
792  '$options[\'sort\']',
793  'must be SORT_ASC or SORT_DESC'
794  );
795  $dbOptions['ORDER BY'][] = "wl_namespace {$options['sort']}";
796  if ( $this->expiryEnabled
797  && array_key_exists( 'sortByExpiry', $options )
798  && $options['sortByExpiry']
799  ) {
800  // Add `wl_has_expiry` column to allow sorting by watched titles that have an expiration date first.
801  $vars['wl_has_expiry'] = $db->conditional( 'we_expiry IS NULL', 0, 1 );
802  // Display temporarily watched titles first.
803  // Order by expiration date, with the titles that will expire soonest at the top.
804  $dbOptions['ORDER BY'][] = "wl_has_expiry DESC";
805  $dbOptions['ORDER BY'][] = "we_expiry ASC";
806  }
807 
808  $dbOptions['ORDER BY'][] = "wl_title {$options['sort']}";
809  }
810 
811  $res = $this->fetchWatchedItems(
812  $db,
813  $user,
814  $vars,
815  $dbOptions
816  );
817 
818  $watchedItems = [];
819  foreach ( $res as $row ) {
820  $target = new TitleValue( (int)$row->wl_namespace, $row->wl_title );
821  // @todo: Should we add these to the process cache?
822  $watchedItems[] = $this->getWatchedItemFromRow( $user, $target, $row );
823  }
824 
825  return $watchedItems;
826  }
827 
835  private function getWatchedItemFromRow(
836  UserIdentity $user,
837  LinkTarget $target,
838  stdClass $row
839  ): WatchedItem {
840  return new WatchedItem(
841  $user,
842  $target,
844  $row->wl_notificationtimestamp, $user, $target ),
845  wfTimestampOrNull( TS_ISO_8601, $row->we_expiry ?? null )
846  );
847  }
848 
861  private function fetchWatchedItems(
862  IDatabase $db,
863  UserIdentity $user,
864  array $vars,
865  array $options = [],
866  $target = null
867  ) {
868  $dbMethod = 'select';
869  $conds = [ 'wl_user' => $user->getId() ];
870 
871  if ( $target ) {
872  if ( $target instanceof LinkTarget ) {
873  $dbMethod = 'selectRow';
874  $conds = array_merge( $conds, [
875  'wl_namespace' => $target->getNamespace(),
876  'wl_title' => $target->getDBkey(),
877  ] );
878  } else {
879  $titleConds = [];
880  foreach ( $target as $linkTarget ) {
881  $titleConds[] = $db->makeList(
882  [
883  'wl_namespace' => $linkTarget->getNamespace(),
884  'wl_title' => $linkTarget->getDBkey(),
885  ],
887  );
888  }
889  $conds[] = $db->makeList( $titleConds, $db::LIST_OR );
890  }
891  }
892 
893  if ( $this->expiryEnabled ) {
894  $vars[] = 'we_expiry';
895  $conds[] = 'we_expiry IS NULL OR we_expiry > ' . $db->addQuotes( $db->timestamp() );
896 
897  return $db->{$dbMethod}(
898  [ 'watchlist', 'watchlist_expiry' ],
899  $vars,
900  $conds,
901  __METHOD__,
902  $options,
903  [ 'watchlist_expiry' => [ 'LEFT JOIN', [ 'wl_id = we_item' ] ] ]
904  );
905  }
906 
907  return $db->{$dbMethod}(
908  'watchlist',
909  $vars,
910  $conds,
911  __METHOD__,
912  $options
913  );
914  }
915 
922  public function isWatched( UserIdentity $user, LinkTarget $target ) {
923  return (bool)$this->getWatchedItem( $user, $target );
924  }
925 
933  public function isTempWatched( UserIdentity $user, LinkTarget $target ): bool {
934  $item = $this->getWatchedItem( $user, $target );
935  return $item && $item->getExpiry();
936  }
937 
944  public function getNotificationTimestampsBatch( UserIdentity $user, array $targets ) {
945  $timestamps = [];
946  foreach ( $targets as $target ) {
947  $timestamps[$target->getNamespace()][$target->getDBkey()] = false;
948  }
949 
950  if ( !$user->isRegistered() ) {
951  return $timestamps;
952  }
953 
954  $targetsToLoad = [];
955  foreach ( $targets as $target ) {
956  $cachedItem = $this->getCached( $user, $target );
957  if ( $cachedItem ) {
958  $timestamps[$target->getNamespace()][$target->getDBkey()] =
959  $cachedItem->getNotificationTimestamp();
960  } else {
961  $targetsToLoad[] = $target;
962  }
963  }
964 
965  if ( !$targetsToLoad ) {
966  return $timestamps;
967  }
968 
969  $dbr = $this->getConnectionRef( DB_REPLICA );
970 
971  $lb = $this->linkBatchFactory->newLinkBatch( $targetsToLoad );
972  $res = $dbr->select(
973  'watchlist',
974  [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
975  [
976  $lb->constructSet( 'wl', $dbr ),
977  'wl_user' => $user->getId(),
978  ],
979  __METHOD__
980  );
981 
982  foreach ( $res as $row ) {
983  $target = new TitleValue( (int)$row->wl_namespace, $row->wl_title );
984  $timestamps[$row->wl_namespace][$row->wl_title] =
985  $this->getLatestNotificationTimestamp(
986  $row->wl_notificationtimestamp, $user, $target );
987  }
988 
989  return $timestamps;
990  }
991 
1000  public function addWatch( UserIdentity $user, LinkTarget $target, ?string $expiry = null ) {
1001  $this->addWatchBatchForUser( $user, [ $target ], $expiry );
1002 
1003  if ( $this->expiryEnabled && !$expiry ) {
1004  // When re-watching a page with a null $expiry, any existing expiry is left unchanged.
1005  // However we must re-fetch the preexisting expiry or else the cached WatchedItem will
1006  // incorrectly have a null expiry. Note that loadWatchedItem() does the caching.
1007  // See T259379
1008  $this->loadWatchedItem( $user, $target );
1009  } else {
1010  // Create a new WatchedItem and add it to the process cache.
1011  // In this case we don't need to re-fetch the expiry.
1012  $expiry = ExpiryDef::normalizeUsingMaxExpiry( $expiry, $this->maxExpiryDuration, TS_ISO_8601 );
1013  $item = new WatchedItem(
1014  $user,
1015  $target,
1016  null,
1017  $expiry
1018  );
1019  $this->cache( $item );
1020  }
1021  }
1022 
1036  public function addWatchBatchForUser(
1037  UserIdentity $user,
1038  array $targets,
1039  ?string $expiry = null
1040  ) {
1041  if ( $this->readOnlyMode->isReadOnly() ) {
1042  return false;
1043  }
1044  // Only registered user can have a watchlist
1045  if ( !$user->isRegistered() ) {
1046  return false;
1047  }
1048 
1049  if ( !$targets ) {
1050  return true;
1051  }
1052  $expiry = ExpiryDef::normalizeUsingMaxExpiry( $expiry, $this->maxExpiryDuration, TS_ISO_8601 );
1053  $rows = [];
1054  foreach ( $targets as $target ) {
1055  $rows[] = [
1056  'wl_user' => $user->getId(),
1057  'wl_namespace' => $target->getNamespace(),
1058  'wl_title' => $target->getDBkey(),
1059  'wl_notificationtimestamp' => null,
1060  ];
1061  $this->uncache( $user, $target );
1062  }
1063 
1064  $dbw = $this->getConnectionRef( DB_MASTER );
1065  $ticket = count( $targets ) > $this->updateRowsPerQuery ?
1066  $this->lbFactory->getEmptyTransactionTicket( __METHOD__ ) : null;
1067  $affectedRows = 0;
1068  $rowBatches = array_chunk( $rows, $this->updateRowsPerQuery );
1069  foreach ( $rowBatches as $toInsert ) {
1070  // Use INSERT IGNORE to avoid overwriting the notification timestamp
1071  // if there's already an entry for this page
1072  $dbw->insert( 'watchlist', $toInsert, __METHOD__, [ 'IGNORE' ] );
1073  $affectedRows += $dbw->affectedRows();
1074 
1075  if ( $this->expiryEnabled ) {
1076  $affectedRows += $this->updateOrDeleteExpiries( $dbw, $user->getId(), $toInsert, $expiry );
1077  }
1078 
1079  if ( $ticket ) {
1080  $this->lbFactory->commitAndWaitForReplication( __METHOD__, $ticket );
1081  }
1082  }
1083 
1084  return (bool)$affectedRows;
1085  }
1086 
1096  private function updateOrDeleteExpiries(
1097  IDatabase $dbw,
1098  int $userId,
1099  array $rows,
1100  ?string $expiry = null
1101  ): int {
1102  if ( !$expiry ) {
1103  // if expiry is null (shouldn't change), 0 rows affected.
1104  return 0;
1105  }
1106 
1107  // Build the giant `(...) OR (...)` part to be used with WHERE.
1108  $conds = [];
1109  foreach ( $rows as $row ) {
1110  $conds[] = $dbw->makeList(
1111  [
1112  'wl_user' => $userId,
1113  'wl_namespace' => $row['wl_namespace'],
1114  'wl_title' => $row['wl_title']
1115  ],
1117  );
1118  }
1119  $cond = $dbw->makeList( $conds, $dbw::LIST_OR );
1120 
1121  if ( wfIsInfinity( $expiry ) ) {
1122  // Rows should be deleted rather than updated.
1123  $dbw->deleteJoin(
1124  'watchlist_expiry',
1125  'watchlist',
1126  'we_item',
1127  'wl_id',
1128  [ $cond ],
1129  __METHOD__
1130  );
1131 
1132  return $dbw->affectedRows();
1133  }
1134 
1135  return $this->updateExpiries( $dbw, $expiry, $cond );
1136  }
1137 
1145  private function updateExpiries( IDatabase $dbw, string $expiry, string $cond ): int {
1146  // First fetch the wl_ids from the watchlist table.
1147  // We'd prefer to do a INSERT/SELECT in the same query with IDatabase::insertSelect(),
1148  // but it doesn't allow us to use the "ON DUPLICATE KEY UPDATE" clause.
1149  $wlIds = (array)$dbw->selectFieldValues( 'watchlist', 'wl_id', $cond, __METHOD__ );
1150 
1151  $expiry = $dbw->timestamp( $expiry );
1152 
1153  $weRows = array_map( static function ( $wlId ) use ( $expiry, $dbw ) {
1154  return [
1155  'we_item' => $wlId,
1156  'we_expiry' => $expiry
1157  ];
1158  }, $wlIds );
1159 
1160  // Insert into watchlist_expiry, updating the expiry for duplicate rows.
1161  $dbw->upsert(
1162  'watchlist_expiry',
1163  $weRows,
1164  'we_item',
1165  [ 'we_expiry' => $expiry ],
1166  __METHOD__
1167  );
1168 
1169  return $dbw->affectedRows();
1170  }
1171 
1178  public function removeWatch( UserIdentity $user, LinkTarget $target ) {
1179  return $this->removeWatchBatchForUser( $user, [ $target ] );
1180  }
1181 
1200  UserIdentity $user, $timestamp, array $targets = []
1201  ) {
1202  // Only registered user can have a watchlist
1203  if ( !$user->isRegistered() || $this->readOnlyMode->isReadOnly() ) {
1204  return false;
1205  }
1206 
1207  if ( !$targets ) {
1208  // Backwards compatibility
1209  $this->resetAllNotificationTimestampsForUser( $user, $timestamp );
1210  return true;
1211  }
1212 
1213  $rows = $this->getTitleDbKeysGroupedByNamespace( $targets );
1214 
1215  $dbw = $this->getConnectionRef( DB_MASTER );
1216  if ( $timestamp !== null ) {
1217  $timestamp = $dbw->timestamp( $timestamp );
1218  }
1219  $ticket = $this->lbFactory->getEmptyTransactionTicket( __METHOD__ );
1220  $affectedSinceWait = 0;
1221 
1222  // Batch update items per namespace
1223  foreach ( $rows as $namespace => $namespaceTitles ) {
1224  $rowBatches = array_chunk( $namespaceTitles, $this->updateRowsPerQuery );
1225  foreach ( $rowBatches as $toUpdate ) {
1226  $dbw->update(
1227  'watchlist',
1228  [ 'wl_notificationtimestamp' => $timestamp ],
1229  [
1230  'wl_user' => $user->getId(),
1231  'wl_namespace' => $namespace,
1232  'wl_title' => $toUpdate
1233  ],
1234  __METHOD__
1235  );
1236  $affectedSinceWait += $dbw->affectedRows();
1237  // Wait for replication every time we've touched updateRowsPerQuery rows
1238  if ( $affectedSinceWait >= $this->updateRowsPerQuery ) {
1239  $this->lbFactory->commitAndWaitForReplication( __METHOD__, $ticket );
1240  $affectedSinceWait = 0;
1241  }
1242  }
1243  }
1244 
1245  $this->uncacheUser( $user );
1246 
1247  return true;
1248  }
1249 
1251  $timestamp, UserIdentity $user, LinkTarget $target
1252  ) {
1253  $timestamp = wfTimestampOrNull( TS_MW, $timestamp );
1254  if ( $timestamp === null ) {
1255  return null; // no notification
1256  }
1257 
1258  $seenTimestamps = $this->getPageSeenTimestamps( $user );
1259  if (
1260  $seenTimestamps &&
1261  $seenTimestamps->get( $this->getPageSeenKey( $target ) ) >= $timestamp
1262  ) {
1263  // If a reset job did not yet run, then the "seen" timestamp will be higher
1264  return null;
1265  }
1266 
1267  return $timestamp;
1268  }
1269 
1276  public function resetAllNotificationTimestampsForUser( UserIdentity $user, $timestamp = null ) {
1277  // Only registered user can have a watchlist
1278  if ( !$user->isRegistered() ) {
1279  return;
1280  }
1281 
1282  // If the page is watched by the user (or may be watched), update the timestamp
1284  'userId' => $user->getId(), 'timestamp' => $timestamp, 'casTime' => time()
1285  ] );
1286 
1287  // Try to run this post-send
1288  // Calls DeferredUpdates::addCallableUpdate in normal operation
1289  call_user_func(
1290  $this->deferredUpdatesAddCallableUpdateCallback,
1291  static function () use ( $job ) {
1292  $job->run();
1293  }
1294  );
1295  }
1296 
1305  UserIdentity $editor, LinkTarget $target, $timestamp
1306  ) {
1307  $dbw = $this->getConnectionRef( DB_MASTER );
1308  $selectTables = [ 'watchlist' ];
1309  $selectConds = [
1310  'wl_user != ' . $editor->getId(),
1311  'wl_namespace' => $target->getNamespace(),
1312  'wl_title' => $target->getDBkey(),
1313  'wl_notificationtimestamp IS NULL',
1314  ];
1315  $selectJoin = [];
1316 
1317  if ( $this->expiryEnabled ) {
1318  $selectTables[] = 'watchlist_expiry';
1319  $selectConds[] = 'we_expiry IS NULL OR we_expiry > ' .
1320  $dbw->addQuotes( $dbw->timestamp() );
1321  $selectJoin = [ 'watchlist_expiry' => [ 'LEFT JOIN', 'wl_id = we_item' ] ];
1322  }
1323 
1324  $uids = $dbw->selectFieldValues(
1325  $selectTables,
1326  'wl_user',
1327  $selectConds,
1328  __METHOD__,
1329  [],
1330  $selectJoin
1331  );
1332 
1333  $watchers = array_map( 'intval', $uids );
1334  if ( $watchers ) {
1335  // Update wl_notificationtimestamp for all watching users except the editor
1336  $fname = __METHOD__;
1338  function () use ( $timestamp, $watchers, $target, $fname ) {
1339  $dbw = $this->getConnectionRef( DB_MASTER );
1340  $ticket = $this->lbFactory->getEmptyTransactionTicket( $fname );
1341 
1342  $watchersChunks = array_chunk( $watchers, $this->updateRowsPerQuery );
1343  foreach ( $watchersChunks as $watchersChunk ) {
1344  $dbw->update( 'watchlist',
1345  [ /* SET */
1346  'wl_notificationtimestamp' => $dbw->timestamp( $timestamp )
1347  ], [ /* WHERE - TODO Use wl_id T130067 */
1348  'wl_user' => $watchersChunk,
1349  'wl_namespace' => $target->getNamespace(),
1350  'wl_title' => $target->getDBkey(),
1351  ], $fname
1352  );
1353  if ( count( $watchersChunks ) > 1 ) {
1354  $this->lbFactory->commitAndWaitForReplication(
1355  $fname, $ticket, [ 'domain' => $dbw->getDomainID() ]
1356  );
1357  }
1358  }
1359  $this->uncacheLinkTarget( $target );
1360  },
1361  DeferredUpdates::POSTSEND,
1362  $dbw
1363  );
1364  }
1365 
1366  return $watchers;
1367  }
1368 
1378  UserIdentity $user, LinkTarget $title, $force = '', $oldid = 0
1379  ) {
1380  $time = time();
1381 
1382  // Only registered user can have a watchlist
1383  if ( $this->readOnlyMode->isReadOnly() || !$user->isRegistered() ) {
1384  return false;
1385  }
1386 
1387  // Hook expects User and Title, not UserIdentity and LinkTarget
1388  $userObj = $this->userFactory->newFromId( $user->getId() );
1389  $titleObj = $this->titleFactory->castFromLinkTarget( $title );
1390  if ( !$this->hookRunner->onBeforeResetNotificationTimestamp(
1391  $userObj, $titleObj, $force, $oldid )
1392  ) {
1393  return false;
1394  }
1395  if ( !$userObj->equals( $user ) ) {
1396  $user = $userObj;
1397  }
1398  if ( !$titleObj->equals( $title ) ) {
1399  $title = $titleObj;
1400  }
1401 
1402  $item = null;
1403  if ( $force != 'force' ) {
1404  $item = $this->loadWatchedItem( $user, $title );
1405  if ( !$item || $item->getNotificationTimestamp() === null ) {
1406  return false;
1407  }
1408  }
1409 
1410  // Get the timestamp (TS_MW) of this revision to track the latest one seen
1411  $id = $oldid;
1412  $seenTime = null;
1413  if ( !$id ) {
1414  $latestRev = $this->revisionLookup->getRevisionByTitle( $title );
1415  if ( $latestRev ) {
1416  $id = $latestRev->getId();
1417  // Save a DB query
1418  $seenTime = $latestRev->getTimestamp();
1419  }
1420  }
1421  if ( $seenTime === null ) {
1422  $seenTime = $this->revisionLookup->getTimestampFromId( $id );
1423  }
1424 
1425  // Mark the item as read immediately in lightweight storage
1426  $this->stash->merge(
1427  $this->getPageSeenTimestampsKey( $user ),
1428  function ( $cache, $key, $current ) use ( $title, $seenTime ) {
1429  $value = $current ?: new MapCacheLRU( 300 );
1430  $subKey = $this->getPageSeenKey( $title );
1431 
1432  if ( $seenTime > $value->get( $subKey ) ) {
1433  // Revision is newer than the last one seen
1434  $value->set( $subKey, $seenTime );
1435  $this->latestUpdateCache->set( $key, $value, BagOStuff::TTL_PROC_LONG );
1436  } elseif ( $seenTime === false ) {
1437  // Revision does not exist
1438  $value->set( $subKey, wfTimestamp( TS_MW ) );
1439  $this->latestUpdateCache->set( $key, $value, BagOStuff::TTL_PROC_LONG );
1440  } else {
1441  return false; // nothing to update
1442  }
1443 
1444  return $value;
1445  },
1446  BagOStuff::TTL_HOUR
1447  );
1448 
1449  // If the page is watched by the user (or may be watched), update the timestamp
1450  $job = new ActivityUpdateJob(
1451  $title,
1452  [
1453  'type' => 'updateWatchlistNotification',
1454  'userid' => $user->getId(),
1455  'notifTime' => $this->getNotificationTimestamp( $user, $title, $item, $force, $oldid ),
1456  'curTime' => $time
1457  ]
1458  );
1459  // Try to enqueue this post-send
1460  $this->queueGroup->lazyPush( $job );
1461 
1462  $this->uncache( $user, $title );
1463 
1464  return true;
1465  }
1466 
1471  private function getPageSeenTimestamps( UserIdentity $user ) {
1472  $key = $this->getPageSeenTimestampsKey( $user );
1473 
1474  return $this->latestUpdateCache->getWithSetCallback(
1475  $key,
1476  BagOStuff::TTL_PROC_LONG,
1477  function () use ( $key ) {
1478  return $this->stash->get( $key ) ?: null;
1479  }
1480  );
1481  }
1482 
1487  private function getPageSeenTimestampsKey( UserIdentity $user ) {
1488  return $this->stash->makeGlobalKey(
1489  'watchlist-recent-updates',
1490  $this->lbFactory->getLocalDomainID(),
1491  $user->getId()
1492  );
1493  }
1494 
1499  private function getPageSeenKey( LinkTarget $target ) {
1500  return "{$target->getNamespace()}:{$target->getDBkey()}";
1501  }
1502 
1511  private function getNotificationTimestamp(
1512  UserIdentity $user, LinkTarget $title, $item, $force, $oldid
1513  ) {
1514  if ( !$oldid ) {
1515  // No oldid given, assuming latest revision; clear the timestamp.
1516  return null;
1517  }
1518 
1519  $oldRev = $this->revisionLookup->getRevisionById( $oldid );
1520  if ( !$oldRev ) {
1521  // Oldid given but does not exist (probably deleted)
1522  return false;
1523  }
1524 
1525  $nextRev = $this->revisionLookup->getNextRevision( $oldRev );
1526  if ( !$nextRev ) {
1527  // Oldid given and is the latest revision for this title; clear the timestamp.
1528  return null;
1529  }
1530 
1531  if ( $item === null ) {
1532  $item = $this->loadWatchedItem( $user, $title );
1533  }
1534 
1535  if ( !$item ) {
1536  // This can only happen if $force is enabled.
1537  return null;
1538  }
1539 
1540  // Oldid given and isn't the latest; update the timestamp.
1541  // This will result in no further notification emails being sent!
1542  $notificationTimestamp = $this->revisionLookup->getTimestampFromId( $oldid );
1543  // @FIXME: this should use getTimestamp() for consistency with updates on new edits
1544  // $notificationTimestamp = $nextRev->getTimestamp(); // first unseen revision timestamp
1545 
1546  // We need to go one second to the future because of various strict comparisons
1547  // throughout the codebase
1548  $ts = new MWTimestamp( $notificationTimestamp );
1549  $ts->timestamp->add( new DateInterval( 'PT1S' ) );
1550  $notificationTimestamp = $ts->getTimestamp( TS_MW );
1551 
1552  if ( $notificationTimestamp < $item->getNotificationTimestamp() ) {
1553  if ( $force != 'force' ) {
1554  return false;
1555  } else {
1556  // This is a little silly‚Ķ
1557  return $item->getNotificationTimestamp();
1558  }
1559  }
1560 
1561  return $notificationTimestamp;
1562  }
1563 
1570  public function countUnreadNotifications( UserIdentity $user, $unreadLimit = null ) {
1571  $dbr = $this->getConnectionRef( DB_REPLICA );
1572 
1573  $queryOptions = [];
1574  if ( $unreadLimit !== null ) {
1575  $unreadLimit = (int)$unreadLimit;
1576  $queryOptions['LIMIT'] = $unreadLimit;
1577  }
1578 
1579  $conds = [
1580  'wl_user' => $user->getId(),
1581  'wl_notificationtimestamp IS NOT NULL'
1582  ];
1583 
1584  $rowCount = $dbr->selectRowCount( 'watchlist', '1', $conds, __METHOD__, $queryOptions );
1585 
1586  if ( $unreadLimit === null ) {
1587  return $rowCount;
1588  }
1589 
1590  if ( $rowCount >= $unreadLimit ) {
1591  return true;
1592  }
1593 
1594  return $rowCount;
1595  }
1596 
1602  public function duplicateAllAssociatedEntries( LinkTarget $oldTarget, LinkTarget $newTarget ) {
1603  // Duplicate first the subject page, then the talk page
1604  $this->duplicateEntry(
1605  $this->nsInfo->getSubjectPage( $oldTarget ),
1606  $this->nsInfo->getSubjectPage( $newTarget )
1607  );
1608  $this->duplicateEntry(
1609  $this->nsInfo->getTalkPage( $oldTarget ),
1610  $this->nsInfo->getTalkPage( $newTarget )
1611  );
1612  }
1613 
1619  public function duplicateEntry( LinkTarget $oldTarget, LinkTarget $newTarget ) {
1620  $dbw = $this->getConnectionRef( DB_MASTER );
1621  $result = $this->fetchWatchedItemsForPage( $dbw, $oldTarget );
1622  $newNamespace = $newTarget->getNamespace();
1623  $newDBkey = $newTarget->getDBkey();
1624 
1625  # Construct array to replace into the watchlist
1626  $values = [];
1627  $expiries = [];
1628  foreach ( $result as $row ) {
1629  $values[] = [
1630  'wl_user' => $row->wl_user,
1631  'wl_namespace' => $newNamespace,
1632  'wl_title' => $newDBkey,
1633  'wl_notificationtimestamp' => $row->wl_notificationtimestamp,
1634  ];
1635 
1636  if ( $this->expiryEnabled && $row->we_expiry ) {
1637  $expiries[$row->wl_user] = $row->we_expiry;
1638  }
1639  }
1640 
1641  if ( empty( $values ) ) {
1642  return;
1643  }
1644 
1645  // Perform a replace on the watchlist table rows.
1646  // Note that multi-row replace is very efficient for MySQL but may be inefficient for
1647  // some other DBMSes, mostly due to poor simulation by us.
1648  $dbw->replace(
1649  'watchlist',
1650  [ [ 'wl_user', 'wl_namespace', 'wl_title' ] ],
1651  $values,
1652  __METHOD__
1653  );
1654 
1655  if ( $this->expiryEnabled ) {
1656  $this->updateExpiriesAfterMove( $dbw, $expiries, $newNamespace, $newDBkey );
1657  }
1658  }
1659 
1665  private function fetchWatchedItemsForPage(
1666  IDatabase $dbw,
1667  LinkTarget $target
1668  ) : IResultWrapper {
1669  $tables = [ 'watchlist' ];
1670  $fields = [ 'wl_user', 'wl_notificationtimestamp' ];
1671  $joins = [];
1672 
1673  if ( $this->expiryEnabled ) {
1674  $tables[] = 'watchlist_expiry';
1675  $fields[] = 'we_expiry';
1676  $joins['watchlist_expiry'] = [ 'LEFT JOIN', [ 'wl_id = we_item' ] ];
1677  }
1678 
1679  return $dbw->select(
1680  $tables,
1681  $fields,
1682  [
1683  'wl_namespace' => $target->getNamespace(),
1684  'wl_title' => $target->getDBkey(),
1685  ],
1686  __METHOD__,
1687  [ 'FOR UPDATE' ],
1688  $joins
1689  );
1690  }
1691 
1698  private function updateExpiriesAfterMove(
1699  IDatabase $dbw,
1700  array $expiries,
1701  int $namespace,
1702  string $dbKey
1703  ): void {
1704  $method = __METHOD__;
1706  function () use ( $dbw, $expiries, $namespace, $dbKey, $method ) {
1707  // First fetch new wl_ids.
1708  $res = $dbw->select(
1709  'watchlist',
1710  [ 'wl_user', 'wl_id' ],
1711  [
1712  'wl_namespace' => $namespace,
1713  'wl_title' => $dbKey,
1714  ],
1715  $method
1716  );
1717 
1718  // Build new array to INSERT into multiple rows at once.
1719  $expiryData = [];
1720  foreach ( $res as $row ) {
1721  if ( !empty( $expiries[$row->wl_user] ) ) {
1722  $expiryData[] = [
1723  'we_item' => $row->wl_id,
1724  'we_expiry' => $expiries[$row->wl_user],
1725  ];
1726  }
1727  }
1728 
1729  // Batch the insertions.
1730  $batches = array_chunk( $expiryData, $this->updateRowsPerQuery );
1731  foreach ( $batches as $toInsert ) {
1732  $dbw->replace(
1733  'watchlist_expiry',
1734  'we_item',
1735  $toInsert,
1736  $method
1737  );
1738  }
1739  },
1740  DeferredUpdates::POSTSEND,
1741  $dbw
1742  );
1743  }
1744 
1749  private function getTitleDbKeysGroupedByNamespace( array $titles ) {
1750  $rows = [];
1751  foreach ( $titles as $title ) {
1752  // Group titles by namespace.
1753  $rows[ $title->getNamespace() ][] = $title->getDBkey();
1754  }
1755  return $rows;
1756  }
1757 
1762  private function uncacheTitlesForUser( UserIdentity $user, array $titles ) {
1763  foreach ( $titles as $title ) {
1764  $this->uncache( $user, $title );
1765  }
1766  }
1767 
1771  public function countExpired(): int {
1772  $dbr = $this->getConnectionRef( DB_REPLICA );
1773  return $dbr->selectRowCount(
1774  'watchlist_expiry',
1775  '*',
1776  [ 'we_expiry <= ' . $dbr->addQuotes( $dbr->timestamp() ) ],
1777  __METHOD__
1778  );
1779  }
1780 
1784  public function removeExpired( int $limit, bool $deleteOrphans = false ): void {
1785  $dbr = $this->getConnectionRef( DB_REPLICA );
1786  $dbw = $this->getConnectionRef( DB_MASTER );
1787  $ticket = $this->lbFactory->getEmptyTransactionTicket( __METHOD__ );
1788 
1789  // Get a batch of watchlist IDs to delete.
1790  $toDelete = $dbr->selectFieldValues(
1791  'watchlist_expiry',
1792  'we_item',
1793  [ 'we_expiry <= ' . $dbr->addQuotes( $dbr->timestamp() ) ],
1794  __METHOD__,
1795  [ 'LIMIT' => $limit ]
1796  );
1797  if ( count( $toDelete ) > 0 ) {
1798  // Delete them from the watchlist and watchlist_expiry table.
1799  $dbw->delete(
1800  'watchlist',
1801  [ 'wl_id' => $toDelete ],
1802  __METHOD__
1803  );
1804  $dbw->delete(
1805  'watchlist_expiry',
1806  [ 'we_item' => $toDelete ],
1807  __METHOD__
1808  );
1809  }
1810 
1811  // Also delete any orphaned or null-expiry watchlist_expiry rows
1812  // (they should not exist, but might because not everywhere knows about the expiry table yet).
1813  if ( $deleteOrphans ) {
1814  $expiryToDelete = $dbr->selectFieldValues(
1815  [ 'watchlist_expiry', 'watchlist' ],
1816  'we_item',
1817  $dbr->makeList(
1818  [ 'wl_id' => null, 'we_expiry' => null ],
1820  ),
1821  __METHOD__,
1822  [],
1823  [ 'watchlist' => [ 'LEFT JOIN', 'wl_id = we_item' ] ]
1824  );
1825  if ( count( $expiryToDelete ) > 0 ) {
1826  $dbw->delete(
1827  'watchlist_expiry',
1828  [ 'we_item' => $expiryToDelete ],
1829  __METHOD__
1830  );
1831  }
1832  }
1833 
1834  $this->lbFactory->commitAndWaitForReplication( __METHOD__, $ticket );
1835  }
1836 }
LIST_OR
const LIST_OR
Definition: Defines.php:46
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:1250
MWTimestamp
Library for creating and parsing MW-style timestamps.
Definition: MWTimestamp.php:37
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:342
ActivityUpdateJob
Job for updating user activity like "last viewed" timestamps.
Definition: ActivityUpdateJob.php:36
WatchedItemStore\loadWatchedItem
loadWatchedItem(UserIdentity $user, LinkTarget $target)
Definition: WatchedItemStore.php:736
WatchedItemStore\updateNotificationTimestamp
updateNotificationTimestamp(UserIdentity $editor, LinkTarget $target, $timestamp)
Definition: WatchedItemStore.php:1304
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:1471
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:296
WatchedItemStore\getVisitingWatchersCondition
getVisitingWatchersCondition(IDatabase $db, array $targetsWithVisitThresholds)
Generates condition for the query used in a batch count visiting watchers.
Definition: WatchedItemStore.php:673
wfTimestamp
wfTimestamp( $outputtype=TS_UNIX, $ts=0)
Get a timestamp string in one of various formats.
Definition: GlobalFunctions.php:1832
WatchedItemStore\getNotificationTimestampsBatch
getNotificationTimestampsBatch(UserIdentity $user, array $targets)
Definition: WatchedItemStore.php:944
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:96
WatchedItemStore\countVisitingWatchers
countVisitingWatchers(LinkTarget $target, $threshold)
Definition: WatchedItemStore.php:470
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:408
WatchedItemStore\duplicateAllAssociatedEntries
duplicateAllAssociatedEntries(LinkTarget $oldTarget, LinkTarget $newTarget)
Definition: WatchedItemStore.php:1602
BagOStuff
Class representing a cache/ephemeral data store.
Definition: BagOStuff.php:86
WatchedItemStore\addWatch
addWatch(UserIdentity $user, LinkTarget $target, ?string $expiry=null)
Definition: WatchedItemStore.php:1000
MediaWiki\User\UserIdentity\getId
getId( $wikiId=self::LOCAL)
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:381
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:861
$res
$res
Definition: testCompression.php:57
WatchedItemStore\$titleFactory
TitleFactory $titleFactory
Definition: WatchedItemStore.php:127
WatchedItemStore\getNotificationTimestamp
getNotificationTimestamp(UserIdentity $user, LinkTarget $title, $item, $force, $oldid)
Definition: WatchedItemStore.php:1511
WatchedItemStore\countExpired
countExpired()
Get the number of watchlist items that expire before the current time.1.35int
Definition: WatchedItemStore.php:1771
MediaWiki\User\UserIdentity
Interface for objects representing user identity.
Definition: UserIdentity.php:39
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:747
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:204
MWException
MediaWiki exception.
Definition: MWException.php:29
WatchedItemStore\getPageSeenTimestampsKey
getPageSeenTimestampsKey(UserIdentity $user)
Definition: WatchedItemStore.php:1487
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:933
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:1377
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:1848
WatchedItemStore\duplicateEntry
duplicateEntry(LinkTarget $oldTarget, LinkTarget $newTarget)
Definition: WatchedItemStore.php:1619
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:1178
$title
$title
Definition: testCompression.php:38
WatchedItemStore\$maxExpiryDuration
string null $maxExpiryDuration
Maximum configured relative expiry.
Definition: WatchedItemStore.php:132
DB_REPLICA
const DB_REPLICA
Definition: defines.php:25
WatchedItemStore\getMaxId
getMaxId()
Definition: WatchedItemStore.php:393
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:1096
DB_MASTER
const DB_MASTER
Definition: defines.php:26
WatchedItemStore\isWatched
isWatched(UserIdentity $user, LinkTarget $target)
Definition: WatchedItemStore.php:922
WatchedItemStore\uncacheLinkTarget
uncacheLinkTarget(LinkTarget $target)
Definition: WatchedItemStore.php:240
WatchedItem\getUserIdentity
getUserIdentity()
Definition: WatchedItem.php:119
WatchedItemStore\getWatchedItemFromRow
getWatchedItemFromRow(UserIdentity $user, LinkTarget $target, stdClass $row)
Construct a new WatchedItem given a row from watchlist/watchlist_expiry.
Definition: WatchedItemStore.php:835
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:1784
WatchedItemStore\getPageSeenKey
getPageSeenKey(LinkTarget $target)
Definition: WatchedItemStore.php:1499
WatchedItemStore\getConnectionRef
getConnectionRef( $dbIndex)
Definition: WatchedItemStore.php:282
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:1570
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:338
WatchedItemStore\fetchWatchedItemsForPage
fetchWatchedItemsForPage(IDatabase $dbw, LinkTarget $target)
Definition: WatchedItemStore.php:1665
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:1276
wfIsInfinity
wfIsInfinity( $str)
Determine input string is represents as infinity.
Definition: GlobalFunctions.php:2773
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:1199
WatchedItemStore\getWatchedItem
getWatchedItem(UserIdentity $user, LinkTarget $target)
Definition: WatchedItemStore.php:716
WatchedItemStore\$latestUpdateCache
HashBagOStuff $latestUpdateCache
Definition: WatchedItemStore.php:72
WatchedItemStore\uncacheTitlesForUser
uncacheTitlesForUser(UserIdentity $user, array $titles)
Definition: WatchedItemStore.php:1762
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:273
WatchedItemStore\getCacheKey
getCacheKey(UserIdentity $user, LinkTarget $target)
Definition: WatchedItemStore.php:217
WatchedItemStore\uncache
uncache(UserIdentity $user, LinkTarget $target)
Definition: WatchedItemStore.php:234
$cache
$cache
Definition: mcc.php:33
WatchedItemStore\updateExpiriesAfterMove
updateExpiriesAfterMove(IDatabase $dbw, array $expiries, int $namespace, string $dbKey)
Definition: WatchedItemStore.php:1698
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:437
$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:1145
TitleFactory
Creates Title objects.
Definition: TitleFactory.php:34
WatchedItemStore\$userFactory
UserFactory $userFactory
Definition: WatchedItemStore.php:124
WatchedItem\getLinkTarget
getLinkTarget()
Definition: WatchedItem.php:126
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:617
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:189
WatchedItemStore\uncacheUser
uncacheUser(UserIdentity $user)
Definition: WatchedItemStore.php:251
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:148
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:576
WatchedItemStore\getWatchedItemsForUser
getWatchedItemsForUser(UserIdentity $user, array $options=[])
Definition: WatchedItemStore.php:784
WatchedItemStore\addWatchBatchForUser
addWatchBatchForUser(UserIdentity $user, array $targets, ?string $expiry=null)
Add multiple items to the user's watchlist.
Definition: WatchedItemStore.php:1036
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:1749
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:91
WatchedItemStore\countWatchersMultiple
countWatchersMultiple(array $targets, array $options=[])
Definition: WatchedItemStore.php:569
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:505
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:373
WatchedItemStore\cache
cache(WatchedItem $item)
Definition: WatchedItemStore.php:225
TitleValue
Represents a page (or page fragment) title within MediaWiki.
Definition: TitleValue.php:40