MediaWiki  1.34.0
WatchedItemStore.php
Go to the documentation of this file.
1 <?php
2 
3 use Liuggio\StatsdClient\Factory\StatsdDataFactoryInterface;
7 use Wikimedia\Assert\Assert;
11 use Wikimedia\ScopedCallback;
12 
22 
26  private $lbFactory;
27 
31  private $loadBalancer;
32 
36  private $queueGroup;
37 
41  private $stash;
42 
46  private $readOnlyMode;
47 
51  private $cache;
52 
57 
64  private $cacheIndex = [];
65 
70 
75 
79  private $nsInfo;
80 
84  private $revisionLookup;
85 
89  private $stats;
90 
101  public function __construct(
110  ) {
111  $this->lbFactory = $lbFactory;
112  $this->loadBalancer = $lbFactory->getMainLB();
113  $this->queueGroup = $queueGroup;
114  $this->stash = $stash;
115  $this->cache = $cache;
116  $this->readOnlyMode = $readOnlyMode;
117  $this->stats = new NullStatsdDataFactory();
118  $this->deferredUpdatesAddCallableUpdateCallback =
119  [ DeferredUpdates::class, 'addCallableUpdate' ];
120  $this->updateRowsPerQuery = $updateRowsPerQuery;
121  $this->nsInfo = $nsInfo;
122  $this->revisionLookup = $revisionLookup;
123 
124  $this->latestUpdateCache = new HashBagOStuff( [ 'maxKeys' => 3 ] );
125  }
126 
130  public function setStatsdDataFactory( StatsdDataFactoryInterface $stats ) {
131  $this->stats = $stats;
132  }
133 
145  public function overrideDeferredUpdatesAddCallableUpdateCallback( callable $callback ) {
146  if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
147  throw new MWException(
148  'Cannot override DeferredUpdates::addCallableUpdate callback in operation.'
149  );
150  }
152  $this->deferredUpdatesAddCallableUpdateCallback = $callback;
153  return new ScopedCallback( function () use ( $previousValue ) {
154  $this->deferredUpdatesAddCallableUpdateCallback = $previousValue;
155  } );
156  }
157 
158  private function getCacheKey( UserIdentity $user, LinkTarget $target ) {
159  return $this->cache->makeKey(
160  (string)$target->getNamespace(),
161  $target->getDBkey(),
162  (string)$user->getId()
163  );
164  }
165 
166  private function cache( WatchedItem $item ) {
167  $user = $item->getUserIdentity();
168  $target = $item->getLinkTarget();
169  $key = $this->getCacheKey( $user, $target );
170  $this->cache->set( $key, $item );
171  $this->cacheIndex[$target->getNamespace()][$target->getDBkey()][$user->getId()] = $key;
172  $this->stats->increment( 'WatchedItemStore.cache' );
173  }
174 
175  private function uncache( UserIdentity $user, LinkTarget $target ) {
176  $this->cache->delete( $this->getCacheKey( $user, $target ) );
177  unset( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()][$user->getId()] );
178  $this->stats->increment( 'WatchedItemStore.uncache' );
179  }
180 
181  private function uncacheLinkTarget( LinkTarget $target ) {
182  $this->stats->increment( 'WatchedItemStore.uncacheLinkTarget' );
183  if ( !isset( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()] ) ) {
184  return;
185  }
186  foreach ( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()] as $key ) {
187  $this->stats->increment( 'WatchedItemStore.uncacheLinkTarget.items' );
188  $this->cache->delete( $key );
189  }
190  }
191 
192  private function uncacheUser( UserIdentity $user ) {
193  $this->stats->increment( 'WatchedItemStore.uncacheUser' );
194  foreach ( $this->cacheIndex as $ns => $dbKeyArray ) {
195  foreach ( $dbKeyArray as $dbKey => $userArray ) {
196  if ( isset( $userArray[$user->getId()] ) ) {
197  $this->stats->increment( 'WatchedItemStore.uncacheUser.items' );
198  $this->cache->delete( $userArray[$user->getId()] );
199  }
200  }
201  }
202 
203  $pageSeenKey = $this->getPageSeenTimestampsKey( $user );
204  $this->latestUpdateCache->delete( $pageSeenKey );
205  $this->stash->delete( $pageSeenKey );
206  }
207 
214  private function getCached( UserIdentity $user, LinkTarget $target ) {
215  return $this->cache->get( $this->getCacheKey( $user, $target ) );
216  }
217 
227  private function dbCond( UserIdentity $user, LinkTarget $target ) {
228  return [
229  'wl_user' => $user->getId(),
230  'wl_namespace' => $target->getNamespace(),
231  'wl_title' => $target->getDBkey(),
232  ];
233  }
234 
240  private function getConnectionRef( $dbIndex ) {
241  return $this->loadBalancer->getConnectionRef( $dbIndex, [ 'watchlist' ] );
242  }
243 
254  public function clearUserWatchedItems( UserIdentity $user ) {
255  if ( $this->countWatchedItems( $user ) > $this->updateRowsPerQuery ) {
256  return false;
257  }
258 
259  $dbw = $this->loadBalancer->getConnectionRef( DB_MASTER );
260  $dbw->delete(
261  'watchlist',
262  [ 'wl_user' => $user->getId() ],
263  __METHOD__
264  );
265  $this->uncacheAllItemsForUser( $user );
266 
267  return true;
268  }
269 
270  private function uncacheAllItemsForUser( UserIdentity $user ) {
271  $userId = $user->getId();
272  foreach ( $this->cacheIndex as $ns => $dbKeyIndex ) {
273  foreach ( $dbKeyIndex as $dbKey => $userIndex ) {
274  if ( array_key_exists( $userId, $userIndex ) ) {
275  $this->cache->delete( $userIndex[$userId] );
276  unset( $this->cacheIndex[$ns][$dbKey][$userId] );
277  }
278  }
279  }
280 
281  // Cleanup empty cache keys
282  foreach ( $this->cacheIndex as $ns => $dbKeyIndex ) {
283  foreach ( $dbKeyIndex as $dbKey => $userIndex ) {
284  if ( empty( $this->cacheIndex[$ns][$dbKey] ) ) {
285  unset( $this->cacheIndex[$ns][$dbKey] );
286  }
287  }
288  if ( empty( $this->cacheIndex[$ns] ) ) {
289  unset( $this->cacheIndex[$ns] );
290  }
291  }
292  }
293 
302  $job = ClearUserWatchlistJob::newForUser( $user, $this->getMaxId() );
303  $this->queueGroup->push( $job );
304  }
305 
310  public function getMaxId() {
311  $dbr = $this->getConnectionRef( DB_REPLICA );
312  return (int)$dbr->selectField(
313  'watchlist',
314  'MAX(wl_id)',
315  '',
316  __METHOD__
317  );
318  }
319 
325  public function countWatchedItems( UserIdentity $user ) {
326  $dbr = $this->getConnectionRef( DB_REPLICA );
327  $return = (int)$dbr->selectField(
328  'watchlist',
329  'COUNT(*)',
330  [
331  'wl_user' => $user->getId()
332  ],
333  __METHOD__
334  );
335 
336  return $return;
337  }
338 
344  public function countWatchers( LinkTarget $target ) {
345  $dbr = $this->getConnectionRef( DB_REPLICA );
346  $return = (int)$dbr->selectField(
347  'watchlist',
348  'COUNT(*)',
349  [
350  'wl_namespace' => $target->getNamespace(),
351  'wl_title' => $target->getDBkey(),
352  ],
353  __METHOD__
354  );
355 
356  return $return;
357  }
358 
365  public function countVisitingWatchers( LinkTarget $target, $threshold ) {
366  $dbr = $this->getConnectionRef( DB_REPLICA );
367  $visitingWatchers = (int)$dbr->selectField(
368  'watchlist',
369  'COUNT(*)',
370  [
371  'wl_namespace' => $target->getNamespace(),
372  'wl_title' => $target->getDBkey(),
373  'wl_notificationtimestamp >= ' .
374  $dbr->addQuotes( $dbr->timestamp( $threshold ) ) .
375  ' OR wl_notificationtimestamp IS NULL'
376  ],
377  __METHOD__
378  );
379 
380  return $visitingWatchers;
381  }
382 
388  public function removeWatchBatchForUser( UserIdentity $user, array $titles ) {
389  if ( $this->readOnlyMode->isReadOnly() ) {
390  return false;
391  }
392  if ( !$user->isRegistered() ) {
393  return false;
394  }
395  if ( !$titles ) {
396  return true;
397  }
398 
399  $rows = $this->getTitleDbKeysGroupedByNamespace( $titles );
400  $this->uncacheTitlesForUser( $user, $titles );
401 
402  $dbw = $this->getConnectionRef( DB_MASTER );
403  $ticket = count( $titles ) > $this->updateRowsPerQuery ?
404  $this->lbFactory->getEmptyTransactionTicket( __METHOD__ ) : null;
405  $affectedRows = 0;
406 
407  // Batch delete items per namespace.
408  foreach ( $rows as $namespace => $namespaceTitles ) {
409  $rowBatches = array_chunk( $namespaceTitles, $this->updateRowsPerQuery );
410  foreach ( $rowBatches as $toDelete ) {
411  $dbw->delete( 'watchlist', [
412  'wl_user' => $user->getId(),
413  'wl_namespace' => $namespace,
414  'wl_title' => $toDelete
415  ], __METHOD__ );
416  $affectedRows += $dbw->affectedRows();
417  if ( $ticket ) {
418  $this->lbFactory->commitAndWaitForReplication( __METHOD__, $ticket );
419  }
420  }
421  }
422 
423  return (bool)$affectedRows;
424  }
425 
432  public function countWatchersMultiple( array $targets, array $options = [] ) {
433  $dbOptions = [ 'GROUP BY' => [ 'wl_namespace', 'wl_title' ] ];
434 
435  $dbr = $this->getConnectionRef( DB_REPLICA );
436 
437  if ( array_key_exists( 'minimumWatchers', $options ) ) {
438  $dbOptions['HAVING'] = 'COUNT(*) >= ' . (int)$options['minimumWatchers'];
439  }
440 
441  $lb = new LinkBatch( $targets );
442  $res = $dbr->select(
443  'watchlist',
444  [ 'wl_title', 'wl_namespace', 'watchers' => 'COUNT(*)' ],
445  [ $lb->constructSet( 'wl', $dbr ) ],
446  __METHOD__,
447  $dbOptions
448  );
449 
450  $watchCounts = [];
451  foreach ( $targets as $linkTarget ) {
452  $watchCounts[$linkTarget->getNamespace()][$linkTarget->getDBkey()] = 0;
453  }
454 
455  foreach ( $res as $row ) {
456  $watchCounts[$row->wl_namespace][$row->wl_title] = (int)$row->watchers;
457  }
458 
459  return $watchCounts;
460  }
461 
469  array $targetsWithVisitThresholds,
470  $minimumWatchers = null
471  ) {
472  if ( $targetsWithVisitThresholds === [] ) {
473  // No titles requested => no results returned
474  return [];
475  }
476 
477  $dbr = $this->getConnectionRef( DB_REPLICA );
478 
479  $conds = $this->getVisitingWatchersCondition( $dbr, $targetsWithVisitThresholds );
480 
481  $dbOptions = [ 'GROUP BY' => [ 'wl_namespace', 'wl_title' ] ];
482  if ( $minimumWatchers !== null ) {
483  $dbOptions['HAVING'] = 'COUNT(*) >= ' . (int)$minimumWatchers;
484  }
485  $res = $dbr->select(
486  'watchlist',
487  [ 'wl_namespace', 'wl_title', 'watchers' => 'COUNT(*)' ],
488  $conds,
489  __METHOD__,
490  $dbOptions
491  );
492 
493  $watcherCounts = [];
494  foreach ( $targetsWithVisitThresholds as list( $target ) ) {
495  /* @var LinkTarget $target */
496  $watcherCounts[$target->getNamespace()][$target->getDBkey()] = 0;
497  }
498 
499  foreach ( $res as $row ) {
500  $watcherCounts[$row->wl_namespace][$row->wl_title] = (int)$row->watchers;
501  }
502 
503  return $watcherCounts;
504  }
505 
514  IDatabase $db,
515  array $targetsWithVisitThresholds
516  ) {
517  $missingTargets = [];
518  $namespaceConds = [];
519  foreach ( $targetsWithVisitThresholds as list( $target, $threshold ) ) {
520  if ( $threshold === null ) {
521  $missingTargets[] = $target;
522  continue;
523  }
524  /* @var LinkTarget $target */
525  $namespaceConds[$target->getNamespace()][] = $db->makeList( [
526  'wl_title = ' . $db->addQuotes( $target->getDBkey() ),
527  $db->makeList( [
528  'wl_notificationtimestamp >= ' . $db->addQuotes( $db->timestamp( $threshold ) ),
529  'wl_notificationtimestamp IS NULL'
530  ], LIST_OR )
531  ], LIST_AND );
532  }
533 
534  $conds = [];
535  foreach ( $namespaceConds as $namespace => $pageConds ) {
536  $conds[] = $db->makeList( [
537  'wl_namespace = ' . $namespace,
538  '(' . $db->makeList( $pageConds, LIST_OR ) . ')'
539  ], LIST_AND );
540  }
541 
542  if ( $missingTargets ) {
543  $lb = new LinkBatch( $missingTargets );
544  $conds[] = $lb->constructSet( 'wl', $db );
545  }
546 
547  return $db->makeList( $conds, LIST_OR );
548  }
549 
556  public function getWatchedItem( UserIdentity $user, LinkTarget $target ) {
557  if ( !$user->isRegistered() ) {
558  return false;
559  }
560 
561  $cached = $this->getCached( $user, $target );
562  if ( $cached ) {
563  $this->stats->increment( 'WatchedItemStore.getWatchedItem.cached' );
564  return $cached;
565  }
566  $this->stats->increment( 'WatchedItemStore.getWatchedItem.load' );
567  return $this->loadWatchedItem( $user, $target );
568  }
569 
576  public function loadWatchedItem( UserIdentity $user, LinkTarget $target ) {
577  // Only registered user can have a watchlist
578  if ( !$user->isRegistered() ) {
579  return false;
580  }
581 
582  $dbr = $this->getConnectionRef( DB_REPLICA );
583 
584  $row = $dbr->selectRow(
585  'watchlist',
586  'wl_notificationtimestamp',
587  $this->dbCond( $user, $target ),
588  __METHOD__
589  );
590 
591  if ( !$row ) {
592  return false;
593  }
594 
595  $item = new WatchedItem(
596  $user,
597  $target,
598  $this->getLatestNotificationTimestamp( $row->wl_notificationtimestamp, $user, $target )
599  );
600  $this->cache( $item );
601 
602  return $item;
603  }
604 
611  public function getWatchedItemsForUser( UserIdentity $user, array $options = [] ) {
612  $options += [ 'forWrite' => false ];
613 
614  $dbOptions = [];
615  if ( array_key_exists( 'sort', $options ) ) {
616  Assert::parameter(
617  ( in_array( $options['sort'], [ self::SORT_ASC, self::SORT_DESC ] ) ),
618  '$options[\'sort\']',
619  'must be SORT_ASC or SORT_DESC'
620  );
621  $dbOptions['ORDER BY'] = [
622  "wl_namespace {$options['sort']}",
623  "wl_title {$options['sort']}"
624  ];
625  }
626  $db = $this->getConnectionRef( $options['forWrite'] ? DB_MASTER : DB_REPLICA );
627 
628  $res = $db->select(
629  'watchlist',
630  [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
631  [ 'wl_user' => $user->getId() ],
632  __METHOD__,
633  $dbOptions
634  );
635 
636  $watchedItems = [];
637  foreach ( $res as $row ) {
638  $target = new TitleValue( (int)$row->wl_namespace, $row->wl_title );
639  // @todo: Should we add these to the process cache?
640  $watchedItems[] = new WatchedItem(
641  $user,
642  $target,
644  $row->wl_notificationtimestamp, $user, $target )
645  );
646  }
647 
648  return $watchedItems;
649  }
650 
657  public function isWatched( UserIdentity $user, LinkTarget $target ) {
658  return (bool)$this->getWatchedItem( $user, $target );
659  }
660 
667  public function getNotificationTimestampsBatch( UserIdentity $user, array $targets ) {
668  $timestamps = [];
669  foreach ( $targets as $target ) {
670  $timestamps[$target->getNamespace()][$target->getDBkey()] = false;
671  }
672 
673  if ( !$user->isRegistered() ) {
674  return $timestamps;
675  }
676 
677  $targetsToLoad = [];
678  foreach ( $targets as $target ) {
679  $cachedItem = $this->getCached( $user, $target );
680  if ( $cachedItem ) {
681  $timestamps[$target->getNamespace()][$target->getDBkey()] =
682  $cachedItem->getNotificationTimestamp();
683  } else {
684  $targetsToLoad[] = $target;
685  }
686  }
687 
688  if ( !$targetsToLoad ) {
689  return $timestamps;
690  }
691 
692  $dbr = $this->getConnectionRef( DB_REPLICA );
693 
694  $lb = new LinkBatch( $targetsToLoad );
695  $res = $dbr->select(
696  'watchlist',
697  [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
698  [
699  $lb->constructSet( 'wl', $dbr ),
700  'wl_user' => $user->getId(),
701  ],
702  __METHOD__
703  );
704 
705  foreach ( $res as $row ) {
706  $target = new TitleValue( (int)$row->wl_namespace, $row->wl_title );
707  $timestamps[$row->wl_namespace][$row->wl_title] =
709  $row->wl_notificationtimestamp, $user, $target );
710  }
711 
712  return $timestamps;
713  }
714 
720  public function addWatch( UserIdentity $user, LinkTarget $target ) {
721  $this->addWatchBatchForUser( $user, [ $target ] );
722  }
723 
730  public function addWatchBatchForUser( UserIdentity $user, array $targets ) {
731  if ( $this->readOnlyMode->isReadOnly() ) {
732  return false;
733  }
734  // Only registered user can have a watchlist
735  if ( !$user->isRegistered() ) {
736  return false;
737  }
738 
739  if ( !$targets ) {
740  return true;
741  }
742 
743  $rows = [];
744  $items = [];
745  foreach ( $targets as $target ) {
746  $rows[] = [
747  'wl_user' => $user->getId(),
748  'wl_namespace' => $target->getNamespace(),
749  'wl_title' => $target->getDBkey(),
750  'wl_notificationtimestamp' => null,
751  ];
752  $items[] = new WatchedItem(
753  $user,
754  $target,
755  null
756  );
757  $this->uncache( $user, $target );
758  }
759 
760  $dbw = $this->getConnectionRef( DB_MASTER );
761  $ticket = count( $targets ) > $this->updateRowsPerQuery ?
762  $this->lbFactory->getEmptyTransactionTicket( __METHOD__ ) : null;
763  $affectedRows = 0;
764  $rowBatches = array_chunk( $rows, $this->updateRowsPerQuery );
765  foreach ( $rowBatches as $toInsert ) {
766  // Use INSERT IGNORE to avoid overwriting the notification timestamp
767  // if there's already an entry for this page
768  $dbw->insert( 'watchlist', $toInsert, __METHOD__, [ 'IGNORE' ] );
769  $affectedRows += $dbw->affectedRows();
770  if ( $ticket ) {
771  $this->lbFactory->commitAndWaitForReplication( __METHOD__, $ticket );
772  }
773  }
774  // Update process cache to ensure skin doesn't claim that the current
775  // page is unwatched in the response of action=watch itself (T28292).
776  // This would otherwise be re-queried from a replica by isWatched().
777  foreach ( $items as $item ) {
778  $this->cache( $item );
779  }
780 
781  return (bool)$affectedRows;
782  }
783 
790  public function removeWatch( UserIdentity $user, LinkTarget $target ) {
791  return $this->removeWatchBatchForUser( $user, [ $target ] );
792  }
793 
812  UserIdentity $user, $timestamp, array $targets = []
813  ) {
814  // Only registered user can have a watchlist
815  if ( !$user->isRegistered() || $this->readOnlyMode->isReadOnly() ) {
816  return false;
817  }
818 
819  if ( !$targets ) {
820  // Backwards compatibility
821  $this->resetAllNotificationTimestampsForUser( $user, $timestamp );
822  return true;
823  }
824 
825  $rows = $this->getTitleDbKeysGroupedByNamespace( $targets );
826 
827  $dbw = $this->getConnectionRef( DB_MASTER );
828  if ( $timestamp !== null ) {
829  $timestamp = $dbw->timestamp( $timestamp );
830  }
831  $ticket = $this->lbFactory->getEmptyTransactionTicket( __METHOD__ );
832  $affectedSinceWait = 0;
833 
834  // Batch update items per namespace
835  foreach ( $rows as $namespace => $namespaceTitles ) {
836  $rowBatches = array_chunk( $namespaceTitles, $this->updateRowsPerQuery );
837  foreach ( $rowBatches as $toUpdate ) {
838  $dbw->update(
839  'watchlist',
840  [ 'wl_notificationtimestamp' => $timestamp ],
841  [
842  'wl_user' => $user->getId(),
843  'wl_namespace' => $namespace,
844  'wl_title' => $toUpdate
845  ]
846  );
847  $affectedSinceWait += $dbw->affectedRows();
848  // Wait for replication every time we've touched updateRowsPerQuery rows
849  if ( $affectedSinceWait >= $this->updateRowsPerQuery ) {
850  $this->lbFactory->commitAndWaitForReplication( __METHOD__, $ticket );
851  $affectedSinceWait = 0;
852  }
853  }
854  }
855 
856  $this->uncacheUser( $user );
857 
858  return true;
859  }
860 
862  $timestamp, UserIdentity $user, LinkTarget $target
863  ) {
864  $timestamp = wfTimestampOrNull( TS_MW, $timestamp );
865  if ( $timestamp === null ) {
866  return null; // no notification
867  }
868 
869  $seenTimestamps = $this->getPageSeenTimestamps( $user );
870  if (
871  $seenTimestamps &&
872  $seenTimestamps->get( $this->getPageSeenKey( $target ) ) >= $timestamp
873  ) {
874  // If a reset job did not yet run, then the "seen" timestamp will be higher
875  return null;
876  }
877 
878  return $timestamp;
879  }
880 
887  public function resetAllNotificationTimestampsForUser( UserIdentity $user, $timestamp = null ) {
888  // Only registered user can have a watchlist
889  if ( !$user->isRegistered() ) {
890  return;
891  }
892 
893  // If the page is watched by the user (or may be watched), update the timestamp
895  'userId' => $user->getId(), 'timestamp' => $timestamp, 'casTime' => time()
896  ] );
897 
898  // Try to run this post-send
899  // Calls DeferredUpdates::addCallableUpdate in normal operation
900  call_user_func(
901  $this->deferredUpdatesAddCallableUpdateCallback,
902  function () use ( $job ) {
903  $job->run();
904  }
905  );
906  }
907 
916  UserIdentity $editor, LinkTarget $target, $timestamp
917  ) {
918  $dbw = $this->getConnectionRef( DB_MASTER );
919  $uids = $dbw->selectFieldValues(
920  'watchlist',
921  'wl_user',
922  [
923  'wl_user != ' . intval( $editor->getId() ),
924  'wl_namespace' => $target->getNamespace(),
925  'wl_title' => $target->getDBkey(),
926  'wl_notificationtimestamp IS NULL',
927  ],
928  __METHOD__
929  );
930 
931  $watchers = array_map( 'intval', $uids );
932  if ( $watchers ) {
933  // Update wl_notificationtimestamp for all watching users except the editor
934  $fname = __METHOD__;
936  function () use ( $timestamp, $watchers, $target, $fname ) {
937  $dbw = $this->getConnectionRef( DB_MASTER );
938  $ticket = $this->lbFactory->getEmptyTransactionTicket( $fname );
939 
940  $watchersChunks = array_chunk( $watchers, $this->updateRowsPerQuery );
941  foreach ( $watchersChunks as $watchersChunk ) {
942  $dbw->update( 'watchlist',
943  [ /* SET */
944  'wl_notificationtimestamp' => $dbw->timestamp( $timestamp )
945  ], [ /* WHERE - TODO Use wl_id T130067 */
946  'wl_user' => $watchersChunk,
947  'wl_namespace' => $target->getNamespace(),
948  'wl_title' => $target->getDBkey(),
949  ], $fname
950  );
951  if ( count( $watchersChunks ) > 1 ) {
952  $this->lbFactory->commitAndWaitForReplication(
953  $fname, $ticket, [ 'domain' => $dbw->getDomainID() ]
954  );
955  }
956  }
957  $this->uncacheLinkTarget( $target );
958  },
960  $dbw
961  );
962  }
963 
964  return $watchers;
965  }
966 
975  public function resetNotificationTimestamp(
976  UserIdentity $user, LinkTarget $title, $force = '', $oldid = 0
977  ) {
978  $time = time();
979 
980  // Only registered user can have a watchlist
981  if ( $this->readOnlyMode->isReadOnly() || !$user->isRegistered() ) {
982  return false;
983  }
984 
985  // Hook expects User and Title, not UserIdentity and LinkTarget
986  $userObj = User::newFromId( $user->getId() );
987  $titleObj = Title::castFromLinkTarget( $title );
988  if ( !Hooks::run( 'BeforeResetNotificationTimestamp',
989  [ &$userObj, &$titleObj, $force, &$oldid ] )
990  ) {
991  return false;
992  }
993  if ( !$userObj->equals( $user ) ) {
994  $user = $userObj;
995  }
996  if ( !$titleObj->equals( $title ) ) {
997  $title = $titleObj;
998  }
999 
1000  $item = null;
1001  if ( $force != 'force' ) {
1002  $item = $this->loadWatchedItem( $user, $title );
1003  if ( !$item || $item->getNotificationTimestamp() === null ) {
1004  return false;
1005  }
1006  }
1007 
1008  // Get the timestamp (TS_MW) of this revision to track the latest one seen
1009  $id = $oldid;
1010  $seenTime = null;
1011  if ( !$id ) {
1012  $latestRev = $this->revisionLookup->getRevisionByTitle( $title );
1013  if ( $latestRev ) {
1014  $id = $latestRev->getId();
1015  // Save a DB query
1016  $seenTime = $latestRev->getTimestamp();
1017  }
1018  }
1019  if ( $seenTime === null ) {
1020  $seenTime = $this->revisionLookup->getTimestampFromId( $id );
1021  }
1022 
1023  // Mark the item as read immediately in lightweight storage
1024  $this->stash->merge(
1025  $this->getPageSeenTimestampsKey( $user ),
1026  function ( $cache, $key, $current ) use ( $title, $seenTime ) {
1027  $value = $current ?: new MapCacheLRU( 300 );
1028  $subKey = $this->getPageSeenKey( $title );
1029 
1030  if ( $seenTime > $value->get( $subKey ) ) {
1031  // Revision is newer than the last one seen
1032  $value->set( $subKey, $seenTime );
1033  $this->latestUpdateCache->set( $key, $value, BagOStuff::TTL_PROC_LONG );
1034  } elseif ( $seenTime === false ) {
1035  // Revision does not exist
1036  $value->set( $subKey, wfTimestamp( TS_MW ) );
1037  $this->latestUpdateCache->set( $key, $value, BagOStuff::TTL_PROC_LONG );
1038  } else {
1039  return false; // nothing to update
1040  }
1041 
1042  return $value;
1043  },
1045  );
1046 
1047  // If the page is watched by the user (or may be watched), update the timestamp
1048  $job = new ActivityUpdateJob(
1049  $title,
1050  [
1051  'type' => 'updateWatchlistNotification',
1052  'userid' => $user->getId(),
1053  'notifTime' => $this->getNotificationTimestamp( $user, $title, $item, $force, $oldid ),
1054  'curTime' => $time
1055  ]
1056  );
1057  // Try to enqueue this post-send
1058  $this->queueGroup->lazyPush( $job );
1059 
1060  $this->uncache( $user, $title );
1061 
1062  return true;
1063  }
1064 
1069  private function getPageSeenTimestamps( UserIdentity $user ) {
1070  $key = $this->getPageSeenTimestampsKey( $user );
1071 
1072  return $this->latestUpdateCache->getWithSetCallback(
1073  $key,
1075  function () use ( $key ) {
1076  return $this->stash->get( $key ) ?: null;
1077  }
1078  );
1079  }
1080 
1085  private function getPageSeenTimestampsKey( UserIdentity $user ) {
1086  return $this->stash->makeGlobalKey(
1087  'watchlist-recent-updates',
1088  $this->lbFactory->getLocalDomainID(),
1089  $user->getId()
1090  );
1091  }
1092 
1097  private function getPageSeenKey( LinkTarget $target ) {
1098  return "{$target->getNamespace()}:{$target->getDBkey()}";
1099  }
1100 
1109  private function getNotificationTimestamp(
1110  UserIdentity $user, LinkTarget $title, $item, $force, $oldid
1111  ) {
1112  if ( !$oldid ) {
1113  // No oldid given, assuming latest revision; clear the timestamp.
1114  return null;
1115  }
1116 
1117  $oldRev = $this->revisionLookup->getRevisionById( $oldid );
1118  if ( !$oldRev ) {
1119  // Oldid given but does not exist (probably deleted)
1120  return false;
1121  }
1122 
1123  $nextRev = $this->revisionLookup->getNextRevision( $oldRev );
1124  if ( !$nextRev ) {
1125  // Oldid given and is the latest revision for this title; clear the timestamp.
1126  return null;
1127  }
1128 
1129  if ( $item === null ) {
1130  $item = $this->loadWatchedItem( $user, $title );
1131  }
1132 
1133  if ( !$item ) {
1134  // This can only happen if $force is enabled.
1135  return null;
1136  }
1137 
1138  // Oldid given and isn't the latest; update the timestamp.
1139  // This will result in no further notification emails being sent!
1140  $notificationTimestamp = $this->revisionLookup->getTimestampFromId( $oldid );
1141  // @FIXME: this should use getTimestamp() for consistency with updates on new edits
1142  // $notificationTimestamp = $nextRev->getTimestamp(); // first unseen revision timestamp
1143 
1144  // We need to go one second to the future because of various strict comparisons
1145  // throughout the codebase
1146  $ts = new MWTimestamp( $notificationTimestamp );
1147  $ts->timestamp->add( new DateInterval( 'PT1S' ) );
1148  $notificationTimestamp = $ts->getTimestamp( TS_MW );
1149 
1150  if ( $notificationTimestamp < $item->getNotificationTimestamp() ) {
1151  if ( $force != 'force' ) {
1152  return false;
1153  } else {
1154  // This is a little silly…
1155  return $item->getNotificationTimestamp();
1156  }
1157  }
1158 
1159  return $notificationTimestamp;
1160  }
1161 
1168  public function countUnreadNotifications( UserIdentity $user, $unreadLimit = null ) {
1169  $dbr = $this->getConnectionRef( DB_REPLICA );
1170 
1171  $queryOptions = [];
1172  if ( $unreadLimit !== null ) {
1173  $unreadLimit = (int)$unreadLimit;
1174  $queryOptions['LIMIT'] = $unreadLimit;
1175  }
1176 
1177  $conds = [
1178  'wl_user' => $user->getId(),
1179  'wl_notificationtimestamp IS NOT NULL'
1180  ];
1181 
1182  $rowCount = $dbr->selectRowCount( 'watchlist', '1', $conds, __METHOD__, $queryOptions );
1183 
1184  if ( $unreadLimit === null ) {
1185  return $rowCount;
1186  }
1187 
1188  if ( $rowCount >= $unreadLimit ) {
1189  return true;
1190  }
1191 
1192  return $rowCount;
1193  }
1194 
1200  public function duplicateAllAssociatedEntries( LinkTarget $oldTarget, LinkTarget $newTarget ) {
1201  // Duplicate first the subject page, then the talk page
1202  $this->duplicateEntry(
1203  $this->nsInfo->getSubjectPage( $oldTarget ),
1204  $this->nsInfo->getSubjectPage( $newTarget )
1205  );
1206  $this->duplicateEntry(
1207  $this->nsInfo->getTalkPage( $oldTarget ),
1208  $this->nsInfo->getTalkPage( $newTarget )
1209  );
1210  }
1211 
1217  public function duplicateEntry( LinkTarget $oldTarget, LinkTarget $newTarget ) {
1218  $dbw = $this->getConnectionRef( DB_MASTER );
1219 
1220  $result = $dbw->select(
1221  'watchlist',
1222  [ 'wl_user', 'wl_notificationtimestamp' ],
1223  [
1224  'wl_namespace' => $oldTarget->getNamespace(),
1225  'wl_title' => $oldTarget->getDBkey(),
1226  ],
1227  __METHOD__,
1228  [ 'FOR UPDATE' ]
1229  );
1230 
1231  $newNamespace = $newTarget->getNamespace();
1232  $newDBkey = $newTarget->getDBkey();
1233 
1234  # Construct array to replace into the watchlist
1235  $values = [];
1236  foreach ( $result as $row ) {
1237  $values[] = [
1238  'wl_user' => $row->wl_user,
1239  'wl_namespace' => $newNamespace,
1240  'wl_title' => $newDBkey,
1241  'wl_notificationtimestamp' => $row->wl_notificationtimestamp,
1242  ];
1243  }
1244 
1245  if ( !empty( $values ) ) {
1246  # Perform replace
1247  # Note that multi-row replace is very efficient for MySQL but may be inefficient for
1248  # some other DBMSes, mostly due to poor simulation by us
1249  $dbw->replace(
1250  'watchlist',
1251  [ [ 'wl_user', 'wl_namespace', 'wl_title' ] ],
1252  $values,
1253  __METHOD__
1254  );
1255  }
1256  }
1257 
1262  private function getTitleDbKeysGroupedByNamespace( array $titles ) {
1263  $rows = [];
1264  foreach ( $titles as $title ) {
1265  // Group titles by namespace.
1266  $rows[ $title->getNamespace() ][] = $title->getDBkey();
1267  }
1268  return $rows;
1269  }
1270 
1275  private function uncacheTitlesForUser( UserIdentity $user, array $titles ) {
1276  foreach ( $titles as $title ) {
1277  $this->uncache( $user, $title );
1278  }
1279  }
1280 
1281 }
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:861
MWTimestamp
Library for creating and parsing MW-style timestamps.
Definition: MWTimestamp.php:32
User\newFromId
static newFromId( $id)
Static factory method for creation from a given user ID.
Definition: User.php:539
WatchedItemStore\uncacheAllItemsForUser
uncacheAllItemsForUser(UserIdentity $user)
Definition: WatchedItemStore.php:270
ActivityUpdateJob
Job for updating user activity like "last viewed" timestamps.
Definition: ActivityUpdateJob.php:36
WatchedItemStore\loadWatchedItem
loadWatchedItem(UserIdentity $user, LinkTarget $target)
Definition: WatchedItemStore.php:576
WatchedItemStore\__construct
__construct(ILBFactory $lbFactory, JobQueueGroup $queueGroup, BagOStuff $stash, HashBagOStuff $cache, ReadOnlyMode $readOnlyMode, $updateRowsPerQuery, NamespaceInfo $nsInfo, RevisionLookup $revisionLookup)
Definition: WatchedItemStore.php:101
WatchedItemStore\updateNotificationTimestamp
updateNotificationTimestamp(UserIdentity $editor, LinkTarget $target, $timestamp)
Definition: WatchedItemStore.php:915
HashBagOStuff
Simple store for keeping values in an associative array for the current process.
Definition: HashBagOStuff.php:31
LinkBatch
Class representing a list of titles The execute() method checks them all for existence and adds them ...
Definition: LinkBatch.php:34
Wikimedia\Rdbms\IDatabase\makeList
makeList( $a, $mode=self::LIST_COMMA)
Makes an encoded list of strings from an array.
WatchedItemStore\getPageSeenTimestamps
getPageSeenTimestamps(UserIdentity $user)
Definition: WatchedItemStore.php:1069
WatchedItemStore\clearUserWatchedItems
clearUserWatchedItems(UserIdentity $user)
Deletes ALL watched items for the given user when under $updateRowsPerQuery entries exist.
Definition: WatchedItemStore.php:254
WatchedItemStore\getVisitingWatchersCondition
getVisitingWatchersCondition(IDatabase $db, array $targetsWithVisitThresholds)
Generates condition for the query used in a batch count visiting watchers.
Definition: WatchedItemStore.php:513
wfTimestamp
wfTimestamp( $outputtype=TS_UNIX, $ts=0)
Get a timestamp string in one of various formats.
Definition: GlobalFunctions.php:1869
WatchedItemStore\getNotificationTimestampsBatch
getNotificationTimestampsBatch(UserIdentity $user, array $targets)
Definition: WatchedItemStore.php:667
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
WatchedItemStore\$nsInfo
NamespaceInfo $nsInfo
Definition: WatchedItemStore.php:79
WatchedItemStore\countVisitingWatchers
countVisitingWatchers(LinkTarget $target, $threshold)
Definition: WatchedItemStore.php:365
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:325
WatchedItemStore\duplicateAllAssociatedEntries
duplicateAllAssociatedEntries(LinkTarget $oldTarget, LinkTarget $newTarget)
Definition: WatchedItemStore.php:1200
BagOStuff
Class representing a cache/ephemeral data store.
Definition: BagOStuff.php:63
WatchedItemStore\$loadBalancer
LoadBalancer $loadBalancer
Definition: WatchedItemStore.php:31
Wikimedia\Rdbms\ILBFactory\getMainLB
getMainLB( $domain=false)
Get a cached (tracked) load balancer object.
$res
$res
Definition: testCompression.php:52
WatchedItemStore\getNotificationTimestamp
getNotificationTimestamp(UserIdentity $user, LinkTarget $title, $item, $force, $oldid)
Definition: WatchedItemStore.php:1109
MediaWiki\User\UserIdentity
Interface for objects representing user identity.
Definition: UserIdentity.php:32
WatchedItemStore\$cache
HashBagOStuff $cache
Definition: WatchedItemStore.php:51
Revision\RevisionLookup
Service for looking up page revisions.
Definition: RevisionLookup.php:38
LIST_AND
const LIST_AND
Definition: Defines.php:39
Wikimedia\Rdbms\IDatabase
Basic database interface for live and lazy-loaded relation database handles.
Definition: IDatabase.php:38
$dbr
$dbr
Definition: testCompression.php:50
WatchedItemStore\$lbFactory
ILBFactory $lbFactory
Definition: WatchedItemStore.php:26
MediaWiki\Linker\LinkTarget\getNamespace
getNamespace()
Get the namespace index.
WatchedItemStore\$readOnlyMode
ReadOnlyMode $readOnlyMode
Definition: WatchedItemStore.php:46
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:42
WatchedItemStore\overrideDeferredUpdatesAddCallableUpdateCallback
overrideDeferredUpdatesAddCallableUpdateCallback(callable $callback)
Overrides the DeferredUpdates::addCallableUpdate callback This is intended for use while testing and ...
Definition: WatchedItemStore.php:145
MWException
MediaWiki exception.
Definition: MWException.php:26
WatchedItemStore\getPageSeenTimestampsKey
getPageSeenTimestampsKey(UserIdentity $user)
Definition: WatchedItemStore.php:1085
MediaWiki\User\UserIdentity\isRegistered
isRegistered()
WatchedItemStore\resetNotificationTimestamp
resetNotificationTimestamp(UserIdentity $user, LinkTarget $title, $force='', $oldid=0)
Definition: WatchedItemStore.php:975
WatchedItemStore\$queueGroup
JobQueueGroup $queueGroup
Definition: WatchedItemStore.php:36
wfTimestampOrNull
wfTimestampOrNull( $outputtype=TS_UNIX, $ts=null)
Return a formatted timestamp, or null if input is null.
Definition: GlobalFunctions.php:1885
WatchedItemStore\duplicateEntry
duplicateEntry(LinkTarget $oldTarget, LinkTarget $newTarget)
Definition: WatchedItemStore.php:1217
MapCacheLRU
Handles a simple LRU key/value map with a maximum number of entries.
Definition: MapCacheLRU.php:37
WatchedItemStore\removeWatch
removeWatch(UserIdentity $user, LinkTarget $target)
Definition: WatchedItemStore.php:790
DeferredUpdates\POSTSEND
const POSTSEND
Definition: DeferredUpdates.php:70
$title
$title
Definition: testCompression.php:34
DB_REPLICA
const DB_REPLICA
Definition: defines.php:25
WatchedItemStore\addWatchBatchForUser
addWatchBatchForUser(UserIdentity $user, array $targets)
Definition: WatchedItemStore.php:730
WatchedItemStore\getMaxId
getMaxId()
Definition: WatchedItemStore.php:310
WatchedItemStore\addWatch
addWatch(UserIdentity $user, LinkTarget $target)
Definition: WatchedItemStore.php:720
DB_MASTER
const DB_MASTER
Definition: defines.php:26
WatchedItemStore\isWatched
isWatched(UserIdentity $user, LinkTarget $target)
Definition: WatchedItemStore.php:657
WatchedItemStore\uncacheLinkTarget
uncacheLinkTarget(LinkTarget $target)
Definition: WatchedItemStore.php:181
WatchedItem\getUserIdentity
getUserIdentity()
Definition: WatchedItem.php:75
WatchedItemStore\getPageSeenKey
getPageSeenKey(LinkTarget $target)
Definition: WatchedItemStore.php:1097
WatchedItemStore\getConnectionRef
getConnectionRef( $dbIndex)
Definition: WatchedItemStore.php:240
WatchedItemStore\$deferredUpdatesAddCallableUpdateCallback
callable null $deferredUpdatesAddCallableUpdateCallback
Definition: WatchedItemStore.php:69
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:84
WatchedItemStore\countUnreadNotifications
countUnreadNotifications(UserIdentity $user, $unreadLimit=null)
Definition: WatchedItemStore.php:1168
StatsdAwareInterface
Describes a Statsd aware interface.
Definition: StatsdAwareInterface.php:11
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:64
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:887
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:811
WatchedItemStore\getWatchedItem
getWatchedItem(UserIdentity $user, LinkTarget $target)
Definition: WatchedItemStore.php:556
WatchedItemStore\$latestUpdateCache
HashBagOStuff $latestUpdateCache
Definition: WatchedItemStore.php:56
WatchedItemStore\uncacheTitlesForUser
uncacheTitlesForUser(UserIdentity $user, array $titles)
Definition: WatchedItemStore.php:1275
WatchedItemStore
Storage layer class for WatchedItems.
Definition: WatchedItemStore.php:21
IExpiringStore\TTL_HOUR
const TTL_HOUR
Definition: IExpiringStore.php:34
WatchedItemStore\getCached
getCached(UserIdentity $user, LinkTarget $target)
Definition: WatchedItemStore.php:214
WatchedItemStore\getCacheKey
getCacheKey(UserIdentity $user, LinkTarget $target)
Definition: WatchedItemStore.php:158
WatchedItemStore\uncache
uncache(UserIdentity $user, LinkTarget $target)
Definition: WatchedItemStore.php:175
MediaWiki\User\UserIdentity\getId
getId()
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:89
WatchedItemStore\$stash
BagOStuff $stash
Definition: WatchedItemStore.php:41
WatchedItemStore\countWatchers
countWatchers(LinkTarget $target)
Definition: WatchedItemStore.php:344
$job
if(count( $args)< 1) $job
Definition: recompressTracked.php:50
WatchedItem\getLinkTarget
getLinkTarget()
Definition: WatchedItem.php:82
WatchedItemStore\countVisitingWatchersMultiple
countVisitingWatchersMultiple(array $targetsWithVisitThresholds, $minimumWatchers=null)
Definition: WatchedItemStore.php:468
WatchedItemStore\setStatsdDataFactory
setStatsdDataFactory(StatsdDataFactoryInterface $stats)
Definition: WatchedItemStore.php:130
WatchedItemStore\dbCond
dbCond(UserIdentity $user, LinkTarget $target)
Return an array of conditions to select or update the appropriate database row.
Definition: WatchedItemStore.php:227
WatchedItemStore\uncacheUser
uncacheUser(UserIdentity $user)
Definition: WatchedItemStore.php:192
NamespaceInfo
This is a utility class for dealing with namespaces that encodes all the "magic" behaviors of them ba...
Definition: NamespaceInfo.php:33
WatchedItemStore\getWatchedItemsForUser
getWatchedItemsForUser(UserIdentity $user, array $options=[])
Definition: WatchedItemStore.php:611
Title\castFromLinkTarget
static castFromLinkTarget( $linkTarget)
Same as newFromLinkTarget, but if passed null, returns null.
Definition: Title.php:292
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:124
Hooks\run
static run( $event, array $args=[], $deprecatedVersion=null)
Call hook functions defined in Hooks::register and $wgHooks.
Definition: Hooks.php:200
WatchedItemStore\getTitleDbKeysGroupedByNamespace
getTitleDbKeysGroupedByNamespace(array $titles)
Definition: WatchedItemStore.php:1262
WatchedItemStore\$updateRowsPerQuery
int $updateRowsPerQuery
Definition: WatchedItemStore.php:74
WatchedItemStore\countWatchersMultiple
countWatchersMultiple(array $targets, array $options=[])
Definition: WatchedItemStore.php:432
IExpiringStore\TTL_PROC_LONG
const TTL_PROC_LONG
Definition: IExpiringStore.php:42
Wikimedia\Rdbms\ILBFactory
An interface for generating database load balancers.
Definition: ILBFactory.php:33
WatchedItemStore\removeWatchBatchForUser
removeWatchBatchForUser(UserIdentity $user, array $titles)
Definition: WatchedItemStore.php:388
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:301
WatchedItemStore\cache
cache(WatchedItem $item)
Definition: WatchedItemStore.php:166
TitleValue
Represents a page (or page fragment) title within MediaWiki.
Definition: TitleValue.php:36