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  'WatchlistPurgeRate',
36  ];
37 
41  private $lbFactory;
42 
46  private $loadBalancer;
47 
51  private $queueGroup;
52 
56  private $stash;
57 
61  private $readOnlyMode;
62 
66  private $cache;
67 
72 
80  private $cacheIndex = [];
81 
86 
91 
95  private $nsInfo;
96 
101 
105  private $stats;
106 
110  private $expiryEnabled;
111 
116 
121 
124 
136  public function __construct(
137  ServiceOptions $options,
146  ) {
147  $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
148  $this->updateRowsPerQuery = $options->get( 'UpdateRowsPerQuery' );
149  $this->expiryEnabled = $options->get( 'WatchlistExpiry' );
150  $this->maxExpiryDuration = $options->get( 'WatchlistExpiryMaxDuration' );
151  $this->watchlistPurgeRate = $options->get( 'WatchlistPurgeRate' );
152 
153  $this->lbFactory = $lbFactory;
154  $this->loadBalancer = $lbFactory->getMainLB();
155  $this->queueGroup = $queueGroup;
156  $this->stash = $stash;
157  $this->cache = $cache;
158  $this->readOnlyMode = $readOnlyMode;
159  $this->stats = new NullStatsdDataFactory();
160  $this->deferredUpdatesAddCallableUpdateCallback =
161  [ DeferredUpdates::class, 'addCallableUpdate' ];
162  $this->nsInfo = $nsInfo;
163  $this->revisionLookup = $revisionLookup;
164  $this->linkBatchFactory = $linkBatchFactory;
165 
166  $this->latestUpdateCache = new HashBagOStuff( [ 'maxKeys' => 3 ] );
167  }
168 
172  public function setStatsdDataFactory( StatsdDataFactoryInterface $stats ) {
173  $this->stats = $stats;
174  }
175 
187  public function overrideDeferredUpdatesAddCallableUpdateCallback( callable $callback ) {
188  if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
189  throw new MWException(
190  'Cannot override DeferredUpdates::addCallableUpdate callback in operation.'
191  );
192  }
194  $this->deferredUpdatesAddCallableUpdateCallback = $callback;
195  return new ScopedCallback( function () use ( $previousValue ) {
196  $this->deferredUpdatesAddCallableUpdateCallback = $previousValue;
197  } );
198  }
199 
205  private function getCacheKey( UserIdentity $user, $target ): string {
206  return $this->cache->makeKey(
207  (string)$target->getNamespace(),
208  $target->getDBkey(),
209  (string)$user->getId()
210  );
211  }
212 
216  private function cache( WatchedItem $item ) {
217  $user = $item->getUserIdentity();
218  $target = $item->getTarget();
219  $key = $this->getCacheKey( $user, $target );
220  $this->cache->set( $key, $item );
221  $this->cacheIndex[$target->getNamespace()][$target->getDBkey()][$user->getId()] = $key;
222  $this->stats->increment( 'WatchedItemStore.cache' );
223  }
224 
229  private function uncache( UserIdentity $user, $target ) {
230  $this->cache->delete( $this->getCacheKey( $user, $target ) );
231  unset( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()][$user->getId()] );
232  $this->stats->increment( 'WatchedItemStore.uncache' );
233  }
234 
238  private function uncacheLinkTarget( $target ) {
239  $this->stats->increment( 'WatchedItemStore.uncacheLinkTarget' );
240  if ( !isset( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()] ) ) {
241  return;
242  }
243  foreach ( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()] as $key ) {
244  $this->stats->increment( 'WatchedItemStore.uncacheLinkTarget.items' );
245  $this->cache->delete( $key );
246  }
247  }
248 
252  private function uncacheUser( UserIdentity $user ) {
253  $this->stats->increment( 'WatchedItemStore.uncacheUser' );
254  foreach ( $this->cacheIndex as $ns => $dbKeyArray ) {
255  foreach ( $dbKeyArray as $dbKey => $userArray ) {
256  if ( isset( $userArray[$user->getId()] ) ) {
257  $this->stats->increment( 'WatchedItemStore.uncacheUser.items' );
258  $this->cache->delete( $userArray[$user->getId()] );
259  }
260  }
261  }
262 
263  $pageSeenKey = $this->getPageSeenTimestampsKey( $user );
264  $this->latestUpdateCache->delete( $pageSeenKey );
265  $this->stash->delete( $pageSeenKey );
266  }
267 
274  private function getCached( UserIdentity $user, $target ) {
275  return $this->cache->get( $this->getCacheKey( $user, $target ) );
276  }
277 
283  private function getConnectionRef( $dbIndex ): IDatabase {
284  return $this->loadBalancer->getConnectionRef( $dbIndex, [ 'watchlist' ] );
285  }
286 
296  private function modifyForExpiry(
297  array &$tables,
298  array &$conds,
299  array &$joinConds,
300  IDatabase $db
301  ) {
302  if ( $this->expiryEnabled ) {
303  $tables[] = 'watchlist_expiry';
304  $conds[] = 'we_expiry IS NULL OR we_expiry > ' . $db->addQuotes( $db->timestamp() );
305  $joinConds[ 'watchlist_expiry' ] = [ 'LEFT JOIN', 'wl_id = we_item' ];
306  }
307  }
308 
319  public function clearUserWatchedItems( UserIdentity $user ): bool {
320  if ( $this->mustClearWatchedItemsUsingJobQueue( $user ) ) {
321  return false;
322  }
323 
324  $dbw = $this->loadBalancer->getConnectionRef( DB_PRIMARY );
325 
326  if ( $this->expiryEnabled ) {
327  $ticket = $this->lbFactory->getEmptyTransactionTicket( __METHOD__ );
328  // First fetch the wl_ids.
329  $wlIds = $dbw->selectFieldValues(
330  'watchlist',
331  'wl_id',
332  [ 'wl_user' => $user->getId() ],
333  __METHOD__
334  );
335 
336  if ( $wlIds ) {
337  // Delete rows from both the watchlist and watchlist_expiry tables.
338  $dbw->delete(
339  'watchlist',
340  [ 'wl_id' => $wlIds ],
341  __METHOD__
342  );
343 
344  $dbw->delete(
345  'watchlist_expiry',
346  [ 'we_item' => $wlIds ],
347  __METHOD__
348  );
349  }
350  $this->lbFactory->commitAndWaitForReplication( __METHOD__, $ticket );
351  } else {
352  $dbw->delete(
353  'watchlist',
354  [ 'wl_user' => $user->getId() ],
355  __METHOD__
356  );
357  }
358 
359  $this->uncacheAllItemsForUser( $user );
360 
361  return true;
362  }
363 
368  public function mustClearWatchedItemsUsingJobQueue( UserIdentity $user ): bool {
369  return $this->countWatchedItems( $user ) > $this->updateRowsPerQuery;
370  }
371 
375  private function uncacheAllItemsForUser( UserIdentity $user ) {
376  $userId = $user->getId();
377  foreach ( $this->cacheIndex as $ns => $dbKeyIndex ) {
378  foreach ( $dbKeyIndex as $dbKey => $userIndex ) {
379  if ( array_key_exists( $userId, $userIndex ) ) {
380  $this->cache->delete( $userIndex[$userId] );
381  unset( $this->cacheIndex[$ns][$dbKey][$userId] );
382  }
383  }
384  }
385 
386  // Cleanup empty cache keys
387  foreach ( $this->cacheIndex as $ns => $dbKeyIndex ) {
388  foreach ( $dbKeyIndex as $dbKey => $userIndex ) {
389  if ( empty( $this->cacheIndex[$ns][$dbKey] ) ) {
390  unset( $this->cacheIndex[$ns][$dbKey] );
391  }
392  }
393  if ( empty( $this->cacheIndex[$ns] ) ) {
394  unset( $this->cacheIndex[$ns] );
395  }
396  }
397  }
398 
407  $job = ClearUserWatchlistJob::newForUser( $user, $this->getMaxId() );
408  $this->queueGroup->push( $job );
409  }
410 
414  public function maybeEnqueueWatchlistExpiryJob(): void {
415  if ( !$this->expiryEnabled ) {
416  // No need to purge expired entries if there are none
417  return;
418  }
419 
420  $max = mt_getrandmax();
421  if ( mt_rand( 0, $max ) < $max * $this->watchlistPurgeRate ) {
422  // The higher the watchlist purge rate, the more likely we are to enqueue a job.
423  $this->queueGroup->lazyPush( new WatchlistExpiryJob() );
424  }
425  }
426 
431  public function getMaxId(): int {
432  $dbr = $this->getConnectionRef( DB_REPLICA );
433  return (int)$dbr->selectField(
434  'watchlist',
435  'MAX(wl_id)',
436  '',
437  __METHOD__
438  );
439  }
440 
446  public function countWatchedItems( UserIdentity $user ): int {
447  $dbr = $this->getConnectionRef( DB_REPLICA );
448  $tables = [ 'watchlist' ];
449  $conds = [ 'wl_user' => $user->getId() ];
450  $joinConds = [];
451 
452  $this->modifyForExpiry( $tables, $conds, $joinConds, $dbr );
453 
454  return (int)$dbr->selectField(
455  $tables,
456  'COUNT(*)',
457  $conds,
458  __METHOD__,
459  [],
460  $joinConds
461  );
462  }
463 
469  public function countWatchers( $target ): int {
470  $dbr = $this->getConnectionRef( DB_REPLICA );
471  $tables = [ 'watchlist' ];
472  $conds = [
473  'wl_namespace' => $target->getNamespace(),
474  'wl_title' => $target->getDBkey()
475  ];
476  $joinConds = [];
477 
478  $this->modifyForExpiry( $tables, $conds, $joinConds, $dbr );
479 
480  return (int)$dbr->selectField(
481  $tables,
482  'COUNT(*)',
483  $conds,
484  __METHOD__,
485  [],
486  $joinConds
487  );
488  }
489 
496  public function countVisitingWatchers( $target, $threshold ): int {
497  $dbr = $this->getConnectionRef( DB_REPLICA );
498  $tables = [ 'watchlist' ];
499  $conds = [
500  'wl_namespace' => $target->getNamespace(),
501  'wl_title' => $target->getDBkey(),
502  'wl_notificationtimestamp >= ' .
503  $dbr->addQuotes( $dbr->timestamp( $threshold ) ) .
504  ' OR wl_notificationtimestamp IS NULL'
505  ];
506  $joinConds = [];
507 
508  $this->modifyForExpiry( $tables, $conds, $joinConds, $dbr );
509 
510  return (int)$dbr->selectField(
511  $tables,
512  'COUNT(*)',
513  $conds,
514  __METHOD__,
515  [],
516  $joinConds
517  );
518  }
519 
525  public function removeWatchBatchForUser( UserIdentity $user, array $titles ): bool {
526  if ( $this->readOnlyMode->isReadOnly() ) {
527  return false;
528  }
529  if ( !$user->isRegistered() ) {
530  return false;
531  }
532  if ( !$titles ) {
533  return true;
534  }
535 
536  $rows = $this->getTitleDbKeysGroupedByNamespace( $titles );
537  $this->uncacheTitlesForUser( $user, $titles );
538 
539  $dbw = $this->getConnectionRef( DB_PRIMARY );
540  $ticket = count( $titles ) > $this->updateRowsPerQuery ?
541  $this->lbFactory->getEmptyTransactionTicket( __METHOD__ ) : null;
542  $affectedRows = 0;
543 
544  // Batch delete items per namespace.
545  foreach ( $rows as $namespace => $namespaceTitles ) {
546  $rowBatches = array_chunk( $namespaceTitles, $this->updateRowsPerQuery );
547  foreach ( $rowBatches as $toDelete ) {
548  // First fetch the wl_ids.
549  $wlIds = $dbw->selectFieldValues(
550  'watchlist',
551  'wl_id',
552  [
553  'wl_user' => $user->getId(),
554  'wl_namespace' => $namespace,
555  'wl_title' => $toDelete
556  ],
557  __METHOD__
558  );
559 
560  if ( $wlIds ) {
561  // Delete rows from both the watchlist and watchlist_expiry tables.
562  $dbw->delete(
563  'watchlist',
564  [ 'wl_id' => $wlIds ],
565  __METHOD__
566  );
567  $affectedRows += $dbw->affectedRows();
568 
569  if ( $this->expiryEnabled ) {
570  $dbw->delete(
571  'watchlist_expiry',
572  [ 'we_item' => $wlIds ],
573  __METHOD__
574  );
575  $affectedRows += $dbw->affectedRows();
576  }
577  }
578 
579  if ( $ticket ) {
580  $this->lbFactory->commitAndWaitForReplication( __METHOD__, $ticket );
581  }
582  }
583  }
584 
585  return (bool)$affectedRows;
586  }
587 
595  public function countWatchersMultiple( array $targets, array $options = [] ): array {
596  $dbOptions = [ 'GROUP BY' => [ 'wl_namespace', 'wl_title' ] ];
597 
598  if ( array_key_exists( 'minimumWatchers', $options ) ) {
599  $dbOptions['HAVING'] = 'COUNT(*) >= ' . (int)$options['minimumWatchers'];
600  }
601 
602  $linkTargets = array_map( static function ( $target ) {
603  if ( !$target instanceof LinkTarget ) {
604  return new TitleValue( $target->getNamespace(), $target->getDBkey() );
605  }
606  return $target;
607  }, $targets );
608  $lb = $this->linkBatchFactory->newLinkBatch( $linkTargets );
609  $dbr = $this->getConnectionRef( DB_REPLICA );
610 
611  $tables = [ 'watchlist' ];
612  $conds = [ $lb->constructSet( 'wl', $dbr ) ];
613  $joinConds = [];
614 
615  $this->modifyForExpiry( $tables, $conds, $joinConds, $dbr );
616 
617  $res = $dbr->select(
618  $tables,
619  [ 'wl_title', 'wl_namespace', 'watchers' => 'COUNT(*)' ],
620  $conds,
621  __METHOD__,
622  $dbOptions,
623  $joinConds
624  );
625 
626  $watchCounts = [];
627  foreach ( $targets as $linkTarget ) {
628  $watchCounts[$linkTarget->getNamespace()][$linkTarget->getDBkey()] = 0;
629  }
630 
631  foreach ( $res as $row ) {
632  $watchCounts[$row->wl_namespace][$row->wl_title] = (int)$row->watchers;
633  }
634 
635  return $watchCounts;
636  }
637 
647  array $targetsWithVisitThresholds,
648  $minimumWatchers = null
649  ): array {
650  if ( $targetsWithVisitThresholds === [] ) {
651  // No titles requested => no results returned
652  return [];
653  }
654 
655  $dbr = $this->getConnectionRef( DB_REPLICA );
656 
657  $conds = [ $this->getVisitingWatchersCondition( $dbr, $targetsWithVisitThresholds ) ];
658 
659  $dbOptions = [ 'GROUP BY' => [ 'wl_namespace', 'wl_title' ] ];
660  if ( $minimumWatchers !== null ) {
661  $dbOptions['HAVING'] = 'COUNT(*) >= ' . (int)$minimumWatchers;
662  }
663 
664  $tables = [ 'watchlist' ];
665  $joinConds = [];
666 
667  $this->modifyForExpiry( $tables, $conds, $joinConds, $dbr );
668 
669  $res = $dbr->select(
670  $tables,
671  [ 'wl_namespace', 'wl_title', 'watchers' => 'COUNT(*)' ],
672  $conds,
673  __METHOD__,
674  $dbOptions,
675  $joinConds
676  );
677 
678  $watcherCounts = [];
679  foreach ( $targetsWithVisitThresholds as list( $target ) ) {
681  $watcherCounts[$target->getNamespace()][$target->getDBkey()] = 0;
682  }
683 
684  foreach ( $res as $row ) {
685  $watcherCounts[$row->wl_namespace][$row->wl_title] = (int)$row->watchers;
686  }
687 
688  return $watcherCounts;
689  }
690 
700  IDatabase $db,
701  array $targetsWithVisitThresholds
702  ): string {
703  $missingTargets = [];
704  $namespaceConds = [];
705  foreach ( $targetsWithVisitThresholds as list( $target, $threshold ) ) {
706  if ( $threshold === null ) {
707  $missingTargets[] = $target;
708  continue;
709  }
711  $namespaceConds[$target->getNamespace()][] = $db->makeList( [
712  'wl_title = ' . $db->addQuotes( $target->getDBkey() ),
713  $db->makeList( [
714  'wl_notificationtimestamp >= ' . $db->addQuotes( $db->timestamp( $threshold ) ),
715  'wl_notificationtimestamp IS NULL'
716  ], LIST_OR )
717  ], LIST_AND );
718  }
719 
720  $conds = [];
721  foreach ( $namespaceConds as $namespace => $pageConds ) {
722  $conds[] = $db->makeList( [
723  'wl_namespace = ' . $namespace,
724  '(' . $db->makeList( $pageConds, LIST_OR ) . ')'
725  ], LIST_AND );
726  }
727 
728  if ( $missingTargets ) {
729  $lb = $this->linkBatchFactory->newLinkBatch( $missingTargets );
730  $conds[] = $lb->constructSet( 'wl', $db );
731  }
732 
733  return $db->makeList( $conds, LIST_OR );
734  }
735 
742  public function getWatchedItem( UserIdentity $user, $target ) {
743  if ( !$user->isRegistered() ) {
744  return false;
745  }
746 
747  $cached = $this->getCached( $user, $target );
748  if ( $cached && !$cached->isExpired() ) {
749  $this->stats->increment( 'WatchedItemStore.getWatchedItem.cached' );
750  return $cached;
751  }
752  $this->stats->increment( 'WatchedItemStore.getWatchedItem.load' );
753  return $this->loadWatchedItem( $user, $target );
754  }
755 
762  public function loadWatchedItem( UserIdentity $user, $target ) {
763  $item = $this->loadWatchedItemsBatch( $user, [ $target ] );
764  return $item ? $item[0] : false;
765  }
766 
773  public function loadWatchedItemsBatch( UserIdentity $user, array $targets ) {
774  // Only registered user can have a watchlist
775  if ( !$user->isRegistered() ) {
776  return false;
777  }
778 
779  $dbr = $this->getConnectionRef( DB_REPLICA );
780 
781  $rows = $this->fetchWatchedItems(
782  $dbr,
783  $user,
784  [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
785  [],
786  $targets
787  );
788 
789  if ( !$rows ) {
790  return false;
791  }
792 
793  $items = [];
794  foreach ( $rows as $row ) {
795  // TODO: convert to PageIdentity
796  $target = new TitleValue( (int)$row->wl_namespace, $row->wl_title );
797  $item = $this->getWatchedItemFromRow( $user, $target, $row );
798  $this->cache( $item );
799  $items[] = $item;
800  }
801 
802  return $items;
803  }
804 
815  public function getWatchedItemsForUser( UserIdentity $user, array $options = [] ): array {
816  $options += [ 'forWrite' => false ];
817  $vars = [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ];
818  $dbOptions = [];
819  $db = $this->getConnectionRef( $options['forWrite'] ? DB_PRIMARY : DB_REPLICA );
820  if ( array_key_exists( 'sort', $options ) ) {
821  Assert::parameter(
822  ( in_array( $options['sort'], [ self::SORT_ASC, self::SORT_DESC ] ) ),
823  '$options[\'sort\']',
824  'must be SORT_ASC or SORT_DESC'
825  );
826  $dbOptions['ORDER BY'][] = "wl_namespace {$options['sort']}";
827  if ( $this->expiryEnabled
828  && array_key_exists( 'sortByExpiry', $options )
829  && $options['sortByExpiry']
830  ) {
831  // Add `wl_has_expiry` column to allow sorting by watched titles that have an expiration date first.
832  $vars['wl_has_expiry'] = $db->conditional( 'we_expiry IS NULL', 0, 1 );
833  // Display temporarily watched titles first.
834  // Order by expiration date, with the titles that will expire soonest at the top.
835  $dbOptions['ORDER BY'][] = "wl_has_expiry DESC";
836  $dbOptions['ORDER BY'][] = "we_expiry ASC";
837  }
838 
839  $dbOptions['ORDER BY'][] = "wl_title {$options['sort']}";
840  }
841 
842  $res = $this->fetchWatchedItems(
843  $db,
844  $user,
845  $vars,
846  $dbOptions
847  );
848 
849  $watchedItems = [];
850  foreach ( $res as $row ) {
851  // TODO: convert to PageIdentity
852  $target = new TitleValue( (int)$row->wl_namespace, $row->wl_title );
853  // @todo: Should we add these to the process cache?
854  $watchedItems[] = $this->getWatchedItemFromRow( $user, $target, $row );
855  }
856 
857  return $watchedItems;
858  }
859 
867  private function getWatchedItemFromRow(
868  UserIdentity $user,
869  $target,
870  stdClass $row
871  ): WatchedItem {
872  return new WatchedItem(
873  $user,
874  $target,
876  $row->wl_notificationtimestamp, $user, $target ),
877  wfTimestampOrNull( TS_ISO_8601, $row->we_expiry ?? null )
878  );
879  }
880 
894  private function fetchWatchedItems(
895  IDatabase $db,
896  UserIdentity $user,
897  array $vars,
898  array $options = [],
899  $target = null
900  ) {
901  $dbMethod = 'select';
902  $conds = [ 'wl_user' => $user->getId() ];
903 
904  if ( $target ) {
905  if ( $target instanceof LinkTarget || $target instanceof PageIdentity ) {
906  $dbMethod = 'selectRow';
907  $conds = array_merge( $conds, [
908  'wl_namespace' => $target->getNamespace(),
909  'wl_title' => $target->getDBkey(),
910  ] );
911  } else {
912  $titleConds = [];
913  foreach ( $target as $linkTarget ) {
914  $titleConds[] = $db->makeList(
915  [
916  'wl_namespace' => $linkTarget->getNamespace(),
917  'wl_title' => $linkTarget->getDBkey(),
918  ],
920  );
921  }
922  $conds[] = $db->makeList( $titleConds, $db::LIST_OR );
923  }
924  }
925 
926  $tables = [ 'watchlist' ];
927  $joinConds = [];
928  $this->modifyForExpiry( $tables, $conds, $joinConds, $db );
929 
930  if ( $this->expiryEnabled ) {
931  $vars[] = 'we_expiry';
932  }
933 
934  return $db->{$dbMethod}(
935  $tables,
936  $vars,
937  $conds,
938  __METHOD__,
939  $options,
940  $joinConds
941  );
942  }
943 
950  public function isWatched( UserIdentity $user, $target ): bool {
951  return (bool)$this->getWatchedItem( $user, $target );
952  }
953 
961  public function isTempWatched( UserIdentity $user, $target ): bool {
962  $item = $this->getWatchedItem( $user, $target );
963  return $item && $item->getExpiry();
964  }
965 
973  public function getNotificationTimestampsBatch( UserIdentity $user, array $targets ): array {
974  $timestamps = [];
975  foreach ( $targets as $target ) {
976  $timestamps[$target->getNamespace()][$target->getDBkey()] = false;
977  }
978 
979  if ( !$user->isRegistered() ) {
980  return $timestamps;
981  }
982 
983  $targetsToLoad = [];
984  foreach ( $targets as $target ) {
985  $cachedItem = $this->getCached( $user, $target );
986  if ( $cachedItem ) {
987  $timestamps[$target->getNamespace()][$target->getDBkey()] =
988  $cachedItem->getNotificationTimestamp();
989  } else {
990  $targetsToLoad[] = $target;
991  }
992  }
993 
994  if ( !$targetsToLoad ) {
995  return $timestamps;
996  }
997 
998  $dbr = $this->getConnectionRef( DB_REPLICA );
999 
1000  $lb = $this->linkBatchFactory->newLinkBatch( $targetsToLoad );
1001  $res = $dbr->select(
1002  'watchlist',
1003  [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
1004  [
1005  $lb->constructSet( 'wl', $dbr ),
1006  'wl_user' => $user->getId(),
1007  ],
1008  __METHOD__
1009  );
1010 
1011  foreach ( $res as $row ) {
1012  // TODO: convert to PageIdentity
1013  $target = new TitleValue( (int)$row->wl_namespace, $row->wl_title );
1014  $timestamps[$row->wl_namespace][$row->wl_title] =
1015  $this->getLatestNotificationTimestamp(
1016  $row->wl_notificationtimestamp, $user, $target );
1017  }
1018 
1019  return $timestamps;
1020  }
1021 
1030  public function addWatch( UserIdentity $user, $target, ?string $expiry = null ) {
1031  $this->addWatchBatchForUser( $user, [ $target ], $expiry );
1032 
1033  if ( $this->expiryEnabled && !$expiry ) {
1034  // When re-watching a page with a null $expiry, any existing expiry is left unchanged.
1035  // However we must re-fetch the preexisting expiry or else the cached WatchedItem will
1036  // incorrectly have a null expiry. Note that loadWatchedItem() does the caching.
1037  // See T259379
1038  $this->loadWatchedItem( $user, $target );
1039  } else {
1040  // Create a new WatchedItem and add it to the process cache.
1041  // In this case we don't need to re-fetch the expiry.
1042  $expiry = ExpiryDef::normalizeUsingMaxExpiry( $expiry, $this->maxExpiryDuration, TS_ISO_8601 );
1043  $item = new WatchedItem(
1044  $user,
1045  $target,
1046  null,
1047  $expiry
1048  );
1049  $this->cache( $item );
1050  }
1051  }
1052 
1066  public function addWatchBatchForUser(
1067  UserIdentity $user,
1068  array $targets,
1069  ?string $expiry = null
1070  ): bool {
1071  if ( $this->readOnlyMode->isReadOnly() ) {
1072  return false;
1073  }
1074  // Only registered user can have a watchlist
1075  if ( !$user->isRegistered() ) {
1076  return false;
1077  }
1078 
1079  if ( !$targets ) {
1080  return true;
1081  }
1082  $expiry = ExpiryDef::normalizeUsingMaxExpiry( $expiry, $this->maxExpiryDuration, TS_ISO_8601 );
1083  $rows = [];
1084  foreach ( $targets as $target ) {
1085  $rows[] = [
1086  'wl_user' => $user->getId(),
1087  'wl_namespace' => $target->getNamespace(),
1088  'wl_title' => $target->getDBkey(),
1089  'wl_notificationtimestamp' => null,
1090  ];
1091  $this->uncache( $user, $target );
1092  }
1093 
1094  $dbw = $this->getConnectionRef( DB_PRIMARY );
1095  $ticket = count( $targets ) > $this->updateRowsPerQuery ?
1096  $this->lbFactory->getEmptyTransactionTicket( __METHOD__ ) : null;
1097  $affectedRows = 0;
1098  $rowBatches = array_chunk( $rows, $this->updateRowsPerQuery );
1099  foreach ( $rowBatches as $toInsert ) {
1100  // Use INSERT IGNORE to avoid overwriting the notification timestamp
1101  // if there's already an entry for this page
1102  $dbw->insert( 'watchlist', $toInsert, __METHOD__, [ 'IGNORE' ] );
1103  $affectedRows += $dbw->affectedRows();
1104 
1105  if ( $this->expiryEnabled ) {
1106  $affectedRows += $this->updateOrDeleteExpiries( $dbw, $user->getId(), $toInsert, $expiry );
1107  }
1108 
1109  if ( $ticket ) {
1110  $this->lbFactory->commitAndWaitForReplication( __METHOD__, $ticket );
1111  }
1112  }
1113 
1114  return (bool)$affectedRows;
1115  }
1116 
1126  private function updateOrDeleteExpiries(
1127  IDatabase $dbw,
1128  int $userId,
1129  array $rows,
1130  ?string $expiry = null
1131  ): int {
1132  if ( !$expiry ) {
1133  // if expiry is null (shouldn't change), 0 rows affected.
1134  return 0;
1135  }
1136 
1137  // Build the giant `(...) OR (...)` part to be used with WHERE.
1138  $conds = [];
1139  foreach ( $rows as $row ) {
1140  $conds[] = $dbw->makeList(
1141  [
1142  'wl_user' => $userId,
1143  'wl_namespace' => $row['wl_namespace'],
1144  'wl_title' => $row['wl_title']
1145  ],
1147  );
1148  }
1149  $cond = $dbw->makeList( $conds, $dbw::LIST_OR );
1150 
1151  if ( wfIsInfinity( $expiry ) ) {
1152  // Rows should be deleted rather than updated.
1153  $dbw->deleteJoin(
1154  'watchlist_expiry',
1155  'watchlist',
1156  'we_item',
1157  'wl_id',
1158  [ $cond ],
1159  __METHOD__
1160  );
1161 
1162  return $dbw->affectedRows();
1163  }
1164 
1165  return $this->updateExpiries( $dbw, $expiry, $cond );
1166  }
1167 
1175  private function updateExpiries( IDatabase $dbw, string $expiry, string $cond ): int {
1176  // First fetch the wl_ids from the watchlist table.
1177  // We'd prefer to do a INSERT/SELECT in the same query with IDatabase::insertSelect(),
1178  // but it doesn't allow us to use the "ON DUPLICATE KEY UPDATE" clause.
1179  $wlIds = $dbw->selectFieldValues( 'watchlist', 'wl_id', $cond, __METHOD__ );
1180 
1181  $expiry = $dbw->timestamp( $expiry );
1182 
1183  $weRows = array_map( static function ( $wlId ) use ( $expiry ) {
1184  return [
1185  'we_item' => $wlId,
1186  'we_expiry' => $expiry
1187  ];
1188  }, $wlIds );
1189 
1190  // Insert into watchlist_expiry, updating the expiry for duplicate rows.
1191  $dbw->upsert(
1192  'watchlist_expiry',
1193  $weRows,
1194  'we_item',
1195  [ 'we_expiry' => $expiry ],
1196  __METHOD__
1197  );
1198 
1199  return $dbw->affectedRows();
1200  }
1201 
1208  public function removeWatch( UserIdentity $user, $target ): bool {
1209  return $this->removeWatchBatchForUser( $user, [ $target ] );
1210  }
1211 
1230  UserIdentity $user,
1231  $timestamp,
1232  array $targets = []
1233  ): bool {
1234  // Only registered user can have a watchlist
1235  if ( !$user->isRegistered() || $this->readOnlyMode->isReadOnly() ) {
1236  return false;
1237  }
1238 
1239  if ( !$targets ) {
1240  // Backwards compatibility
1241  $this->resetAllNotificationTimestampsForUser( $user, $timestamp );
1242  return true;
1243  }
1244 
1245  $rows = $this->getTitleDbKeysGroupedByNamespace( $targets );
1246 
1247  $dbw = $this->getConnectionRef( DB_PRIMARY );
1248  if ( $timestamp !== null ) {
1249  $timestamp = $dbw->timestamp( $timestamp );
1250  }
1251  $ticket = $this->lbFactory->getEmptyTransactionTicket( __METHOD__ );
1252  $affectedSinceWait = 0;
1253 
1254  // Batch update items per namespace
1255  foreach ( $rows as $namespace => $namespaceTitles ) {
1256  $rowBatches = array_chunk( $namespaceTitles, $this->updateRowsPerQuery );
1257  foreach ( $rowBatches as $toUpdate ) {
1258  $dbw->update(
1259  'watchlist',
1260  [ 'wl_notificationtimestamp' => $timestamp ],
1261  [
1262  'wl_user' => $user->getId(),
1263  'wl_namespace' => $namespace,
1264  'wl_title' => $toUpdate
1265  ],
1266  __METHOD__
1267  );
1268  $affectedSinceWait += $dbw->affectedRows();
1269  // Wait for replication every time we've touched updateRowsPerQuery rows
1270  if ( $affectedSinceWait >= $this->updateRowsPerQuery ) {
1271  $this->lbFactory->commitAndWaitForReplication( __METHOD__, $ticket );
1272  $affectedSinceWait = 0;
1273  }
1274  }
1275  }
1276 
1277  $this->uncacheUser( $user );
1278 
1279  return true;
1280  }
1281 
1289  $timestamp,
1290  UserIdentity $user,
1291  $target
1292  ) {
1293  $timestamp = wfTimestampOrNull( TS_MW, $timestamp );
1294  if ( $timestamp === null ) {
1295  return null; // no notification
1296  }
1297 
1298  $seenTimestamps = $this->getPageSeenTimestamps( $user );
1299  if ( $seenTimestamps ) {
1300  $seenKey = $this->getPageSeenKey( $target );
1301  if ( isset( $seenTimestamps[$seenKey] ) && $seenTimestamps[$seenKey] >= $timestamp ) {
1302  // If a reset job did not yet run, then the "seen" timestamp will be higher
1303  return null;
1304  }
1305  }
1306 
1307  return $timestamp;
1308  }
1309 
1316  public function resetAllNotificationTimestampsForUser( UserIdentity $user, $timestamp = null ) {
1317  // Only registered user can have a watchlist
1318  if ( !$user->isRegistered() ) {
1319  return;
1320  }
1321 
1322  // If the page is watched by the user (or may be watched), update the timestamp
1324  'userId' => $user->getId(), 'timestamp' => $timestamp, 'casTime' => time()
1325  ] );
1326 
1327  // Try to run this post-send
1328  // Calls DeferredUpdates::addCallableUpdate in normal operation
1329  call_user_func(
1330  $this->deferredUpdatesAddCallableUpdateCallback,
1331  static function () use ( $job ) {
1332  $job->run();
1333  }
1334  );
1335  }
1336 
1345  UserIdentity $editor,
1346  $target,
1347  $timestamp
1348  ): array {
1349  $dbw = $this->getConnectionRef( DB_PRIMARY );
1350  $selectTables = [ 'watchlist' ];
1351  $selectConds = [
1352  'wl_user != ' . $editor->getId(),
1353  'wl_namespace' => $target->getNamespace(),
1354  'wl_title' => $target->getDBkey(),
1355  'wl_notificationtimestamp IS NULL',
1356  ];
1357  $selectJoin = [];
1358 
1359  $this->modifyForExpiry( $selectTables, $selectConds, $selectJoin, $dbw );
1360 
1361  $uids = $dbw->selectFieldValues(
1362  $selectTables,
1363  'wl_user',
1364  $selectConds,
1365  __METHOD__,
1366  [],
1367  $selectJoin
1368  );
1369 
1370  $watchers = array_map( 'intval', $uids );
1371  if ( $watchers ) {
1372  // Update wl_notificationtimestamp for all watching users except the editor
1373  $fname = __METHOD__;
1374 
1375  // Try to run this post-send
1376  // Calls DeferredUpdates::addCallableUpdate in normal operation
1377  call_user_func(
1378  $this->deferredUpdatesAddCallableUpdateCallback,
1379  function () use ( $timestamp, $watchers, $target, $fname ) {
1380  $dbw = $this->getConnectionRef( DB_PRIMARY );
1381  $ticket = $this->lbFactory->getEmptyTransactionTicket( $fname );
1382 
1383  $watchersChunks = array_chunk( $watchers, $this->updateRowsPerQuery );
1384  foreach ( $watchersChunks as $watchersChunk ) {
1385  $dbw->update( 'watchlist',
1386  [ /* SET */
1387  'wl_notificationtimestamp' => $dbw->timestamp( $timestamp )
1388  ], [ /* WHERE - TODO Use wl_id T130067 */
1389  'wl_user' => $watchersChunk,
1390  'wl_namespace' => $target->getNamespace(),
1391  'wl_title' => $target->getDBkey(),
1392  ], $fname
1393  );
1394  if ( count( $watchersChunks ) > 1 ) {
1395  $this->lbFactory->commitAndWaitForReplication(
1396  $fname, $ticket, [ 'domain' => $dbw->getDomainID() ]
1397  );
1398  }
1399  }
1400  $this->uncacheLinkTarget( $target );
1401  },
1402  DeferredUpdates::POSTSEND,
1403  $dbw
1404  );
1405  }
1406 
1407  return $watchers;
1408  }
1409 
1419  UserIdentity $user,
1420  $title,
1421  $force = '',
1422  $oldid = 0
1423  ): bool {
1424  $time = time();
1425 
1426  // Only registered user can have a watchlist
1427  if ( $this->readOnlyMode->isReadOnly() || !$user->isRegistered() ) {
1428  return false;
1429  }
1430 
1431  $item = null;
1432  if ( $force != 'force' ) {
1433  $item = $this->loadWatchedItem( $user, $title );
1434  if ( !$item || $item->getNotificationTimestamp() === null ) {
1435  return false;
1436  }
1437  }
1438 
1439  // Get the timestamp (TS_MW) of this revision to track the latest one seen
1440  $id = $oldid;
1441  $seenTime = null;
1442  if ( !$id ) {
1443  $latestRev = $this->revisionLookup->getRevisionByTitle( $title );
1444  if ( $latestRev ) {
1445  $id = $latestRev->getId();
1446  // Save a DB query
1447  $seenTime = $latestRev->getTimestamp();
1448  }
1449  }
1450  if ( $seenTime === null ) {
1451  $seenTime = $this->revisionLookup->getTimestampFromId( $id );
1452  }
1453 
1454  // Mark the item as read immediately in lightweight storage
1455  $this->stash->merge(
1456  $this->getPageSeenTimestampsKey( $user ),
1457  function ( $cache, $key, $current ) use ( $title, $seenTime ) {
1458  if ( !$current ) {
1459  $value = new MapCacheLRU( 300 );
1460  } elseif ( is_array( $current ) ) {
1461  $value = MapCacheLRU::newFromArray( $current, 300 );
1462  } else {
1463  // Backwards compatibility for T282105
1464  $value = $current;
1465  }
1466  $subKey = $this->getPageSeenKey( $title );
1467 
1468  if ( $seenTime > $value->get( $subKey ) ) {
1469  // Revision is newer than the last one seen
1470  $value->set( $subKey, $seenTime );
1471 
1472  $this->latestUpdateCache->set( $key, $value->toArray(), BagOStuff::TTL_PROC_LONG );
1473  } elseif ( $seenTime === false ) {
1474  // Revision does not exist
1475  $value->set( $subKey, wfTimestamp( TS_MW ) );
1476  $this->latestUpdateCache->set( $key,
1477  $value->toArray(),
1478  BagOStuff::TTL_PROC_LONG );
1479  } else {
1480  return false; // nothing to update
1481  }
1482 
1483  return $value->toArray();
1484  },
1485  BagOStuff::TTL_HOUR
1486  );
1487 
1488  // If the page is watched by the user (or may be watched), update the timestamp
1489  // ActivityUpdateJob accepts both LinkTarget and PageReference
1490  $job = new ActivityUpdateJob(
1491  $title,
1492  [
1493  'type' => 'updateWatchlistNotification',
1494  'userid' => $user->getId(),
1495  'notifTime' => $this->getNotificationTimestamp( $user, $title, $item, $force, $oldid ),
1496  'curTime' => $time
1497  ]
1498  );
1499  // Try to enqueue this post-send
1500  $this->queueGroup->lazyPush( $job );
1501 
1502  $this->uncache( $user, $title );
1503 
1504  return true;
1505  }
1506 
1511  private function getPageSeenTimestamps( UserIdentity $user ) {
1512  $key = $this->getPageSeenTimestampsKey( $user );
1513 
1514  $cache = $this->latestUpdateCache->getWithSetCallback(
1515  $key,
1516  BagOStuff::TTL_PROC_LONG,
1517  function () use ( $key ) {
1518  return $this->stash->get( $key ) ?: null;
1519  }
1520  );
1521  // Backwards compatibility for T282105
1522  if ( $cache instanceof MapCacheLRU ) {
1523  $cache = $cache->toArray();
1524  }
1525  return $cache;
1526  }
1527 
1532  private function getPageSeenTimestampsKey( UserIdentity $user ): string {
1533  return $this->stash->makeGlobalKey(
1534  'watchlist-recent-updates',
1535  $this->lbFactory->getLocalDomainID(),
1536  $user->getId()
1537  );
1538  }
1539 
1544  private function getPageSeenKey( $target ): string {
1545  return "{$target->getNamespace()}:{$target->getDBkey()}";
1546  }
1547 
1556  private function getNotificationTimestamp(
1557  UserIdentity $user,
1558  $title,
1559  $item,
1560  $force,
1561  $oldid
1562  ) {
1563  if ( !$oldid ) {
1564  // No oldid given, assuming latest revision; clear the timestamp.
1565  return null;
1566  }
1567 
1568  $oldRev = $this->revisionLookup->getRevisionById( $oldid );
1569  if ( !$oldRev ) {
1570  // Oldid given but does not exist (probably deleted)
1571  return false;
1572  }
1573 
1574  $nextRev = $this->revisionLookup->getNextRevision( $oldRev );
1575  if ( !$nextRev ) {
1576  // Oldid given and is the latest revision for this title; clear the timestamp.
1577  return null;
1578  }
1579 
1580  if ( $item === null ) {
1581  $item = $this->loadWatchedItem( $user, $title );
1582  }
1583 
1584  if ( !$item ) {
1585  // This can only happen if $force is enabled.
1586  return null;
1587  }
1588 
1589  // Oldid given and isn't the latest; update the timestamp.
1590  // This will result in no further notification emails being sent!
1591  $notificationTimestamp = $this->revisionLookup->getTimestampFromId( $oldid );
1592  // @FIXME: this should use getTimestamp() for consistency with updates on new edits
1593  // $notificationTimestamp = $nextRev->getTimestamp(); // first unseen revision timestamp
1594 
1595  // We need to go one second to the future because of various strict comparisons
1596  // throughout the codebase
1597  $ts = new MWTimestamp( $notificationTimestamp );
1598  $ts->timestamp->add( new DateInterval( 'PT1S' ) );
1599  $notificationTimestamp = $ts->getTimestamp( TS_MW );
1600 
1601  if ( $notificationTimestamp < $item->getNotificationTimestamp() ) {
1602  if ( $force != 'force' ) {
1603  return false;
1604  } else {
1605  // This is a little silly‚Ķ
1606  return $item->getNotificationTimestamp();
1607  }
1608  }
1609 
1610  return $notificationTimestamp;
1611  }
1612 
1619  public function countUnreadNotifications( UserIdentity $user, $unreadLimit = null ) {
1620  $dbr = $this->getConnectionRef( DB_REPLICA );
1621 
1622  $queryOptions = [];
1623  if ( $unreadLimit !== null ) {
1624  $unreadLimit = (int)$unreadLimit;
1625  $queryOptions['LIMIT'] = $unreadLimit;
1626  }
1627 
1628  $conds = [
1629  'wl_user' => $user->getId(),
1630  'wl_notificationtimestamp IS NOT NULL'
1631  ];
1632 
1633  $rowCount = $dbr->selectRowCount( 'watchlist', '1', $conds, __METHOD__, $queryOptions );
1634 
1635  if ( $unreadLimit === null ) {
1636  return $rowCount;
1637  }
1638 
1639  if ( $rowCount >= $unreadLimit ) {
1640  return true;
1641  }
1642 
1643  return $rowCount;
1644  }
1645 
1651  public function duplicateAllAssociatedEntries( $oldTarget, $newTarget ) {
1652  // Duplicate first the subject page, then the talk page
1653  // TODO: convert to PageIdentity
1654  $this->duplicateEntry(
1655  new TitleValue( $this->nsInfo->getSubject( $oldTarget->getNamespace() ), $oldTarget->getDBkey() ),
1656  new TitleValue( $this->nsInfo->getSubject( $newTarget->getNamespace() ), $newTarget->getDBkey() )
1657  );
1658  $this->duplicateEntry(
1659  new TitleValue( $this->nsInfo->getTalk( $oldTarget->getNamespace() ), $oldTarget->getDBkey() ),
1660  new TitleValue( $this->nsInfo->getTalk( $newTarget->getNamespace() ), $newTarget->getDBkey() )
1661  );
1662  }
1663 
1669  public function duplicateEntry( $oldTarget, $newTarget ) {
1670  $dbw = $this->getConnectionRef( DB_PRIMARY );
1671  $result = $this->fetchWatchedItemsForPage( $dbw, $oldTarget );
1672  $newNamespace = $newTarget->getNamespace();
1673  $newDBkey = $newTarget->getDBkey();
1674 
1675  # Construct array to replace into the watchlist
1676  $values = [];
1677  $expiries = [];
1678  foreach ( $result as $row ) {
1679  $values[] = [
1680  'wl_user' => $row->wl_user,
1681  'wl_namespace' => $newNamespace,
1682  'wl_title' => $newDBkey,
1683  'wl_notificationtimestamp' => $row->wl_notificationtimestamp,
1684  ];
1685 
1686  if ( $this->expiryEnabled && $row->we_expiry ) {
1687  $expiries[$row->wl_user] = $row->we_expiry;
1688  }
1689  }
1690 
1691  if ( empty( $values ) ) {
1692  return;
1693  }
1694 
1695  // Perform a replace on the watchlist table rows.
1696  // Note that multi-row replace is very efficient for MySQL but may be inefficient for
1697  // some other DBMSes, mostly due to poor simulation by us.
1698  $dbw->replace(
1699  'watchlist',
1700  [ [ 'wl_user', 'wl_namespace', 'wl_title' ] ],
1701  $values,
1702  __METHOD__
1703  );
1704 
1705  if ( $this->expiryEnabled ) {
1706  $this->updateExpiriesAfterMove( $dbw, $expiries, $newNamespace, $newDBkey );
1707  }
1708  }
1709 
1715  private function fetchWatchedItemsForPage(
1716  IDatabase $dbw,
1717  $target
1718  ): IResultWrapper {
1719  $tables = [ 'watchlist' ];
1720  $fields = [ 'wl_user', 'wl_notificationtimestamp' ];
1721  $joins = [];
1722 
1723  if ( $this->expiryEnabled ) {
1724  $tables[] = 'watchlist_expiry';
1725  $fields[] = 'we_expiry';
1726  $joins['watchlist_expiry'] = [ 'LEFT JOIN', [ 'wl_id = we_item' ] ];
1727  }
1728 
1729  return $dbw->select(
1730  $tables,
1731  $fields,
1732  [
1733  'wl_namespace' => $target->getNamespace(),
1734  'wl_title' => $target->getDBkey(),
1735  ],
1736  __METHOD__,
1737  [ 'FOR UPDATE' ],
1738  $joins
1739  );
1740  }
1741 
1748  private function updateExpiriesAfterMove(
1749  IDatabase $dbw,
1750  array $expiries,
1751  int $namespace,
1752  string $dbKey
1753  ): void {
1754  $method = __METHOD__;
1756  function () use ( $dbw, $expiries, $namespace, $dbKey, $method ) {
1757  // First fetch new wl_ids.
1758  $res = $dbw->select(
1759  'watchlist',
1760  [ 'wl_user', 'wl_id' ],
1761  [
1762  'wl_namespace' => $namespace,
1763  'wl_title' => $dbKey,
1764  ],
1765  $method
1766  );
1767 
1768  // Build new array to INSERT into multiple rows at once.
1769  $expiryData = [];
1770  foreach ( $res as $row ) {
1771  if ( !empty( $expiries[$row->wl_user] ) ) {
1772  $expiryData[] = [
1773  'we_item' => $row->wl_id,
1774  'we_expiry' => $expiries[$row->wl_user],
1775  ];
1776  }
1777  }
1778 
1779  // Batch the insertions.
1780  $batches = array_chunk( $expiryData, $this->updateRowsPerQuery );
1781  foreach ( $batches as $toInsert ) {
1782  $dbw->replace(
1783  'watchlist_expiry',
1784  'we_item',
1785  $toInsert,
1786  $method
1787  );
1788  }
1789  },
1790  DeferredUpdates::POSTSEND,
1791  $dbw
1792  );
1793  }
1794 
1799  private function getTitleDbKeysGroupedByNamespace( array $titles ) {
1800  $rows = [];
1801  foreach ( $titles as $title ) {
1802  // Group titles by namespace.
1803  $rows[ $title->getNamespace() ][] = $title->getDBkey();
1804  }
1805  return $rows;
1806  }
1807 
1812  private function uncacheTitlesForUser( UserIdentity $user, array $titles ) {
1813  foreach ( $titles as $title ) {
1814  $this->uncache( $user, $title );
1815  }
1816  }
1817 
1821  public function countExpired(): int {
1822  $dbr = $this->getConnectionRef( DB_REPLICA );
1823  return $dbr->selectRowCount(
1824  'watchlist_expiry',
1825  '*',
1826  [ 'we_expiry <= ' . $dbr->addQuotes( $dbr->timestamp() ) ],
1827  __METHOD__
1828  );
1829  }
1830 
1834  public function removeExpired( int $limit, bool $deleteOrphans = false ): void {
1835  $dbr = $this->getConnectionRef( DB_REPLICA );
1836  $dbw = $this->getConnectionRef( DB_PRIMARY );
1837  $ticket = $this->lbFactory->getEmptyTransactionTicket( __METHOD__ );
1838 
1839  // Get a batch of watchlist IDs to delete.
1840  $toDelete = $dbr->selectFieldValues(
1841  'watchlist_expiry',
1842  'we_item',
1843  [ 'we_expiry <= ' . $dbr->addQuotes( $dbr->timestamp() ) ],
1844  __METHOD__,
1845  [ 'LIMIT' => $limit ]
1846  );
1847  if ( count( $toDelete ) > 0 ) {
1848  // Delete them from the watchlist and watchlist_expiry table.
1849  $dbw->delete(
1850  'watchlist',
1851  [ 'wl_id' => $toDelete ],
1852  __METHOD__
1853  );
1854  $dbw->delete(
1855  'watchlist_expiry',
1856  [ 'we_item' => $toDelete ],
1857  __METHOD__
1858  );
1859  }
1860 
1861  // Also delete any orphaned or null-expiry watchlist_expiry rows
1862  // (they should not exist, but might because not everywhere knows about the expiry table yet).
1863  if ( $deleteOrphans ) {
1864  $expiryToDelete = $dbr->selectFieldValues(
1865  [ 'watchlist_expiry', 'watchlist' ],
1866  'we_item',
1867  $dbr->makeList(
1868  [ 'wl_id' => null, 'we_expiry' => null ],
1870  ),
1871  __METHOD__,
1872  [],
1873  [ 'watchlist' => [ 'LEFT JOIN', 'wl_id = we_item' ] ]
1874  );
1875  if ( count( $expiryToDelete ) > 0 ) {
1876  $dbw->delete(
1877  'watchlist_expiry',
1878  [ 'we_item' => $expiryToDelete ],
1879  __METHOD__
1880  );
1881  }
1882  }
1883 
1884  $this->lbFactory->commitAndWaitForReplication( __METHOD__, $ticket );
1885  }
1886 }
LIST_OR
const LIST_OR
Definition: Defines.php:46
Page\PageIdentity
Interface for objects (potentially) representing an editable wiki page.
Definition: PageIdentity.php:64
MWTimestamp
Library for creating and parsing MW-style timestamps.
Definition: MWTimestamp.php:38
WatchedItemStore\countWatchers
countWatchers( $target)
Definition: WatchedItemStore.php:469
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:80
Wikimedia\Rdbms\IDatabase\affectedRows
affectedRows()
Get the number of rows affected by the last write query.
WatchedItemStore\uncacheAllItemsForUser
uncacheAllItemsForUser(UserIdentity $user)
Definition: WatchedItemStore.php:375
ActivityUpdateJob
Job for updating user activity like "last viewed" timestamps.
Definition: ActivityUpdateJob.php:37
HashBagOStuff
Simple store for keeping values in an associative array for the current process.
Definition: HashBagOStuff.php:32
WatchedItemStore\maybeEnqueueWatchlistExpiryJob
maybeEnqueueWatchlistExpiryJob()
Probabilistically add a job to purge the expired watchlist items, if watchlist expiration is enabled,...
Definition: WatchedItemStore.php:414
WatchedItemStore\getNotificationTimestamp
getNotificationTimestamp(UserIdentity $user, $title, $item, $force, $oldid)
Definition: WatchedItemStore.php:1556
WatchedItemStore\getPageSeenTimestamps
getPageSeenTimestamps(UserIdentity $user)
Definition: WatchedItemStore.php:1511
WatchedItemStore\removeWatch
removeWatch(UserIdentity $user, $target)
Definition: WatchedItemStore.php:1208
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:319
WatchedItemStore\uncacheLinkTarget
uncacheLinkTarget( $target)
Definition: WatchedItemStore.php:238
WatchedItemStore\getVisitingWatchersCondition
getVisitingWatchersCondition(IDatabase $db, array $targetsWithVisitThresholds)
Generates condition for the query used in a batch count visiting watchers.
Definition: WatchedItemStore.php:699
wfTimestamp
wfTimestamp( $outputtype=TS_UNIX, $ts=0)
Get a timestamp string in one of various formats.
Definition: GlobalFunctions.php:1665
WatchedItemStore\getNotificationTimestampsBatch
getNotificationTimestampsBatch(UserIdentity $user, array $targets)
Definition: WatchedItemStore.php:973
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:95
WatchedItemStore\duplicateAllAssociatedEntries
duplicateAllAssociatedEntries( $oldTarget, $newTarget)
Definition: WatchedItemStore.php:1651
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:446
BagOStuff
Class representing a cache/ephemeral data store.
Definition: BagOStuff.php:86
MediaWiki\User\UserIdentity\getId
getId( $wikiId=self::LOCAL)
WatchedItemStore\modifyForExpiry
modifyForExpiry(array &$tables, array &$conds, array &$joinConds, IDatabase $db)
Helper method to deduplicate logic around queries that need to be modified if watchlist expiration is...
Definition: WatchedItemStore.php:296
WatchedItemStore\$loadBalancer
LoadBalancer $loadBalancer
Definition: WatchedItemStore.php:46
Wikimedia\Rdbms\ILBFactory\getMainLB
getMainLB( $domain=false)
Get the tracked load balancer instance for a main cluster.
WatchedItemStore\fetchWatchedItems
fetchWatchedItems(IDatabase $db, UserIdentity $user, array $vars, array $options=[], $target=null)
Fetches either a single or all watched items for the given user, or a specific set of items.
Definition: WatchedItemStore.php:894
$res
$res
Definition: testCompression.php:57
WatchedItemStore\getCached
getCached(UserIdentity $user, $target)
Definition: WatchedItemStore.php:274
WatchedItemStore\countExpired
countExpired()
Get the number of watchlist items that expire before the current time.1.35int
Definition: WatchedItemStore.php:1821
MediaWiki\User\UserIdentity
Interface for objects representing user identity.
Definition: UserIdentity.php:39
WatchedItemStore\$cache
HashBagOStuff $cache
Definition: WatchedItemStore.php:66
MediaWiki\Revision\RevisionLookup
Service for looking up page revisions.
Definition: RevisionLookup.php:38
WatchedItemStore\loadWatchedItemsBatch
loadWatchedItemsBatch(UserIdentity $user, array $targets)
Definition: WatchedItemStore.php:773
WatchedItemStore\getLatestNotificationTimestamp
getLatestNotificationTimestamp( $timestamp, UserIdentity $user, $target)
Definition: WatchedItemStore.php:1288
WatchedItemStore\CONSTRUCTOR_OPTIONS
const CONSTRUCTOR_OPTIONS
Definition: WatchedItemStore.php:31
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:41
Wikimedia\ParamValidator\TypeDef\ExpiryDef
Type definition for expiry timestamps.
Definition: ExpiryDef.php:17
WatchedItemStore\$readOnlyMode
ReadOnlyMode $readOnlyMode
Definition: WatchedItemStore.php:61
WatchedItemStore\__construct
__construct(ServiceOptions $options, ILBFactory $lbFactory, JobQueueGroup $queueGroup, BagOStuff $stash, HashBagOStuff $cache, ReadOnlyMode $readOnlyMode, NamespaceInfo $nsInfo, RevisionLookup $revisionLookup, LinkBatchFactory $linkBatchFactory)
Definition: WatchedItemStore.php:136
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:110
WatchedItemStore\overrideDeferredUpdatesAddCallableUpdateCallback
overrideDeferredUpdatesAddCallableUpdateCallback(callable $callback)
Overrides the DeferredUpdates::addCallableUpdate callback This is intended for use while testing and ...
Definition: WatchedItemStore.php:187
MWException
MediaWiki exception.
Definition: MWException.php:29
WatchedItemStore\getPageSeenTimestampsKey
getPageSeenTimestampsKey(UserIdentity $user)
Definition: WatchedItemStore.php:1532
MediaWiki\Config\ServiceOptions
A class for passing options to services.
Definition: ServiceOptions.php:27
Wikimedia\Rdbms\IDatabase\deleteJoin
deleteJoin( $delTable, $joinTable, $delVar, $joinVar, $conds, $fname=__METHOD__)
DELETE where the condition is a join.
MediaWiki\User\UserIdentity\isRegistered
isRegistered()
Wikimedia\Rdbms\IResultWrapper
Result wrapper for grabbing data queried from an IDatabase object.
Definition: IResultWrapper.php:26
MapCacheLRU\newFromArray
static newFromArray(array $values, $maxKeys)
Definition: MapCacheLRU.php:77
WatchedItemStore\$queueGroup
JobQueueGroup $queueGroup
Definition: WatchedItemStore.php:51
MediaWiki\Cache\LinkBatchFactory
Definition: LinkBatchFactory.php:39
wfTimestampOrNull
wfTimestampOrNull( $outputtype=TS_UNIX, $ts=null)
Return a formatted timestamp, or null if input is null.
Definition: GlobalFunctions.php:1681
MapCacheLRU
Handles a simple LRU key/value map with a maximum number of entries.
Definition: MapCacheLRU.php:36
$title
$title
Definition: testCompression.php:38
WatchedItemStore\$maxExpiryDuration
string null $maxExpiryDuration
Maximum configured relative expiry.
Definition: WatchedItemStore.php:120
DB_REPLICA
const DB_REPLICA
Definition: defines.php:25
WatchedItemStore\getMaxId
getMaxId()
Definition: WatchedItemStore.php:431
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:1126
WatchedItem\getUserIdentity
getUserIdentity()
Definition: WatchedItem.php:110
WatchedItemStore\removeExpired
removeExpired(int $limit, bool $deleteOrphans=false)
Remove some number of expired watchlist items.1.35The number of items to remove. Whether to also dele...
Definition: WatchedItemStore.php:1834
WatchedItemStore\getConnectionRef
getConnectionRef( $dbIndex)
Definition: WatchedItemStore.php:283
WatchedItemStore\$deferredUpdatesAddCallableUpdateCallback
callable null $deferredUpdatesAddCallableUpdateCallback
Definition: WatchedItemStore.php:85
WatchedItemStore\resetNotificationTimestamp
resetNotificationTimestamp(UserIdentity $user, $title, $force='', $oldid=0)
Definition: WatchedItemStore.php:1418
Wikimedia\Rdbms\LoadBalancer
Database connection, tracking, load balancing, and transaction manager for a cluster.
Definition: LoadBalancer.php:43
WatchedItemStore\getCacheKey
getCacheKey(UserIdentity $user, $target)
Definition: WatchedItemStore.php:205
WatchedItemStore\$revisionLookup
RevisionLookup $revisionLookup
Definition: WatchedItemStore.php:100
WatchedItemStore\countUnreadNotifications
countUnreadNotifications(UserIdentity $user, $unreadLimit=null)
Definition: WatchedItemStore.php:1619
StatsdAwareInterface
Describes a Statsd aware interface.
Definition: StatsdAwareInterface.php:13
WatchedItemStore\mustClearWatchedItemsUsingJobQueue
mustClearWatchedItemsUsingJobQueue(UserIdentity $user)
Definition: WatchedItemStore.php:368
DB_PRIMARY
const DB_PRIMARY
Definition: defines.php:27
WatchedItemStore\loadWatchedItem
loadWatchedItem(UserIdentity $user, $target)
Definition: WatchedItemStore.php:762
WatchedItemStore\addWatch
addWatch(UserIdentity $user, $target, ?string $expiry=null)
Definition: WatchedItemStore.php:1030
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:1316
wfIsInfinity
wfIsInfinity( $str)
Determine input string is represents as infinity.
Definition: GlobalFunctions.php:2522
WatchedItem
Representation of a pair of user and title for watchlist entries.
Definition: WatchedItem.php:36
WatchedItem\getTarget
getTarget()
Definition: WatchedItem.php:129
WatchedItemStore\getWatchedItemFromRow
getWatchedItemFromRow(UserIdentity $user, $target, stdClass $row)
Construct a new WatchedItem given a row from watchlist/watchlist_expiry.
Definition: WatchedItemStore.php:867
WatchedItemStore\setNotificationTimestampsForUser
setNotificationTimestampsForUser(UserIdentity $user, $timestamp, array $targets=[])
Set the "last viewed" timestamps for certain titles on a user's watchlist.
Definition: WatchedItemStore.php:1229
WatchedItemStore\$latestUpdateCache
HashBagOStuff $latestUpdateCache
Definition: WatchedItemStore.php:71
WatchedItemStore\uncacheTitlesForUser
uncacheTitlesForUser(UserIdentity $user, array $titles)
Definition: WatchedItemStore.php:1812
WatchedItemStore
Storage layer class for WatchedItems.
Definition: WatchedItemStore.php:26
WatchedItemStore\getPageSeenKey
getPageSeenKey( $target)
Definition: WatchedItemStore.php:1544
WatchlistExpiryJob
Definition: WatchlistExpiryJob.php:5
WatchedItemStore\isTempWatched
isTempWatched(UserIdentity $user, $target)
Check if the user is temporarily watching the page.
Definition: WatchedItemStore.php:961
$cache
$cache
Definition: mcc.php:33
WatchedItemStore\isWatched
isWatched(UserIdentity $user, $target)
Definition: WatchedItemStore.php:950
WatchedItemStore\updateExpiriesAfterMove
updateExpiriesAfterMove(IDatabase $dbw, array $expiries, int $namespace, string $dbKey)
Definition: WatchedItemStore.php:1748
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:105
WatchedItemStore\$stash
BagOStuff $stash
Definition: WatchedItemStore.php:56
$job
if(count( $args)< 1) $job
Definition: recompressTracked.php:49
WatchedItemStore\updateExpiries
updateExpiries(IDatabase $dbw, string $expiry, string $cond)
Update the expiries for items found with the given $cond.
Definition: WatchedItemStore.php:1175
WatchedItemStore\duplicateEntry
duplicateEntry( $oldTarget, $newTarget)
Definition: WatchedItemStore.php:1669
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:646
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:115
WatchedItemStore\fetchWatchedItemsForPage
fetchWatchedItemsForPage(IDatabase $dbw, $target)
Definition: WatchedItemStore.php:1715
WatchedItemStore\setStatsdDataFactory
setStatsdDataFactory(StatsdDataFactoryInterface $stats)
Definition: WatchedItemStore.php:172
WatchedItemStore\uncacheUser
uncacheUser(UserIdentity $user)
Definition: WatchedItemStore.php:252
WatchedItemStore\countVisitingWatchers
countVisitingWatchers( $target, $threshold)
Definition: WatchedItemStore.php:496
MediaWiki\Config\ServiceOptions\get
get( $key)
Definition: ServiceOptions.php:93
NamespaceInfo
This is a utility class for dealing with namespaces that encodes all the "magic" behaviors of them ba...
Definition: NamespaceInfo.php:35
WatchedItemStore\getWatchedItemsForUser
getWatchedItemsForUser(UserIdentity $user, array $options=[])
Definition: WatchedItemStore.php:815
WatchedItemStore\addWatchBatchForUser
addWatchBatchForUser(UserIdentity $user, array $targets, ?string $expiry=null)
Add multiple items to the user's watchlist.
Definition: WatchedItemStore.php:1066
WatchedItemStore\getWatchedItem
getWatchedItem(UserIdentity $user, $target)
Definition: WatchedItemStore.php:742
WatchedItemStore\updateNotificationTimestamp
updateNotificationTimestamp(UserIdentity $editor, $target, $timestamp)
Definition: WatchedItemStore.php:1344
WatchedItemStore\$watchlistPurgeRate
float $watchlistPurgeRate
corresponds to $wgWatchlistPurgeRate value
Definition: WatchedItemStore.php:123
WatchedItemStore\uncache
uncache(UserIdentity $user, $target)
Definition: WatchedItemStore.php:229
MediaWiki\Linker\LinkTarget
Definition: LinkTarget.php:26
WatchedItemStoreInterface
Definition: WatchedItemStoreInterface.php:31
DeferredUpdates\addCallableUpdate
static addCallableUpdate( $callable, $stage=self::POSTSEND, $dbw=null)
Add an update to the pending update queue that invokes the specified callback when run.
Definition: DeferredUpdates.php:145
WatchedItemStore\getTitleDbKeysGroupedByNamespace
getTitleDbKeysGroupedByNamespace(array $titles)
Definition: WatchedItemStore.php:1799
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:90
WatchedItemStore\countWatchersMultiple
countWatchersMultiple(array $targets, array $options=[])
Definition: WatchedItemStore.php:595
MediaWiki\Config\ServiceOptions\assertRequiredOptions
assertRequiredOptions(array $expectedKeys)
Assert that the list of options provided in this instance exactly match $expectedKeys,...
Definition: ServiceOptions.php:71
Wikimedia\Rdbms\ILBFactory
An interface for generating database load balancers.
Definition: ILBFactory.php:33
WatchedItemStore\removeWatchBatchForUser
removeWatchBatchForUser(UserIdentity $user, array $titles)
Definition: WatchedItemStore.php:525
JobQueueGroup
Class to handle enqueueing of background jobs.
Definition: JobQueueGroup.php:32
WatchedItemStore\clearUserWatchedItemsUsingJobQueue
clearUserWatchedItemsUsingJobQueue(UserIdentity $user)
Queues a job that will clear the users watchlist using the Job Queue.
Definition: WatchedItemStore.php:406
WatchedItemStore\cache
cache(WatchedItem $item)
Definition: WatchedItemStore.php:216
TitleValue
Represents a page (or page fragment) title within MediaWiki.
Definition: TitleValue.php:40