MediaWiki  master
WatchedItemStore.php
Go to the documentation of this file.
1 <?php
2 
3 use Liuggio\StatsdClient\Factory\StatsdDataFactoryInterface;
10 use Wikimedia\Assert\Assert;
16 use Wikimedia\ScopedCallback;
17 
27 
31  public const CONSTRUCTOR_OPTIONS = [
32  'UpdateRowsPerQuery',
33  'WatchlistExpiry',
34  'WatchlistExpiryMaxDuration',
35  ];
36 
40  private $lbFactory;
41 
45  private $loadBalancer;
46 
50  private $queueGroup;
51 
55  private $stash;
56 
60  private $readOnlyMode;
61 
65  private $cache;
66 
71 
79  private $cacheIndex = [];
80 
85 
90 
94  private $nsInfo;
95 
99  private $revisionLookup;
100 
104  private $stats;
105 
109  private $expiryEnabled;
110 
114  private $hookRunner;
115 
120 
132  public function __construct(
133  ServiceOptions $options,
141  HookContainer $hookContainer
142  ) {
143  $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
144  $this->updateRowsPerQuery = $options->get( 'UpdateRowsPerQuery' );
145  $this->expiryEnabled = $options->get( 'WatchlistExpiry' );
146  $this->maxExpiryDuration = $options->get( 'WatchlistExpiryMaxDuration' );
147 
148  $this->lbFactory = $lbFactory;
149  $this->loadBalancer = $lbFactory->getMainLB();
150  $this->queueGroup = $queueGroup;
151  $this->stash = $stash;
152  $this->cache = $cache;
153  $this->readOnlyMode = $readOnlyMode;
154  $this->stats = new NullStatsdDataFactory();
155  $this->deferredUpdatesAddCallableUpdateCallback =
156  [ DeferredUpdates::class, 'addCallableUpdate' ];
157  $this->nsInfo = $nsInfo;
158  $this->revisionLookup = $revisionLookup;
159  $this->hookRunner = new HookRunner( $hookContainer );
160 
161  $this->latestUpdateCache = new HashBagOStuff( [ 'maxKeys' => 3 ] );
162  }
163 
167  public function setStatsdDataFactory( StatsdDataFactoryInterface $stats ) {
168  $this->stats = $stats;
169  }
170 
182  public function overrideDeferredUpdatesAddCallableUpdateCallback( callable $callback ) {
183  if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
184  throw new MWException(
185  'Cannot override DeferredUpdates::addCallableUpdate callback in operation.'
186  );
187  }
189  $this->deferredUpdatesAddCallableUpdateCallback = $callback;
190  return new ScopedCallback( function () use ( $previousValue ) {
191  $this->deferredUpdatesAddCallableUpdateCallback = $previousValue;
192  } );
193  }
194 
195  private function getCacheKey( UserIdentity $user, LinkTarget $target ) {
196  return $this->cache->makeKey(
197  (string)$target->getNamespace(),
198  $target->getDBkey(),
199  (string)$user->getId()
200  );
201  }
202 
203  private function cache( WatchedItem $item ) {
204  $user = $item->getUserIdentity();
205  $target = $item->getLinkTarget();
206  $key = $this->getCacheKey( $user, $target );
207  $this->cache->set( $key, $item );
208  $this->cacheIndex[$target->getNamespace()][$target->getDBkey()][$user->getId()] = $key;
209  $this->stats->increment( 'WatchedItemStore.cache' );
210  }
211 
212  private function uncache( UserIdentity $user, LinkTarget $target ) {
213  $this->cache->delete( $this->getCacheKey( $user, $target ) );
214  unset( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()][$user->getId()] );
215  $this->stats->increment( 'WatchedItemStore.uncache' );
216  }
217 
218  private function uncacheLinkTarget( LinkTarget $target ) {
219  $this->stats->increment( 'WatchedItemStore.uncacheLinkTarget' );
220  if ( !isset( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()] ) ) {
221  return;
222  }
223  foreach ( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()] as $key ) {
224  $this->stats->increment( 'WatchedItemStore.uncacheLinkTarget.items' );
225  $this->cache->delete( $key );
226  }
227  }
228 
229  private function uncacheUser( UserIdentity $user ) {
230  $this->stats->increment( 'WatchedItemStore.uncacheUser' );
231  foreach ( $this->cacheIndex as $ns => $dbKeyArray ) {
232  foreach ( $dbKeyArray as $dbKey => $userArray ) {
233  if ( isset( $userArray[$user->getId()] ) ) {
234  $this->stats->increment( 'WatchedItemStore.uncacheUser.items' );
235  $this->cache->delete( $userArray[$user->getId()] );
236  }
237  }
238  }
239 
240  $pageSeenKey = $this->getPageSeenTimestampsKey( $user );
241  $this->latestUpdateCache->delete( $pageSeenKey );
242  $this->stash->delete( $pageSeenKey );
243  }
244 
251  private function getCached( UserIdentity $user, LinkTarget $target ) {
252  return $this->cache->get( $this->getCacheKey( $user, $target ) );
253  }
254 
260  private function getConnectionRef( $dbIndex ) {
261  return $this->loadBalancer->getConnectionRef( $dbIndex, [ 'watchlist' ] );
262  }
263 
274  public function clearUserWatchedItems( UserIdentity $user ) {
275  if ( $this->mustClearWatchedItemsUsingJobQueue( $user ) ) {
276  return false;
277  }
278 
279  $dbw = $this->loadBalancer->getConnectionRef( DB_MASTER );
280 
281  if ( $this->expiryEnabled ) {
282  $ticket = $this->lbFactory->getEmptyTransactionTicket( __METHOD__ );
283  // First fetch the wl_ids.
284  $wlIds = $dbw->selectFieldValues( 'watchlist', 'wl_id', [
285  'wl_user' => $user->getId()
286  ], __METHOD__ );
287 
288  if ( $wlIds ) {
289  // Delete rows from both the watchlist and watchlist_expiry tables.
290  $dbw->delete(
291  'watchlist',
292  [ 'wl_id' => $wlIds ],
293  __METHOD__
294  );
295 
296  $dbw->delete(
297  'watchlist_expiry',
298  [ 'we_item' => $wlIds ],
299  __METHOD__
300  );
301  }
302  $this->lbFactory->commitAndWaitForReplication( __METHOD__, $ticket );
303  } else {
304  $dbw->delete(
305  'watchlist',
306  [ 'wl_user' => $user->getId() ],
307  __METHOD__
308  );
309  }
310 
311  $this->uncacheAllItemsForUser( $user );
312 
313  return true;
314  }
315 
316  public function mustClearWatchedItemsUsingJobQueue( UserIdentity $user ): bool {
317  return $this->countWatchedItems( $user ) > $this->updateRowsPerQuery;
318  }
319 
320  private function uncacheAllItemsForUser( UserIdentity $user ) {
321  $userId = $user->getId();
322  foreach ( $this->cacheIndex as $ns => $dbKeyIndex ) {
323  foreach ( $dbKeyIndex as $dbKey => $userIndex ) {
324  if ( array_key_exists( $userId, $userIndex ) ) {
325  $this->cache->delete( $userIndex[$userId] );
326  unset( $this->cacheIndex[$ns][$dbKey][$userId] );
327  }
328  }
329  }
330 
331  // Cleanup empty cache keys
332  foreach ( $this->cacheIndex as $ns => $dbKeyIndex ) {
333  foreach ( $dbKeyIndex as $dbKey => $userIndex ) {
334  if ( empty( $this->cacheIndex[$ns][$dbKey] ) ) {
335  unset( $this->cacheIndex[$ns][$dbKey] );
336  }
337  }
338  if ( empty( $this->cacheIndex[$ns] ) ) {
339  unset( $this->cacheIndex[$ns] );
340  }
341  }
342  }
343 
352  $job = ClearUserWatchlistJob::newForUser( $user, $this->getMaxId() );
353  $this->queueGroup->push( $job );
354  }
355 
359  public function enqueueWatchlistExpiryJob( float $watchlistPurgeRate ): void {
360  $max = mt_getrandmax();
361  if ( mt_rand( 0, $max ) < $max * $watchlistPurgeRate ) {
362  // The higher the watchlist purge rate, the more likely we are to enqueue a job.
363  $this->queueGroup->push( new WatchlistExpiryJob() );
364  }
365  }
366 
371  public function getMaxId() {
372  $dbr = $this->getConnectionRef( DB_REPLICA );
373  return (int)$dbr->selectField(
374  'watchlist',
375  'MAX(wl_id)',
376  '',
377  __METHOD__
378  );
379  }
380 
386  public function countWatchedItems( UserIdentity $user ) {
387  $dbr = $this->getConnectionRef( DB_REPLICA );
388  $tables = [ 'watchlist' ];
389  $conds = [ 'wl_user' => $user->getId() ];
390  $joinConds = [];
391 
392  if ( $this->expiryEnabled ) {
393  $tables[] = 'watchlist_expiry';
394  $joinConds[ 'watchlist_expiry' ] = [ 'LEFT JOIN', 'wl_id = we_item' ];
395  $conds[] = 'we_expiry IS NULL OR we_expiry > ' . $dbr->addQuotes( $dbr->timestamp() );
396  }
397 
398  $return = (int)$dbr->selectField(
399  $tables,
400  'COUNT(*)',
401  $conds,
402  __METHOD__,
403  [],
404  $joinConds
405  );
406 
407  return $return;
408  }
409 
415  public function countWatchers( LinkTarget $target ) {
416  $dbr = $this->getConnectionRef( DB_REPLICA );
417  $tables = [ 'watchlist' ];
418  $conds = [
419  'wl_namespace' => $target->getNamespace(),
420  'wl_title' => $target->getDBkey()
421  ];
422  $joinConds = [];
423 
424  if ( $this->expiryEnabled ) {
425  $tables[] = 'watchlist_expiry';
426  $joinConds[ 'watchlist_expiry' ] = [ 'LEFT JOIN', 'wl_id = we_item' ];
427  $conds[] = 'we_expiry IS NULL OR we_expiry > ' . $dbr->addQuotes( $dbr->timestamp() );
428  }
429 
430  $return = (int)$dbr->selectField(
431  $tables,
432  'COUNT(*)',
433  $conds,
434  __METHOD__,
435  [],
436  $joinConds
437  );
438 
439  return $return;
440  }
441 
448  public function countVisitingWatchers( LinkTarget $target, $threshold ) {
449  $dbr = $this->getConnectionRef( DB_REPLICA );
450  $tables = [ 'watchlist' ];
451  $conds = [
452  'wl_namespace' => $target->getNamespace(),
453  'wl_title' => $target->getDBkey(),
454  'wl_notificationtimestamp >= ' .
455  $dbr->addQuotes( $dbr->timestamp( $threshold ) ) .
456  ' OR wl_notificationtimestamp IS NULL'
457  ];
458  $joinConds = [];
459 
460  if ( $this->expiryEnabled ) {
461  $tables[] = 'watchlist_expiry';
462  $joinConds[ 'watchlist_expiry' ] = [ 'LEFT JOIN', 'wl_id = we_item' ];
463  $conds[] = 'we_expiry IS NULL OR we_expiry > ' . $dbr->addQuotes( $dbr->timestamp() );
464  }
465 
466  $visitingWatchers = (int)$dbr->selectField(
467  $tables,
468  'COUNT(*)',
469  $conds,
470  __METHOD__,
471  [],
472  $joinConds
473  );
474 
475  return $visitingWatchers;
476  }
477 
483  public function removeWatchBatchForUser( UserIdentity $user, array $titles ) {
484  if ( $this->readOnlyMode->isReadOnly() ) {
485  return false;
486  }
487  if ( !$user->isRegistered() ) {
488  return false;
489  }
490  if ( !$titles ) {
491  return true;
492  }
493 
494  $rows = $this->getTitleDbKeysGroupedByNamespace( $titles );
495  $this->uncacheTitlesForUser( $user, $titles );
496 
497  $dbw = $this->getConnectionRef( DB_MASTER );
498  $ticket = count( $titles ) > $this->updateRowsPerQuery ?
499  $this->lbFactory->getEmptyTransactionTicket( __METHOD__ ) : null;
500  $affectedRows = 0;
501 
502  // Batch delete items per namespace.
503  foreach ( $rows as $namespace => $namespaceTitles ) {
504  $rowBatches = array_chunk( $namespaceTitles, $this->updateRowsPerQuery );
505  foreach ( $rowBatches as $toDelete ) {
506  // First fetch the wl_ids.
507  $wlIds = $dbw->selectFieldValues( 'watchlist', 'wl_id', [
508  'wl_user' => $user->getId(),
509  'wl_namespace' => $namespace,
510  'wl_title' => $toDelete
511  ], __METHOD__ );
512 
513  if ( $wlIds ) {
514  // Delete rows from both the watchlist and watchlist_expiry tables.
515  $dbw->delete(
516  'watchlist',
517  [ 'wl_id' => $wlIds ],
518  __METHOD__
519  );
520  $affectedRows += $dbw->affectedRows();
521 
522  if ( $this->expiryEnabled ) {
523  $dbw->delete(
524  'watchlist_expiry',
525  [ 'we_item' => $wlIds ],
526  __METHOD__
527  );
528  $affectedRows += $dbw->affectedRows();
529  }
530  }
531 
532  if ( $ticket ) {
533  $this->lbFactory->commitAndWaitForReplication( __METHOD__, $ticket );
534  }
535  }
536  }
537 
538  return (bool)$affectedRows;
539  }
540 
547  public function countWatchersMultiple( array $targets, array $options = [] ) {
548  $dbOptions = [ 'GROUP BY' => [ 'wl_namespace', 'wl_title' ] ];
549 
550  $dbr = $this->getConnectionRef( DB_REPLICA );
551 
552  if ( array_key_exists( 'minimumWatchers', $options ) ) {
553  $dbOptions['HAVING'] = 'COUNT(*) >= ' . (int)$options['minimumWatchers'];
554  }
555 
556  $lb = new LinkBatch( $targets );
557 
558  $tables = [ 'watchlist' ];
559  $conds = [ $lb->constructSet( 'wl', $dbr ) ];
560  $joinConds = [];
561 
562  if ( $this->expiryEnabled ) {
563  $tables[] = 'watchlist_expiry';
564  $joinConds[ 'watchlist_expiry' ] = [ 'LEFT JOIN', 'wl_id = we_item' ];
565  $conds[] = 'we_expiry IS NULL OR we_expiry > ' . $dbr->addQuotes( $dbr->timestamp() );
566  }
567 
568  $res = $dbr->select(
569  $tables,
570  [ 'wl_title', 'wl_namespace', 'watchers' => 'COUNT(*)' ],
571  $conds,
572  __METHOD__,
573  $dbOptions,
574  $joinConds
575  );
576 
577  $watchCounts = [];
578  foreach ( $targets as $linkTarget ) {
579  $watchCounts[$linkTarget->getNamespace()][$linkTarget->getDBkey()] = 0;
580  }
581 
582  foreach ( $res as $row ) {
583  $watchCounts[$row->wl_namespace][$row->wl_title] = (int)$row->watchers;
584  }
585 
586  return $watchCounts;
587  }
588 
596  array $targetsWithVisitThresholds,
597  $minimumWatchers = null
598  ) {
599  if ( $targetsWithVisitThresholds === [] ) {
600  // No titles requested => no results returned
601  return [];
602  }
603 
604  $dbr = $this->getConnectionRef( DB_REPLICA );
605 
606  $conds = [ $this->getVisitingWatchersCondition( $dbr, $targetsWithVisitThresholds ) ];
607 
608  $dbOptions = [ 'GROUP BY' => [ 'wl_namespace', 'wl_title' ] ];
609  if ( $minimumWatchers !== null ) {
610  $dbOptions['HAVING'] = 'COUNT(*) >= ' . (int)$minimumWatchers;
611  }
612 
613  $tables = [ 'watchlist' ];
614  $joinConds = [];
615 
616  if ( $this->expiryEnabled ) {
617  $tables[] = 'watchlist_expiry';
618  $joinConds[ 'watchlist_expiry' ] = [ 'LEFT JOIN', 'wl_id = we_item' ];
619  $conds[] = 'we_expiry IS NULL OR we_expiry > ' . $dbr->addQuotes( $dbr->timestamp() );
620  }
621 
622  $res = $dbr->select(
623  $tables,
624  [ 'wl_namespace', 'wl_title', 'watchers' => 'COUNT(*)' ],
625  $conds,
626  __METHOD__,
627  $dbOptions,
628  $joinConds
629  );
630 
631  $watcherCounts = [];
632  foreach ( $targetsWithVisitThresholds as list( $target ) ) {
634  $watcherCounts[$target->getNamespace()][$target->getDBkey()] = 0;
635  }
636 
637  foreach ( $res as $row ) {
638  $watcherCounts[$row->wl_namespace][$row->wl_title] = (int)$row->watchers;
639  }
640 
641  return $watcherCounts;
642  }
643 
652  IDatabase $db,
653  array $targetsWithVisitThresholds
654  ) {
655  $missingTargets = [];
656  $namespaceConds = [];
657  foreach ( $targetsWithVisitThresholds as list( $target, $threshold ) ) {
658  if ( $threshold === null ) {
659  $missingTargets[] = $target;
660  continue;
661  }
663  $namespaceConds[$target->getNamespace()][] = $db->makeList( [
664  'wl_title = ' . $db->addQuotes( $target->getDBkey() ),
665  $db->makeList( [
666  'wl_notificationtimestamp >= ' . $db->addQuotes( $db->timestamp( $threshold ) ),
667  'wl_notificationtimestamp IS NULL'
668  ], LIST_OR )
669  ], LIST_AND );
670  }
671 
672  $conds = [];
673  foreach ( $namespaceConds as $namespace => $pageConds ) {
674  $conds[] = $db->makeList( [
675  'wl_namespace = ' . $namespace,
676  '(' . $db->makeList( $pageConds, LIST_OR ) . ')'
677  ], LIST_AND );
678  }
679 
680  if ( $missingTargets ) {
681  $lb = new LinkBatch( $missingTargets );
682  $conds[] = $lb->constructSet( 'wl', $db );
683  }
684 
685  return $db->makeList( $conds, LIST_OR );
686  }
687 
694  public function getWatchedItem( UserIdentity $user, LinkTarget $target ) {
695  if ( !$user->isRegistered() ) {
696  return false;
697  }
698 
699  $cached = $this->getCached( $user, $target );
700  if ( $cached && !$cached->isExpired() ) {
701  $this->stats->increment( 'WatchedItemStore.getWatchedItem.cached' );
702  return $cached;
703  }
704  $this->stats->increment( 'WatchedItemStore.getWatchedItem.load' );
705  return $this->loadWatchedItem( $user, $target );
706  }
707 
714  public function loadWatchedItem( UserIdentity $user, LinkTarget $target ) {
715  // Only registered user can have a watchlist
716  if ( !$user->isRegistered() ) {
717  return false;
718  }
719 
720  $dbr = $this->getConnectionRef( DB_REPLICA );
721 
722  $row = $this->fetchWatchedItems(
723  $dbr,
724  $user,
725  [ 'wl_notificationtimestamp' ],
726  [],
727  $target
728  );
729 
730  if ( !$row ) {
731  return false;
732  }
733 
734  $item = $this->getWatchedItemFromRow( $user, $target, $row );
735  $this->cache( $item );
736 
737  return $item;
738  }
739 
746  public function getWatchedItemsForUser( UserIdentity $user, array $options = [] ) {
747  $options += [ 'forWrite' => false ];
748  $vars = [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ];
749  $dbOptions = [];
750  $db = $this->getConnectionRef( $options['forWrite'] ? DB_MASTER : DB_REPLICA );
751  if ( array_key_exists( 'sort', $options ) ) {
752  Assert::parameter(
753  ( in_array( $options['sort'], [ self::SORT_ASC, self::SORT_DESC ] ) ),
754  '$options[\'sort\']',
755  'must be SORT_ASC or SORT_DESC'
756  );
757  $dbOptions['ORDER BY'][] = "wl_namespace {$options['sort']}";
758  if ( $this->expiryEnabled
759  && array_key_exists( 'sortByExpiry', $options )
760  && $options['sortByExpiry']
761  ) {
762  // Add `wl_has_expiry` column to allow sorting by watched titles that have an expiration date first.
763  $vars['wl_has_expiry'] = $db->conditional( 'we_expiry IS NULL', 0, 1 );
764  // Display temporarily watched titles first.
765  // Order by expiration date, with the titles that will expire soonest at the top.
766  $dbOptions['ORDER BY'][] = "wl_has_expiry DESC";
767  $dbOptions['ORDER BY'][] = "we_expiry ASC";
768  }
769 
770  $dbOptions['ORDER BY'][] = "wl_title {$options['sort']}";
771  }
772 
773  $res = $this->fetchWatchedItems(
774  $db,
775  $user,
776  $vars,
777  $dbOptions
778  );
779 
780  $watchedItems = [];
781  foreach ( $res as $row ) {
782  $target = new TitleValue( (int)$row->wl_namespace, $row->wl_title );
783  // @todo: Should we add these to the process cache?
784  $watchedItems[] = $this->getWatchedItemFromRow( $user, $target, $row );
785  }
786 
787  return $watchedItems;
788  }
789 
797  private function getWatchedItemFromRow(
798  UserIdentity $user,
799  LinkTarget $target,
800  stdClass $row
801  ): WatchedItem {
802  return new WatchedItem(
803  $user,
804  $target,
806  $row->wl_notificationtimestamp, $user, $target ),
807  wfTimestampOrNull( TS_MW, $row->we_expiry ?? null )
808  );
809  }
810 
823  private function fetchWatchedItems(
824  IDatabase $db,
825  UserIdentity $user,
826  array $vars,
827  array $options = [],
828  ?LinkTarget $target = null
829  ) {
830  $dbMethod = 'select';
831  $conds = [ 'wl_user' => $user->getId() ];
832 
833  if ( $target ) {
834  $dbMethod = 'selectRow';
835  $conds = array_merge( $conds, [
836  'wl_namespace' => $target->getNamespace(),
837  'wl_title' => $target->getDBkey(),
838  ] );
839  }
840 
841  if ( $this->expiryEnabled ) {
842  $vars[] = 'we_expiry';
843  $conds[] = 'we_expiry IS NULL OR we_expiry > ' . $db->addQuotes( $db->timestamp() );
844 
845  return $db->{$dbMethod}(
846  [ 'watchlist', 'watchlist_expiry' ],
847  $vars,
848  $conds,
849  __METHOD__,
850  $options,
851  [ 'watchlist_expiry' => [ 'LEFT JOIN', [ 'wl_id = we_item' ] ] ]
852  );
853  }
854 
855  return $db->{$dbMethod}(
856  'watchlist',
857  $vars,
858  $conds,
859  __METHOD__,
860  $options
861  );
862  }
863 
870  public function isWatched( UserIdentity $user, LinkTarget $target ) {
871  return (bool)$this->getWatchedItem( $user, $target );
872  }
873 
881  public function isTempWatched( UserIdentity $user, LinkTarget $target ): bool {
882  $item = $this->getWatchedItem( $user, $target );
883  return $item && $item->getExpiry();
884  }
885 
892  public function getNotificationTimestampsBatch( UserIdentity $user, array $targets ) {
893  $timestamps = [];
894  foreach ( $targets as $target ) {
895  $timestamps[$target->getNamespace()][$target->getDBkey()] = false;
896  }
897 
898  if ( !$user->isRegistered() ) {
899  return $timestamps;
900  }
901 
902  $targetsToLoad = [];
903  foreach ( $targets as $target ) {
904  $cachedItem = $this->getCached( $user, $target );
905  if ( $cachedItem ) {
906  $timestamps[$target->getNamespace()][$target->getDBkey()] =
907  $cachedItem->getNotificationTimestamp();
908  } else {
909  $targetsToLoad[] = $target;
910  }
911  }
912 
913  if ( !$targetsToLoad ) {
914  return $timestamps;
915  }
916 
917  $dbr = $this->getConnectionRef( DB_REPLICA );
918 
919  $lb = new LinkBatch( $targetsToLoad );
920  $res = $dbr->select(
921  'watchlist',
922  [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
923  [
924  $lb->constructSet( 'wl', $dbr ),
925  'wl_user' => $user->getId(),
926  ],
927  __METHOD__
928  );
929 
930  foreach ( $res as $row ) {
931  $target = new TitleValue( (int)$row->wl_namespace, $row->wl_title );
932  $timestamps[$row->wl_namespace][$row->wl_title] =
933  $this->getLatestNotificationTimestamp(
934  $row->wl_notificationtimestamp, $user, $target );
935  }
936 
937  return $timestamps;
938  }
939 
945  private function getNormalizedOrMaxExpiry( ?string $expiry ) {
946  if ( ExpiryDef::expiryExceedsMax( $expiry, $this->maxExpiryDuration ) ) {
947  return ExpiryDef::normalizeExpiry( $this->maxExpiryDuration );
948  }
949  return ExpiryDef::normalizeExpiry( $expiry );
950  }
951 
960  public function addWatch( UserIdentity $user, LinkTarget $target, ?string $expiry = null ) {
961  $this->addWatchBatchForUser( $user, [ $target ], $expiry );
962 
963  if ( $this->expiryEnabled && !$expiry ) {
964  // When re-watching a page with a null $expiry, any existing expiry is left unchanged.
965  // However we must re-fetch the preexisting expiry or else the cached WatchedItem will
966  // incorrectly have a null expiry. Note that loadWatchedItem() does the caching.
967  // See T259379
968  $this->loadWatchedItem( $user, $target );
969  } else {
970  // Create a new WatchedItem and add it to the process cache.
971  // In this case we don't need to re-fetch the expiry.
972  $expiry = $this->getNormalizedOrMaxExpiry( $expiry );
973  $item = new WatchedItem(
974  $user,
975  $target,
976  null,
977  wfIsInfinity( $expiry ) ? null : $expiry
978  );
979  $this->cache( $item );
980  }
981  }
982 
996  public function addWatchBatchForUser(
997  UserIdentity $user,
998  array $targets,
999  ?string $expiry = null
1000  ) {
1001  if ( $this->readOnlyMode->isReadOnly() ) {
1002  return false;
1003  }
1004  // Only registered user can have a watchlist
1005  if ( !$user->isRegistered() ) {
1006  return false;
1007  }
1008 
1009  if ( !$targets ) {
1010  return true;
1011  }
1012 
1013  $expiry = $this->getNormalizedOrMaxExpiry( $expiry );
1014 
1015  $rows = [];
1016  foreach ( $targets as $target ) {
1017  $rows[] = [
1018  'wl_user' => $user->getId(),
1019  'wl_namespace' => $target->getNamespace(),
1020  'wl_title' => $target->getDBkey(),
1021  'wl_notificationtimestamp' => null,
1022  ];
1023  $this->uncache( $user, $target );
1024  }
1025 
1026  $dbw = $this->getConnectionRef( DB_MASTER );
1027  $ticket = count( $targets ) > $this->updateRowsPerQuery ?
1028  $this->lbFactory->getEmptyTransactionTicket( __METHOD__ ) : null;
1029  $affectedRows = 0;
1030  $rowBatches = array_chunk( $rows, $this->updateRowsPerQuery );
1031  foreach ( $rowBatches as $toInsert ) {
1032  // Use INSERT IGNORE to avoid overwriting the notification timestamp
1033  // if there's already an entry for this page
1034  $dbw->insert( 'watchlist', $toInsert, __METHOD__, [ 'IGNORE' ] );
1035  $affectedRows += $dbw->affectedRows();
1036 
1037  if ( $this->expiryEnabled ) {
1038  $affectedRows += $this->updateOrDeleteExpiries( $dbw, $user->getId(), $toInsert, $expiry );
1039  }
1040 
1041  if ( $ticket ) {
1042  $this->lbFactory->commitAndWaitForReplication( __METHOD__, $ticket );
1043  }
1044  }
1045 
1046  return (bool)$affectedRows;
1047  }
1048 
1058  private function updateOrDeleteExpiries(
1059  IDatabase $dbw,
1060  int $userId,
1061  array $rows,
1062  ?string $expiry = null
1063  ): int {
1064  if ( !$expiry ) {
1065  // if expiry is null (shouldn't change), 0 rows affected.
1066  return 0;
1067  }
1068 
1069  // Build the giant `(...) OR (...)` part to be used with WHERE.
1070  $conds = [];
1071  foreach ( $rows as $row ) {
1072  $conds[] = $dbw->makeList(
1073  [
1074  'wl_user' => $userId,
1075  'wl_namespace' => $row['wl_namespace'],
1076  'wl_title' => $row['wl_title']
1077  ],
1079  );
1080  }
1081  $cond = $dbw->makeList( $conds, $dbw::LIST_OR );
1082 
1083  if ( wfIsInfinity( $expiry ) ) {
1084  // Rows should be deleted rather than updated.
1085  $dbw->deleteJoin(
1086  'watchlist_expiry',
1087  'watchlist',
1088  'we_item',
1089  'wl_id',
1090  [ $cond ],
1091  __METHOD__
1092  );
1093 
1094  return $dbw->affectedRows();
1095  }
1096 
1097  return $this->updateExpiries( $dbw, $expiry, $cond );
1098  }
1099 
1107  private function updateExpiries( IDatabase $dbw, string $expiry, string $cond ): int {
1108  // First fetch the wl_ids from the watchlist table.
1109  // We'd prefer to do a INSERT/SELECT in the same query with IDatabase::insertSelect(),
1110  // but it doesn't allow us to use the "ON DUPLICATE KEY UPDATE" clause.
1111  $wlIds = (array)$dbw->selectFieldValues( 'watchlist', 'wl_id', $cond, __METHOD__ );
1112 
1113  $expiry = $dbw->timestamp( $expiry );
1114 
1115  $weRows = array_map( function ( $wlId ) use ( $expiry, $dbw ) {
1116  return [
1117  'we_item' => $wlId,
1118  'we_expiry' => $expiry
1119  ];
1120  }, $wlIds );
1121 
1122  // Insert into watchlist_expiry, updating the expiry for duplicate rows.
1123  $dbw->upsert(
1124  'watchlist_expiry',
1125  $weRows,
1126  'we_item',
1127  [ 'we_expiry' => $expiry ],
1128  __METHOD__
1129  );
1130 
1131  return $dbw->affectedRows();
1132  }
1133 
1140  public function removeWatch( UserIdentity $user, LinkTarget $target ) {
1141  return $this->removeWatchBatchForUser( $user, [ $target ] );
1142  }
1143 
1162  UserIdentity $user, $timestamp, array $targets = []
1163  ) {
1164  // Only registered user can have a watchlist
1165  if ( !$user->isRegistered() || $this->readOnlyMode->isReadOnly() ) {
1166  return false;
1167  }
1168 
1169  if ( !$targets ) {
1170  // Backwards compatibility
1171  $this->resetAllNotificationTimestampsForUser( $user, $timestamp );
1172  return true;
1173  }
1174 
1175  $rows = $this->getTitleDbKeysGroupedByNamespace( $targets );
1176 
1177  $dbw = $this->getConnectionRef( DB_MASTER );
1178  if ( $timestamp !== null ) {
1179  $timestamp = $dbw->timestamp( $timestamp );
1180  }
1181  $ticket = $this->lbFactory->getEmptyTransactionTicket( __METHOD__ );
1182  $affectedSinceWait = 0;
1183 
1184  // Batch update items per namespace
1185  foreach ( $rows as $namespace => $namespaceTitles ) {
1186  $rowBatches = array_chunk( $namespaceTitles, $this->updateRowsPerQuery );
1187  foreach ( $rowBatches as $toUpdate ) {
1188  $dbw->update(
1189  'watchlist',
1190  [ 'wl_notificationtimestamp' => $timestamp ],
1191  [
1192  'wl_user' => $user->getId(),
1193  'wl_namespace' => $namespace,
1194  'wl_title' => $toUpdate
1195  ],
1196  __METHOD__
1197  );
1198  $affectedSinceWait += $dbw->affectedRows();
1199  // Wait for replication every time we've touched updateRowsPerQuery rows
1200  if ( $affectedSinceWait >= $this->updateRowsPerQuery ) {
1201  $this->lbFactory->commitAndWaitForReplication( __METHOD__, $ticket );
1202  $affectedSinceWait = 0;
1203  }
1204  }
1205  }
1206 
1207  $this->uncacheUser( $user );
1208 
1209  return true;
1210  }
1211 
1213  $timestamp, UserIdentity $user, LinkTarget $target
1214  ) {
1215  $timestamp = wfTimestampOrNull( TS_MW, $timestamp );
1216  if ( $timestamp === null ) {
1217  return null; // no notification
1218  }
1219 
1220  $seenTimestamps = $this->getPageSeenTimestamps( $user );
1221  if (
1222  $seenTimestamps &&
1223  $seenTimestamps->get( $this->getPageSeenKey( $target ) ) >= $timestamp
1224  ) {
1225  // If a reset job did not yet run, then the "seen" timestamp will be higher
1226  return null;
1227  }
1228 
1229  return $timestamp;
1230  }
1231 
1238  public function resetAllNotificationTimestampsForUser( UserIdentity $user, $timestamp = null ) {
1239  // Only registered user can have a watchlist
1240  if ( !$user->isRegistered() ) {
1241  return;
1242  }
1243 
1244  // If the page is watched by the user (or may be watched), update the timestamp
1246  'userId' => $user->getId(), 'timestamp' => $timestamp, 'casTime' => time()
1247  ] );
1248 
1249  // Try to run this post-send
1250  // Calls DeferredUpdates::addCallableUpdate in normal operation
1251  call_user_func(
1252  $this->deferredUpdatesAddCallableUpdateCallback,
1253  function () use ( $job ) {
1254  $job->run();
1255  }
1256  );
1257  }
1258 
1267  UserIdentity $editor, LinkTarget $target, $timestamp
1268  ) {
1269  $dbw = $this->getConnectionRef( DB_MASTER );
1270  $selectTables = [ 'watchlist' ];
1271  $selectConds = [
1272  'wl_user != ' . intval( $editor->getId() ),
1273  'wl_namespace' => $target->getNamespace(),
1274  'wl_title' => $target->getDBkey(),
1275  'wl_notificationtimestamp IS NULL',
1276  ];
1277  $selectJoin = [];
1278 
1279  if ( $this->expiryEnabled ) {
1280  $selectTables[] = 'watchlist_expiry';
1281  $selectConds[] = 'we_expiry IS NULL OR we_expiry > ' .
1282  $dbw->addQuotes( $dbw->timestamp() );
1283  $selectJoin = [ 'watchlist_expiry' => [ 'LEFT JOIN', 'wl_id = we_item' ] ];
1284  }
1285 
1286  $uids = $dbw->selectFieldValues(
1287  $selectTables,
1288  'wl_user',
1289  $selectConds,
1290  __METHOD__,
1291  [],
1292  $selectJoin
1293  );
1294 
1295  $watchers = array_map( 'intval', $uids );
1296  if ( $watchers ) {
1297  // Update wl_notificationtimestamp for all watching users except the editor
1298  $fname = __METHOD__;
1300  function () use ( $timestamp, $watchers, $target, $fname ) {
1301  $dbw = $this->getConnectionRef( DB_MASTER );
1302  $ticket = $this->lbFactory->getEmptyTransactionTicket( $fname );
1303 
1304  $watchersChunks = array_chunk( $watchers, $this->updateRowsPerQuery );
1305  foreach ( $watchersChunks as $watchersChunk ) {
1306  $dbw->update( 'watchlist',
1307  [ /* SET */
1308  'wl_notificationtimestamp' => $dbw->timestamp( $timestamp )
1309  ], [ /* WHERE - TODO Use wl_id T130067 */
1310  'wl_user' => $watchersChunk,
1311  'wl_namespace' => $target->getNamespace(),
1312  'wl_title' => $target->getDBkey(),
1313  ], $fname
1314  );
1315  if ( count( $watchersChunks ) > 1 ) {
1316  $this->lbFactory->commitAndWaitForReplication(
1317  $fname, $ticket, [ 'domain' => $dbw->getDomainID() ]
1318  );
1319  }
1320  }
1321  $this->uncacheLinkTarget( $target );
1322  },
1324  $dbw
1325  );
1326  }
1327 
1328  return $watchers;
1329  }
1330 
1340  UserIdentity $user, LinkTarget $title, $force = '', $oldid = 0
1341  ) {
1342  $time = time();
1343 
1344  // Only registered user can have a watchlist
1345  if ( $this->readOnlyMode->isReadOnly() || !$user->isRegistered() ) {
1346  return false;
1347  }
1348 
1349  // Hook expects User and Title, not UserIdentity and LinkTarget
1350  $userObj = User::newFromId( $user->getId() );
1351  $titleObj = Title::castFromLinkTarget( $title );
1352  if ( !$this->hookRunner->onBeforeResetNotificationTimestamp(
1353  $userObj, $titleObj, $force, $oldid )
1354  ) {
1355  return false;
1356  }
1357  if ( !$userObj->equals( $user ) ) {
1358  $user = $userObj;
1359  }
1360  if ( !$titleObj->equals( $title ) ) {
1361  $title = $titleObj;
1362  }
1363 
1364  $item = null;
1365  if ( $force != 'force' ) {
1366  $item = $this->loadWatchedItem( $user, $title );
1367  if ( !$item || $item->getNotificationTimestamp() === null ) {
1368  return false;
1369  }
1370  }
1371 
1372  // Get the timestamp (TS_MW) of this revision to track the latest one seen
1373  $id = $oldid;
1374  $seenTime = null;
1375  if ( !$id ) {
1376  $latestRev = $this->revisionLookup->getRevisionByTitle( $title );
1377  if ( $latestRev ) {
1378  $id = $latestRev->getId();
1379  // Save a DB query
1380  $seenTime = $latestRev->getTimestamp();
1381  }
1382  }
1383  if ( $seenTime === null ) {
1384  $seenTime = $this->revisionLookup->getTimestampFromId( $id );
1385  }
1386 
1387  // Mark the item as read immediately in lightweight storage
1388  $this->stash->merge(
1389  $this->getPageSeenTimestampsKey( $user ),
1390  function ( $cache, $key, $current ) use ( $title, $seenTime ) {
1391  $value = $current ?: new MapCacheLRU( 300 );
1392  $subKey = $this->getPageSeenKey( $title );
1393 
1394  if ( $seenTime > $value->get( $subKey ) ) {
1395  // Revision is newer than the last one seen
1396  $value->set( $subKey, $seenTime );
1397  $this->latestUpdateCache->set( $key, $value, BagOStuff::TTL_PROC_LONG );
1398  } elseif ( $seenTime === false ) {
1399  // Revision does not exist
1400  $value->set( $subKey, wfTimestamp( TS_MW ) );
1401  $this->latestUpdateCache->set( $key, $value, BagOStuff::TTL_PROC_LONG );
1402  } else {
1403  return false; // nothing to update
1404  }
1405 
1406  return $value;
1407  },
1408  BagOStuff::TTL_HOUR
1409  );
1410 
1411  // If the page is watched by the user (or may be watched), update the timestamp
1412  $job = new ActivityUpdateJob(
1413  $title,
1414  [
1415  'type' => 'updateWatchlistNotification',
1416  'userid' => $user->getId(),
1417  'notifTime' => $this->getNotificationTimestamp( $user, $title, $item, $force, $oldid ),
1418  'curTime' => $time
1419  ]
1420  );
1421  // Try to enqueue this post-send
1422  $this->queueGroup->lazyPush( $job );
1423 
1424  $this->uncache( $user, $title );
1425 
1426  return true;
1427  }
1428 
1433  private function getPageSeenTimestamps( UserIdentity $user ) {
1434  $key = $this->getPageSeenTimestampsKey( $user );
1435 
1436  return $this->latestUpdateCache->getWithSetCallback(
1437  $key,
1438  BagOStuff::TTL_PROC_LONG,
1439  function () use ( $key ) {
1440  return $this->stash->get( $key ) ?: null;
1441  }
1442  );
1443  }
1444 
1449  private function getPageSeenTimestampsKey( UserIdentity $user ) {
1450  return $this->stash->makeGlobalKey(
1451  'watchlist-recent-updates',
1452  $this->lbFactory->getLocalDomainID(),
1453  $user->getId()
1454  );
1455  }
1456 
1461  private function getPageSeenKey( LinkTarget $target ) {
1462  return "{$target->getNamespace()}:{$target->getDBkey()}";
1463  }
1464 
1473  private function getNotificationTimestamp(
1474  UserIdentity $user, LinkTarget $title, $item, $force, $oldid
1475  ) {
1476  if ( !$oldid ) {
1477  // No oldid given, assuming latest revision; clear the timestamp.
1478  return null;
1479  }
1480 
1481  $oldRev = $this->revisionLookup->getRevisionById( $oldid );
1482  if ( !$oldRev ) {
1483  // Oldid given but does not exist (probably deleted)
1484  return false;
1485  }
1486 
1487  $nextRev = $this->revisionLookup->getNextRevision( $oldRev );
1488  if ( !$nextRev ) {
1489  // Oldid given and is the latest revision for this title; clear the timestamp.
1490  return null;
1491  }
1492 
1493  if ( $item === null ) {
1494  $item = $this->loadWatchedItem( $user, $title );
1495  }
1496 
1497  if ( !$item ) {
1498  // This can only happen if $force is enabled.
1499  return null;
1500  }
1501 
1502  // Oldid given and isn't the latest; update the timestamp.
1503  // This will result in no further notification emails being sent!
1504  $notificationTimestamp = $this->revisionLookup->getTimestampFromId( $oldid );
1505  // @FIXME: this should use getTimestamp() for consistency with updates on new edits
1506  // $notificationTimestamp = $nextRev->getTimestamp(); // first unseen revision timestamp
1507 
1508  // We need to go one second to the future because of various strict comparisons
1509  // throughout the codebase
1510  $ts = new MWTimestamp( $notificationTimestamp );
1511  $ts->timestamp->add( new DateInterval( 'PT1S' ) );
1512  $notificationTimestamp = $ts->getTimestamp( TS_MW );
1513 
1514  if ( $notificationTimestamp < $item->getNotificationTimestamp() ) {
1515  if ( $force != 'force' ) {
1516  return false;
1517  } else {
1518  // This is a little silly‚Ķ
1519  return $item->getNotificationTimestamp();
1520  }
1521  }
1522 
1523  return $notificationTimestamp;
1524  }
1525 
1532  public function countUnreadNotifications( UserIdentity $user, $unreadLimit = null ) {
1533  $dbr = $this->getConnectionRef( DB_REPLICA );
1534 
1535  $queryOptions = [];
1536  if ( $unreadLimit !== null ) {
1537  $unreadLimit = (int)$unreadLimit;
1538  $queryOptions['LIMIT'] = $unreadLimit;
1539  }
1540 
1541  $conds = [
1542  'wl_user' => $user->getId(),
1543  'wl_notificationtimestamp IS NOT NULL'
1544  ];
1545 
1546  $rowCount = $dbr->selectRowCount( 'watchlist', '1', $conds, __METHOD__, $queryOptions );
1547 
1548  if ( $unreadLimit === null ) {
1549  return $rowCount;
1550  }
1551 
1552  if ( $rowCount >= $unreadLimit ) {
1553  return true;
1554  }
1555 
1556  return $rowCount;
1557  }
1558 
1564  public function duplicateAllAssociatedEntries( LinkTarget $oldTarget, LinkTarget $newTarget ) {
1565  // Duplicate first the subject page, then the talk page
1566  $this->duplicateEntry(
1567  $this->nsInfo->getSubjectPage( $oldTarget ),
1568  $this->nsInfo->getSubjectPage( $newTarget )
1569  );
1570  $this->duplicateEntry(
1571  $this->nsInfo->getTalkPage( $oldTarget ),
1572  $this->nsInfo->getTalkPage( $newTarget )
1573  );
1574  }
1575 
1581  public function duplicateEntry( LinkTarget $oldTarget, LinkTarget $newTarget ) {
1582  $dbw = $this->getConnectionRef( DB_MASTER );
1583  $result = $this->fetchWatchedItemsForPage( $dbw, $oldTarget );
1584  $newNamespace = $newTarget->getNamespace();
1585  $newDBkey = $newTarget->getDBkey();
1586 
1587  # Construct array to replace into the watchlist
1588  $values = [];
1589  $expiries = [];
1590  foreach ( $result as $row ) {
1591  $values[] = [
1592  'wl_user' => $row->wl_user,
1593  'wl_namespace' => $newNamespace,
1594  'wl_title' => $newDBkey,
1595  'wl_notificationtimestamp' => $row->wl_notificationtimestamp,
1596  ];
1597 
1598  if ( $this->expiryEnabled && $row->we_expiry ) {
1599  $expiries[$row->wl_user] = $row->we_expiry;
1600  }
1601  }
1602 
1603  if ( empty( $values ) ) {
1604  return;
1605  }
1606 
1607  // Perform a replace on the watchlist table rows.
1608  // Note that multi-row replace is very efficient for MySQL but may be inefficient for
1609  // some other DBMSes, mostly due to poor simulation by us.
1610  $dbw->replace(
1611  'watchlist',
1612  [ [ 'wl_user', 'wl_namespace', 'wl_title' ] ],
1613  $values,
1614  __METHOD__
1615  );
1616 
1617  if ( $this->expiryEnabled ) {
1618  $this->updateExpiriesAfterMove( $dbw, $expiries, $newNamespace, $newDBkey );
1619  }
1620  }
1621 
1627  private function fetchWatchedItemsForPage(
1628  IDatabase $dbw,
1629  LinkTarget $target
1630  ) : IResultWrapper {
1631  $tables = [ 'watchlist' ];
1632  $fields = [ 'wl_user', 'wl_notificationtimestamp' ];
1633  $joins = [];
1634 
1635  if ( $this->expiryEnabled ) {
1636  $tables[] = 'watchlist_expiry';
1637  $fields[] = 'we_expiry';
1638  $joins['watchlist_expiry'] = [ 'LEFT JOIN', [ 'wl_id = we_item' ] ];
1639  }
1640 
1641  return $dbw->select(
1642  $tables,
1643  $fields,
1644  [
1645  'wl_namespace' => $target->getNamespace(),
1646  'wl_title' => $target->getDBkey(),
1647  ],
1648  __METHOD__,
1649  [ 'FOR UPDATE' ],
1650  $joins
1651  );
1652  }
1653 
1660  private function updateExpiriesAfterMove(
1661  IDatabase $dbw,
1662  array $expiries,
1663  int $namespace,
1664  string $dbKey
1665  ): void {
1666  $method = __METHOD__;
1668  function () use ( $dbw, $expiries, $namespace, $dbKey, $method ) {
1669  // First fetch new wl_ids.
1670  $res = $dbw->select(
1671  'watchlist',
1672  [ 'wl_user', 'wl_id' ],
1673  [
1674  'wl_namespace' => $namespace,
1675  'wl_title' => $dbKey,
1676  ],
1677  $method
1678  );
1679 
1680  // Build new array to INSERT into multiple rows at once.
1681  $expiryData = [];
1682  foreach ( $res as $row ) {
1683  if ( !empty( $expiries[$row->wl_user] ) ) {
1684  $expiryData[] = [
1685  'we_item' => $row->wl_id,
1686  'we_expiry' => $expiries[$row->wl_user],
1687  ];
1688  }
1689  }
1690 
1691  // Batch the insertions.
1692  $batches = array_chunk( $expiryData, $this->updateRowsPerQuery );
1693  foreach ( $batches as $toInsert ) {
1694  $dbw->insert(
1695  'watchlist_expiry',
1696  $toInsert,
1697  $method
1698  );
1699  }
1700  },
1702  $dbw
1703  );
1704  }
1705 
1710  private function getTitleDbKeysGroupedByNamespace( array $titles ) {
1711  $rows = [];
1712  foreach ( $titles as $title ) {
1713  // Group titles by namespace.
1714  $rows[ $title->getNamespace() ][] = $title->getDBkey();
1715  }
1716  return $rows;
1717  }
1718 
1723  private function uncacheTitlesForUser( UserIdentity $user, array $titles ) {
1724  foreach ( $titles as $title ) {
1725  $this->uncache( $user, $title );
1726  }
1727  }
1728 
1732  public function countExpired(): int {
1733  $dbr = $this->getConnectionRef( DB_REPLICA );
1734  return $dbr->selectRowCount(
1735  'watchlist_expiry',
1736  '*',
1737  [ 'we_expiry <= ' . $dbr->addQuotes( $dbr->timestamp() ) ],
1738  __METHOD__
1739  );
1740  }
1741 
1745  public function removeExpired( int $limit, bool $deleteOrphans = false ): void {
1746  $dbr = $this->getConnectionRef( DB_REPLICA );
1747  $dbw = $this->getConnectionRef( DB_MASTER );
1748  $ticket = $this->lbFactory->getEmptyTransactionTicket( __METHOD__ );
1749 
1750  // Get a batch of watchlist IDs to delete.
1751  $toDelete = $dbr->selectFieldValues(
1752  'watchlist_expiry',
1753  'we_item',
1754  [ 'we_expiry <= ' . $dbr->addQuotes( $dbr->timestamp() ) ],
1755  __METHOD__,
1756  [ 'LIMIT' => $limit ]
1757  );
1758  if ( count( $toDelete ) > 0 ) {
1759  // Delete them from the watchlist and watchlist_expiry table.
1760  $dbw->delete(
1761  'watchlist',
1762  [ 'wl_id' => $toDelete ],
1763  __METHOD__
1764  );
1765  $dbw->delete(
1766  'watchlist_expiry',
1767  [ 'we_item' => $toDelete ],
1768  __METHOD__
1769  );
1770  }
1771 
1772  // Also delete any orphaned or null-expiry watchlist_expiry rows
1773  // (they should not exist, but might because not everywhere knows about the expiry table yet).
1774  if ( $deleteOrphans ) {
1775  $expiryToDelete = $dbr->selectFieldValues(
1776  [ 'watchlist_expiry', 'watchlist' ],
1777  'we_item',
1778  $dbr->makeList(
1779  [ 'wl_id' => null, 'we_expiry' => null ],
1781  ),
1782  __METHOD__,
1783  [],
1784  [ 'watchlist' => [ 'LEFT JOIN', 'wl_id = we_item' ] ]
1785  );
1786  if ( count( $expiryToDelete ) > 0 ) {
1787  $dbw->delete(
1788  'watchlist_expiry',
1789  [ 'we_item' => $expiryToDelete ],
1790  __METHOD__
1791  );
1792  }
1793  }
1794 
1795  $this->lbFactory->commitAndWaitForReplication( __METHOD__, $ticket );
1796  }
1797 }
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:1212
MWTimestamp
Library for creating and parsing MW-style timestamps.
Definition: MWTimestamp.php:34
WatchedItemStore\$cacheIndex
array[][] $cacheIndex
Looks like $cacheIndex[Namespace ID][Target DB Key][User Id] => 'key' The index is needed so that on ...
Definition: WatchedItemStore.php:79
User\newFromId
static newFromId( $id)
Static factory method for creation from a given user ID.
Definition: User.php:562
Wikimedia\Rdbms\IDatabase\affectedRows
affectedRows()
Get the number of rows affected by the last write query.
WatchedItemStore\uncacheAllItemsForUser
uncacheAllItemsForUser(UserIdentity $user)
Definition: WatchedItemStore.php:320
ActivityUpdateJob
Job for updating user activity like "last viewed" timestamps.
Definition: ActivityUpdateJob.php:36
WatchedItemStore\loadWatchedItem
loadWatchedItem(UserIdentity $user, LinkTarget $target)
Definition: WatchedItemStore.php:714
WatchedItemStore\updateNotificationTimestamp
updateNotificationTimestamp(UserIdentity $editor, LinkTarget $target, $timestamp)
Definition: WatchedItemStore.php:1266
HashBagOStuff
Simple store for keeping values in an associative array for the current process.
Definition: HashBagOStuff.php:32
LinkBatch
Class representing a list of titles The execute() method checks them all for existence and adds them ...
Definition: LinkBatch.php:35
WatchedItemStore\getPageSeenTimestamps
getPageSeenTimestamps(UserIdentity $user)
Definition: WatchedItemStore.php:1433
WatchedItemStore\clearUserWatchedItems
clearUserWatchedItems(UserIdentity $user)
Deletes ALL watched items for the given user when under $updateRowsPerQuery entries exist.
Definition: WatchedItemStore.php:274
WatchedItemStore\getVisitingWatchersCondition
getVisitingWatchersCondition(IDatabase $db, array $targetsWithVisitThresholds)
Generates condition for the query used in a batch count visiting watchers.
Definition: WatchedItemStore.php:651
WatchedItemStore\getNormalizedOrMaxExpiry
getNormalizedOrMaxExpiry(?string $expiry)
Returns a normalized expiry or the max expiry if the given expiry exceeds it.
Definition: WatchedItemStore.php:945
wfTimestamp
wfTimestamp( $outputtype=TS_UNIX, $ts=0)
Get a timestamp string in one of various formats.
Definition: GlobalFunctions.php:1809
WatchedItemStore\getNotificationTimestampsBatch
getNotificationTimestampsBatch(UserIdentity $user, array $targets)
Definition: WatchedItemStore.php:892
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:94
WatchedItemStore\countVisitingWatchers
countVisitingWatchers(LinkTarget $target, $threshold)
Definition: WatchedItemStore.php:448
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:386
WatchedItemStore\duplicateAllAssociatedEntries
duplicateAllAssociatedEntries(LinkTarget $oldTarget, LinkTarget $newTarget)
Definition: WatchedItemStore.php:1564
BagOStuff
Class representing a cache/ephemeral data store.
Definition: BagOStuff.php:71
WatchedItemStore\addWatch
addWatch(UserIdentity $user, LinkTarget $target, ?string $expiry=null)
Definition: WatchedItemStore.php:960
WatchedItemStore\$loadBalancer
LoadBalancer $loadBalancer
Definition: WatchedItemStore.php:45
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:359
$res
$res
Definition: testCompression.php:57
WatchedItemStore\getNotificationTimestamp
getNotificationTimestamp(UserIdentity $user, LinkTarget $title, $item, $force, $oldid)
Definition: WatchedItemStore.php:1473
WatchedItemStore\countExpired
countExpired()
Get the number of watchlist items that expire before the current time.1.35int
Definition: WatchedItemStore.php:1732
MediaWiki\User\UserIdentity
Interface for objects representing user identity.
Definition: UserIdentity.php:32
WatchedItemStore\$cache
HashBagOStuff $cache
Definition: WatchedItemStore.php:65
Revision\RevisionLookup
Service for looking up page revisions.
Definition: RevisionLookup.php:38
WatchedItemStore\CONSTRUCTOR_OPTIONS
const CONSTRUCTOR_OPTIONS
Definition: WatchedItemStore.php:31
LIST_AND
const LIST_AND
Definition: Defines.php:48
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:40
Wikimedia\ParamValidator\TypeDef\ExpiryDef
Type definition for expiry timestamps.
Definition: ExpiryDef.php:16
MediaWiki\Linker\LinkTarget\getNamespace
getNamespace()
Get the namespace index.
WatchedItemStore\$readOnlyMode
ReadOnlyMode $readOnlyMode
Definition: WatchedItemStore.php:60
Wikimedia\Rdbms\IDatabase\timestamp
timestamp( $ts=0)
Convert a timestamp in one of the formats accepted by ConvertibleTimestamp to the format used for ins...
LIST_OR
const LIST_OR
Definition: Defines.php:51
WatchedItemStore\$expiryEnabled
bool $expiryEnabled
Correlates to $wgWatchlistExpiry feature flag.
Definition: WatchedItemStore.php:109
WatchedItemStore\overrideDeferredUpdatesAddCallableUpdateCallback
overrideDeferredUpdatesAddCallableUpdateCallback(callable $callback)
Overrides the DeferredUpdates::addCallableUpdate callback This is intended for use while testing and ...
Definition: WatchedItemStore.php:182
MWException
MediaWiki exception.
Definition: MWException.php:29
WatchedItemStore\getPageSeenTimestampsKey
getPageSeenTimestampsKey(UserIdentity $user)
Definition: WatchedItemStore.php:1449
MediaWiki\Config\ServiceOptions
A class for passing options to services.
Definition: ServiceOptions.php:25
WatchedItemStore\isTempWatched
isTempWatched(UserIdentity $user, LinkTarget $target)
Check if the user is temporarily watching the page.
Definition: WatchedItemStore.php:881
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:1339
WatchedItemStore\$queueGroup
JobQueueGroup $queueGroup
Definition: WatchedItemStore.php:50
wfTimestampOrNull
wfTimestampOrNull( $outputtype=TS_UNIX, $ts=null)
Return a formatted timestamp, or null if input is null.
Definition: GlobalFunctions.php:1825
WatchedItemStore\duplicateEntry
duplicateEntry(LinkTarget $oldTarget, LinkTarget $newTarget)
Definition: WatchedItemStore.php:1581
MapCacheLRU
Handles a simple LRU key/value map with a maximum number of entries.
Definition: MapCacheLRU.php:38
WatchedItemStore\removeWatch
removeWatch(UserIdentity $user, LinkTarget $target)
Definition: WatchedItemStore.php:1140
DeferredUpdates\POSTSEND
const POSTSEND
Definition: DeferredUpdates.php:85
$title
$title
Definition: testCompression.php:38
WatchedItemStore\$maxExpiryDuration
string null $maxExpiryDuration
Maximum configured relative expiry.
Definition: WatchedItemStore.php:119
DB_REPLICA
const DB_REPLICA
Definition: defines.php:25
WatchedItemStore\getMaxId
getMaxId()
Definition: WatchedItemStore.php:371
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:1058
DB_MASTER
const DB_MASTER
Definition: defines.php:26
WatchedItemStore\isWatched
isWatched(UserIdentity $user, LinkTarget $target)
Definition: WatchedItemStore.php:870
WatchedItemStore\uncacheLinkTarget
uncacheLinkTarget(LinkTarget $target)
Definition: WatchedItemStore.php:218
WatchedItem\getUserIdentity
getUserIdentity()
Definition: WatchedItem.php:107
WatchedItemStore\getWatchedItemFromRow
getWatchedItemFromRow(UserIdentity $user, LinkTarget $target, stdClass $row)
Construct a new WatchedItem given a row from watchlist/watchlist_expiry.
Definition: WatchedItemStore.php:797
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:1745
WatchedItemStore\getPageSeenKey
getPageSeenKey(LinkTarget $target)
Definition: WatchedItemStore.php:1461
WatchedItemStore\getConnectionRef
getConnectionRef( $dbIndex)
Definition: WatchedItemStore.php:260
WatchedItemStore\$deferredUpdatesAddCallableUpdateCallback
callable null $deferredUpdatesAddCallableUpdateCallback
Definition: WatchedItemStore.php:84
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:99
WatchedItemStore\countUnreadNotifications
countUnreadNotifications(UserIdentity $user, $unreadLimit=null)
Definition: WatchedItemStore.php:1532
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:316
WatchedItemStore\fetchWatchedItemsForPage
fetchWatchedItemsForPage(IDatabase $dbw, LinkTarget $target)
Definition: WatchedItemStore.php:1627
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:1238
wfIsInfinity
wfIsInfinity( $str)
Determine input string is represents as infinity.
Definition: GlobalFunctions.php:2787
WatchedItem
Representation of a pair of user and title for watchlist entries.
Definition: WatchedItem.php:33
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:1161
WatchedItemStore\getWatchedItem
getWatchedItem(UserIdentity $user, LinkTarget $target)
Definition: WatchedItemStore.php:694
WatchedItemStore\$latestUpdateCache
HashBagOStuff $latestUpdateCache
Definition: WatchedItemStore.php:70
WatchedItemStore\uncacheTitlesForUser
uncacheTitlesForUser(UserIdentity $user, array $titles)
Definition: WatchedItemStore.php:1723
Wikimedia\Rdbms\IDatabase\insert
insert( $table, $rows, $fname=__METHOD__, $options=[])
Insert the given row(s) into a table.
WatchedItemStore
Storage layer class for WatchedItems.
Definition: WatchedItemStore.php:26
WatchlistExpiryJob
Definition: WatchlistExpiryJob.php:5
WatchedItemStore\getCached
getCached(UserIdentity $user, LinkTarget $target)
Definition: WatchedItemStore.php:251
WatchedItemStore\getCacheKey
getCacheKey(UserIdentity $user, LinkTarget $target)
Definition: WatchedItemStore.php:195
WatchedItemStore\uncache
uncache(UserIdentity $user, LinkTarget $target)
Definition: WatchedItemStore.php:212
MediaWiki\User\UserIdentity\getId
getId()
$cache
$cache
Definition: mcc.php:33
WatchedItemStore\updateExpiriesAfterMove
updateExpiriesAfterMove(IDatabase $dbw, array $expiries, int $namespace, string $dbKey)
Definition: WatchedItemStore.php:1660
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:104
WatchedItemStore\$stash
BagOStuff $stash
Definition: WatchedItemStore.php:55
WatchedItemStore\countWatchers
countWatchers(LinkTarget $target)
Definition: WatchedItemStore.php:415
$job
if(count( $args)< 1) $job
Definition: recompressTracked.php:50
WatchedItemStore\__construct
__construct(ServiceOptions $options, ILBFactory $lbFactory, JobQueueGroup $queueGroup, BagOStuff $stash, HashBagOStuff $cache, ReadOnlyMode $readOnlyMode, NamespaceInfo $nsInfo, RevisionLookup $revisionLookup, HookContainer $hookContainer)
Definition: WatchedItemStore.php:132
WatchedItemStore\updateExpiries
updateExpiries(IDatabase $dbw, string $expiry, string $cond)
Update the expiries for items found with the given $cond.
Definition: WatchedItemStore.php:1107
WatchedItem\getLinkTarget
getLinkTarget()
Definition: WatchedItem.php:114
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:595
Wikimedia\Rdbms\IDatabase\select
select( $table, $vars, $conds='', $fname=__METHOD__, $options=[], $join_conds=[])
Execute a SELECT query constructed using the various parameters provided.
WatchedItemStore\setStatsdDataFactory
setStatsdDataFactory(StatsdDataFactoryInterface $stats)
Definition: WatchedItemStore.php:167
WatchedItemStore\uncacheUser
uncacheUser(UserIdentity $user)
Definition: WatchedItemStore.php:229
MediaWiki\Config\ServiceOptions\get
get( $key)
Definition: ServiceOptions.php:84
MediaWiki\HookContainer\HookContainer
HookContainer class.
Definition: HookContainer.php:44
WatchedItemStore\$hookRunner
HookRunner $hookRunner
Definition: WatchedItemStore.php:114
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:570
WatchedItemStore\getWatchedItemsForUser
getWatchedItemsForUser(UserIdentity $user, array $options=[])
Definition: WatchedItemStore.php:746
WatchedItemStore\addWatchBatchForUser
addWatchBatchForUser(UserIdentity $user, array $targets, ?string $expiry=null)
Add multiple items to the user's watchlist.
Definition: WatchedItemStore.php:996
Title\castFromLinkTarget
static castFromLinkTarget( $linkTarget)
Same as newFromLinkTarget, but if passed null, returns null.
Definition: Title.php:304
MediaWiki\Linker\LinkTarget
Definition: LinkTarget.php:26
WatchedItemStoreInterface
Definition: WatchedItemStoreInterface.php:30
DeferredUpdates\addCallableUpdate
static addCallableUpdate( $callable, $stage=self::POSTSEND, $dbw=null)
Add a callable update.
Definition: DeferredUpdates.php:145
WatchedItemStore\getTitleDbKeysGroupedByNamespace
getTitleDbKeysGroupedByNamespace(array $titles)
Definition: WatchedItemStore.php:1710
Wikimedia\Rdbms\IDatabase\makeList
makeList(array $a, $mode=self::LIST_COMMA)
Makes an encoded list of strings from an array.
WatchedItemStore\$updateRowsPerQuery
int $updateRowsPerQuery
Definition: WatchedItemStore.php:89
WatchedItemStore\countWatchersMultiple
countWatchersMultiple(array $targets, array $options=[])
Definition: WatchedItemStore.php:547
WatchedItemStore\fetchWatchedItems
fetchWatchedItems(IDatabase $db, UserIdentity $user, array $vars, array $options=[], ?LinkTarget $target=null)
Fetches either a single or all watched items for the given user.
Definition: WatchedItemStore.php:823
MediaWiki\Config\ServiceOptions\assertRequiredOptions
assertRequiredOptions(array $expectedKeys)
Assert that the list of options provided in this instance exactly match $expectedKeys,...
Definition: ServiceOptions.php:62
Wikimedia\Rdbms\ILBFactory
An interface for generating database load balancers.
Definition: ILBFactory.php:33
WatchedItemStore\removeWatchBatchForUser
removeWatchBatchForUser(UserIdentity $user, array $titles)
Definition: WatchedItemStore.php:483
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:351
WatchedItemStore\cache
cache(WatchedItem $item)
Definition: WatchedItemStore.php:203
TitleValue
Represents a page (or page fragment) title within MediaWiki.
Definition: TitleValue.php:39