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