Merge "Exclude redirects from Special:Fewestrevisions"
[lhc/web/wiklou.git] / includes / watcheditem / WatchedItemStore.php
1 <?php
2
3 use Liuggio\StatsdClient\Factory\StatsdDataFactoryInterface;
4 use MediaWiki\Linker\LinkTarget;
5 use MediaWiki\Revision\RevisionLookup;
6 use MediaWiki\User\UserIdentity;
7 use Wikimedia\Assert\Assert;
8 use Wikimedia\Rdbms\IDatabase;
9 use Wikimedia\Rdbms\ILBFactory;
10 use Wikimedia\Rdbms\LoadBalancer;
11 use Wikimedia\ScopedCallback;
12
13 /**
14 * Storage layer class for WatchedItems.
15 * Database interaction & caching
16 * TODO caching should be factored out into a CachingWatchedItemStore class
17 *
18 * @author Addshore
19 * @since 1.27
20 */
21 class WatchedItemStore implements WatchedItemStoreInterface, StatsdAwareInterface {
22
23 /**
24 * @var ILBFactory
25 */
26 private $lbFactory;
27
28 /**
29 * @var LoadBalancer
30 */
31 private $loadBalancer;
32
33 /**
34 * @var JobQueueGroup
35 */
36 private $queueGroup;
37
38 /**
39 * @var BagOStuff
40 */
41 private $stash;
42
43 /**
44 * @var ReadOnlyMode
45 */
46 private $readOnlyMode;
47
48 /**
49 * @var HashBagOStuff
50 */
51 private $cache;
52
53 /**
54 * @var HashBagOStuff
55 */
56 private $latestUpdateCache;
57
58 /**
59 * @var array[] Looks like $cacheIndex[Namespace ID][Target DB Key][User Id] => 'key'
60 * The index is needed so that on mass changes all relevant items can be un-cached.
61 * For example: Clearing a users watchlist of all items or updating notification timestamps
62 * for all users watching a single target.
63 */
64 private $cacheIndex = [];
65
66 /**
67 * @var callable|null
68 */
69 private $deferredUpdatesAddCallableUpdateCallback;
70
71 /**
72 * @var int
73 */
74 private $updateRowsPerQuery;
75
76 /**
77 * @var NamespaceInfo
78 */
79 private $nsInfo;
80
81 /**
82 * @var RevisionLookup
83 */
84 private $revisionLookup;
85
86 /**
87 * @var StatsdDataFactoryInterface
88 */
89 private $stats;
90
91 /**
92 * @param ILBFactory $lbFactory
93 * @param JobQueueGroup $queueGroup
94 * @param BagOStuff $stash
95 * @param HashBagOStuff $cache
96 * @param ReadOnlyMode $readOnlyMode
97 * @param int $updateRowsPerQuery
98 * @param NamespaceInfo $nsInfo
99 * @param RevisionLookup $revisionLookup
100 */
101 public function __construct(
102 ILBFactory $lbFactory,
103 JobQueueGroup $queueGroup,
104 BagOStuff $stash,
105 HashBagOStuff $cache,
106 ReadOnlyMode $readOnlyMode,
107 $updateRowsPerQuery,
108 NamespaceInfo $nsInfo,
109 RevisionLookup $revisionLookup
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
127 /**
128 * @param StatsdDataFactoryInterface $stats
129 */
130 public function setStatsdDataFactory( StatsdDataFactoryInterface $stats ) {
131 $this->stats = $stats;
132 }
133
134 /**
135 * Overrides the DeferredUpdates::addCallableUpdate callback
136 * This is intended for use while testing and will fail if MW_PHPUNIT_TEST is not defined.
137 *
138 * @param callable $callback
139 *
140 * @see DeferredUpdates::addCallableUpdate for callback signiture
141 *
142 * @return ScopedCallback to reset the overridden value
143 * @throws MWException
144 */
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 }
151 $previousValue = $this->deferredUpdatesAddCallableUpdateCallback;
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
208 /**
209 * @param UserIdentity $user
210 * @param LinkTarget $target
211 *
212 * @return WatchedItem|false
213 */
214 private function getCached( UserIdentity $user, LinkTarget $target ) {
215 return $this->cache->get( $this->getCacheKey( $user, $target ) );
216 }
217
218 /**
219 * Return an array of conditions to select or update the appropriate database
220 * row.
221 *
222 * @param UserIdentity $user
223 * @param LinkTarget $target
224 *
225 * @return array
226 */
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
235 /**
236 * @param int $dbIndex DB_MASTER or DB_REPLICA
237 *
238 * @return IDatabase
239 * @throws MWException
240 */
241 private function getConnectionRef( $dbIndex ) {
242 return $this->loadBalancer->getConnectionRef( $dbIndex, [ 'watchlist' ] );
243 }
244
245 /**
246 * Deletes ALL watched items for the given user when under
247 * $updateRowsPerQuery entries exist.
248 *
249 * @since 1.30
250 *
251 * @param UserIdentity $user
252 *
253 * @return bool true on success, false when too many items are watched
254 */
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
295 /**
296 * Queues a job that will clear the users watchlist using the Job Queue.
297 *
298 * @since 1.31
299 *
300 * @param UserIdentity $user
301 */
302 public function clearUserWatchedItemsUsingJobQueue( UserIdentity $user ) {
303 $job = ClearUserWatchlistJob::newForUser( $user, $this->getMaxId() );
304 $this->queueGroup->push( $job );
305 }
306
307 /**
308 * @since 1.31
309 * @return int The maximum current wl_id
310 */
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
321 /**
322 * @since 1.31
323 * @param UserIdentity $user
324 * @return int
325 */
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
340 /**
341 * @since 1.27
342 * @param LinkTarget $target
343 * @return int
344 */
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
360 /**
361 * @since 1.27
362 * @param LinkTarget $target
363 * @param string|int $threshold
364 * @return int
365 */
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
384 /**
385 * @param UserIdentity $user
386 * @param TitleValue[] $titles
387 * @return bool
388 * @throws MWException
389 */
390 public function removeWatchBatchForUser( UserIdentity $user, array $titles ) {
391 if ( $this->readOnlyMode->isReadOnly() ) {
392 return false;
393 }
394 if ( !$user->isRegistered() ) {
395 return false;
396 }
397 if ( !$titles ) {
398 return true;
399 }
400
401 $rows = $this->getTitleDbKeysGroupedByNamespace( $titles );
402 $this->uncacheTitlesForUser( $user, $titles );
403
404 $dbw = $this->getConnectionRef( DB_MASTER );
405 $ticket = count( $titles ) > $this->updateRowsPerQuery ?
406 $this->lbFactory->getEmptyTransactionTicket( __METHOD__ ) : null;
407 $affectedRows = 0;
408
409 // Batch delete items per namespace.
410 foreach ( $rows as $namespace => $namespaceTitles ) {
411 $rowBatches = array_chunk( $namespaceTitles, $this->updateRowsPerQuery );
412 foreach ( $rowBatches as $toDelete ) {
413 $dbw->delete( 'watchlist', [
414 'wl_user' => $user->getId(),
415 'wl_namespace' => $namespace,
416 'wl_title' => $toDelete
417 ], __METHOD__ );
418 $affectedRows += $dbw->affectedRows();
419 if ( $ticket ) {
420 $this->lbFactory->commitAndWaitForReplication( __METHOD__, $ticket );
421 }
422 }
423 }
424
425 return (bool)$affectedRows;
426 }
427
428 /**
429 * @since 1.27
430 * @param LinkTarget[] $targets
431 * @param array $options
432 * @return array
433 */
434 public function countWatchersMultiple( array $targets, array $options = [] ) {
435 $dbOptions = [ 'GROUP BY' => [ 'wl_namespace', 'wl_title' ] ];
436
437 $dbr = $this->getConnectionRef( DB_REPLICA );
438
439 if ( array_key_exists( 'minimumWatchers', $options ) ) {
440 $dbOptions['HAVING'] = 'COUNT(*) >= ' . (int)$options['minimumWatchers'];
441 }
442
443 $lb = new LinkBatch( $targets );
444 $res = $dbr->select(
445 'watchlist',
446 [ 'wl_title', 'wl_namespace', 'watchers' => 'COUNT(*)' ],
447 [ $lb->constructSet( 'wl', $dbr ) ],
448 __METHOD__,
449 $dbOptions
450 );
451
452 $watchCounts = [];
453 foreach ( $targets as $linkTarget ) {
454 $watchCounts[$linkTarget->getNamespace()][$linkTarget->getDBkey()] = 0;
455 }
456
457 foreach ( $res as $row ) {
458 $watchCounts[$row->wl_namespace][$row->wl_title] = (int)$row->watchers;
459 }
460
461 return $watchCounts;
462 }
463
464 /**
465 * @since 1.27
466 * @param array $targetsWithVisitThresholds
467 * @param int|null $minimumWatchers
468 * @return array
469 */
470 public function countVisitingWatchersMultiple(
471 array $targetsWithVisitThresholds,
472 $minimumWatchers = null
473 ) {
474 if ( $targetsWithVisitThresholds === [] ) {
475 // No titles requested => no results returned
476 return [];
477 }
478
479 $dbr = $this->getConnectionRef( DB_REPLICA );
480
481 $conds = $this->getVisitingWatchersCondition( $dbr, $targetsWithVisitThresholds );
482
483 $dbOptions = [ 'GROUP BY' => [ 'wl_namespace', 'wl_title' ] ];
484 if ( $minimumWatchers !== null ) {
485 $dbOptions['HAVING'] = 'COUNT(*) >= ' . (int)$minimumWatchers;
486 }
487 $res = $dbr->select(
488 'watchlist',
489 [ 'wl_namespace', 'wl_title', 'watchers' => 'COUNT(*)' ],
490 $conds,
491 __METHOD__,
492 $dbOptions
493 );
494
495 $watcherCounts = [];
496 foreach ( $targetsWithVisitThresholds as list( $target ) ) {
497 /* @var LinkTarget $target */
498 $watcherCounts[$target->getNamespace()][$target->getDBkey()] = 0;
499 }
500
501 foreach ( $res as $row ) {
502 $watcherCounts[$row->wl_namespace][$row->wl_title] = (int)$row->watchers;
503 }
504
505 return $watcherCounts;
506 }
507
508 /**
509 * Generates condition for the query used in a batch count visiting watchers.
510 *
511 * @param IDatabase $db
512 * @param array $targetsWithVisitThresholds array of pairs (LinkTarget, last visit threshold)
513 * @return string
514 */
515 private function getVisitingWatchersCondition(
516 IDatabase $db,
517 array $targetsWithVisitThresholds
518 ) {
519 $missingTargets = [];
520 $namespaceConds = [];
521 foreach ( $targetsWithVisitThresholds as list( $target, $threshold ) ) {
522 if ( $threshold === null ) {
523 $missingTargets[] = $target;
524 continue;
525 }
526 /* @var LinkTarget $target */
527 $namespaceConds[$target->getNamespace()][] = $db->makeList( [
528 'wl_title = ' . $db->addQuotes( $target->getDBkey() ),
529 $db->makeList( [
530 'wl_notificationtimestamp >= ' . $db->addQuotes( $db->timestamp( $threshold ) ),
531 'wl_notificationtimestamp IS NULL'
532 ], LIST_OR )
533 ], LIST_AND );
534 }
535
536 $conds = [];
537 foreach ( $namespaceConds as $namespace => $pageConds ) {
538 $conds[] = $db->makeList( [
539 'wl_namespace = ' . $namespace,
540 '(' . $db->makeList( $pageConds, LIST_OR ) . ')'
541 ], LIST_AND );
542 }
543
544 if ( $missingTargets ) {
545 $lb = new LinkBatch( $missingTargets );
546 $conds[] = $lb->constructSet( 'wl', $db );
547 }
548
549 return $db->makeList( $conds, LIST_OR );
550 }
551
552 /**
553 * @since 1.27
554 * @param UserIdentity $user
555 * @param LinkTarget $target
556 * @return WatchedItem|false
557 */
558 public function getWatchedItem( UserIdentity $user, LinkTarget $target ) {
559 if ( !$user->isRegistered() ) {
560 return false;
561 }
562
563 $cached = $this->getCached( $user, $target );
564 if ( $cached ) {
565 $this->stats->increment( 'WatchedItemStore.getWatchedItem.cached' );
566 return $cached;
567 }
568 $this->stats->increment( 'WatchedItemStore.getWatchedItem.load' );
569 return $this->loadWatchedItem( $user, $target );
570 }
571
572 /**
573 * @since 1.27
574 * @param UserIdentity $user
575 * @param LinkTarget $target
576 * @return WatchedItem|false
577 */
578 public function loadWatchedItem( UserIdentity $user, LinkTarget $target ) {
579 // Only registered user can have a watchlist
580 if ( !$user->isRegistered() ) {
581 return false;
582 }
583
584 $dbr = $this->getConnectionRef( DB_REPLICA );
585
586 $row = $dbr->selectRow(
587 'watchlist',
588 'wl_notificationtimestamp',
589 $this->dbCond( $user, $target ),
590 __METHOD__
591 );
592
593 if ( !$row ) {
594 return false;
595 }
596
597 $item = new WatchedItem(
598 $user,
599 $target,
600 $this->getLatestNotificationTimestamp( $row->wl_notificationtimestamp, $user, $target )
601 );
602 $this->cache( $item );
603
604 return $item;
605 }
606
607 /**
608 * @since 1.27
609 * @param UserIdentity $user
610 * @param array $options
611 * @return WatchedItem[]
612 */
613 public function getWatchedItemsForUser( UserIdentity $user, array $options = [] ) {
614 $options += [ 'forWrite' => false ];
615
616 $dbOptions = [];
617 if ( array_key_exists( 'sort', $options ) ) {
618 Assert::parameter(
619 ( in_array( $options['sort'], [ self::SORT_ASC, self::SORT_DESC ] ) ),
620 '$options[\'sort\']',
621 'must be SORT_ASC or SORT_DESC'
622 );
623 $dbOptions['ORDER BY'] = [
624 "wl_namespace {$options['sort']}",
625 "wl_title {$options['sort']}"
626 ];
627 }
628 $db = $this->getConnectionRef( $options['forWrite'] ? DB_MASTER : DB_REPLICA );
629
630 $res = $db->select(
631 'watchlist',
632 [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
633 [ 'wl_user' => $user->getId() ],
634 __METHOD__,
635 $dbOptions
636 );
637
638 $watchedItems = [];
639 foreach ( $res as $row ) {
640 $target = new TitleValue( (int)$row->wl_namespace, $row->wl_title );
641 // @todo: Should we add these to the process cache?
642 $watchedItems[] = new WatchedItem(
643 $user,
644 $target,
645 $this->getLatestNotificationTimestamp(
646 $row->wl_notificationtimestamp, $user, $target )
647 );
648 }
649
650 return $watchedItems;
651 }
652
653 /**
654 * @since 1.27
655 * @param UserIdentity $user
656 * @param LinkTarget $target
657 * @return bool
658 */
659 public function isWatched( UserIdentity $user, LinkTarget $target ) {
660 return (bool)$this->getWatchedItem( $user, $target );
661 }
662
663 /**
664 * @since 1.27
665 * @param UserIdentity $user
666 * @param LinkTarget[] $targets
667 * @return array
668 */
669 public function getNotificationTimestampsBatch( UserIdentity $user, array $targets ) {
670 $timestamps = [];
671 foreach ( $targets as $target ) {
672 $timestamps[$target->getNamespace()][$target->getDBkey()] = false;
673 }
674
675 if ( !$user->isRegistered() ) {
676 return $timestamps;
677 }
678
679 $targetsToLoad = [];
680 foreach ( $targets as $target ) {
681 $cachedItem = $this->getCached( $user, $target );
682 if ( $cachedItem ) {
683 $timestamps[$target->getNamespace()][$target->getDBkey()] =
684 $cachedItem->getNotificationTimestamp();
685 } else {
686 $targetsToLoad[] = $target;
687 }
688 }
689
690 if ( !$targetsToLoad ) {
691 return $timestamps;
692 }
693
694 $dbr = $this->getConnectionRef( DB_REPLICA );
695
696 $lb = new LinkBatch( $targetsToLoad );
697 $res = $dbr->select(
698 'watchlist',
699 [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
700 [
701 $lb->constructSet( 'wl', $dbr ),
702 'wl_user' => $user->getId(),
703 ],
704 __METHOD__
705 );
706
707 foreach ( $res as $row ) {
708 $target = new TitleValue( (int)$row->wl_namespace, $row->wl_title );
709 $timestamps[$row->wl_namespace][$row->wl_title] =
710 $this->getLatestNotificationTimestamp(
711 $row->wl_notificationtimestamp, $user, $target );
712 }
713
714 return $timestamps;
715 }
716
717 /**
718 * @since 1.27
719 * @param UserIdentity $user
720 * @param LinkTarget $target
721 * @throws MWException
722 */
723 public function addWatch( UserIdentity $user, LinkTarget $target ) {
724 $this->addWatchBatchForUser( $user, [ $target ] );
725 }
726
727 /**
728 * @since 1.27
729 * @param UserIdentity $user
730 * @param LinkTarget[] $targets
731 * @return bool
732 * @throws MWException
733 */
734 public function addWatchBatchForUser( UserIdentity $user, array $targets ) {
735 if ( $this->readOnlyMode->isReadOnly() ) {
736 return false;
737 }
738 // Only registered user can have a watchlist
739 if ( !$user->isRegistered() ) {
740 return false;
741 }
742
743 if ( !$targets ) {
744 return true;
745 }
746
747 $rows = [];
748 $items = [];
749 foreach ( $targets as $target ) {
750 $rows[] = [
751 'wl_user' => $user->getId(),
752 'wl_namespace' => $target->getNamespace(),
753 'wl_title' => $target->getDBkey(),
754 'wl_notificationtimestamp' => null,
755 ];
756 $items[] = new WatchedItem(
757 $user,
758 $target,
759 null
760 );
761 $this->uncache( $user, $target );
762 }
763
764 $dbw = $this->getConnectionRef( DB_MASTER );
765 $ticket = count( $targets ) > $this->updateRowsPerQuery ?
766 $this->lbFactory->getEmptyTransactionTicket( __METHOD__ ) : null;
767 $affectedRows = 0;
768 $rowBatches = array_chunk( $rows, $this->updateRowsPerQuery );
769 foreach ( $rowBatches as $toInsert ) {
770 // Use INSERT IGNORE to avoid overwriting the notification timestamp
771 // if there's already an entry for this page
772 $dbw->insert( 'watchlist', $toInsert, __METHOD__, [ 'IGNORE' ] );
773 $affectedRows += $dbw->affectedRows();
774 if ( $ticket ) {
775 $this->lbFactory->commitAndWaitForReplication( __METHOD__, $ticket );
776 }
777 }
778 // Update process cache to ensure skin doesn't claim that the current
779 // page is unwatched in the response of action=watch itself (T28292).
780 // This would otherwise be re-queried from a replica by isWatched().
781 foreach ( $items as $item ) {
782 $this->cache( $item );
783 }
784
785 return (bool)$affectedRows;
786 }
787
788 /**
789 * @since 1.27
790 * @param UserIdentity $user
791 * @param LinkTarget $target
792 * @return bool
793 * @throws MWException
794 */
795 public function removeWatch( UserIdentity $user, LinkTarget $target ) {
796 return $this->removeWatchBatchForUser( $user, [ $target ] );
797 }
798
799 /**
800 * Set the "last viewed" timestamps for certain titles on a user's watchlist.
801 *
802 * If the $targets parameter is omitted or set to [], this method simply wraps
803 * resetAllNotificationTimestampsForUser(), and in that case you should instead call that method
804 * directly; support for omitting $targets is for backwards compatibility.
805 *
806 * If $targets is omitted or set to [], timestamps will be updated for every title on the user's
807 * watchlist, and this will be done through a DeferredUpdate. If $targets is a non-empty array,
808 * only the specified titles will be updated, and this will be done immediately (not deferred).
809 *
810 * @since 1.27
811 * @param UserIdentity $user
812 * @param string|int $timestamp Value to set the "last viewed" timestamp to (null to clear)
813 * @param LinkTarget[] $targets Titles to set the timestamp for; [] means the entire watchlist
814 * @return bool
815 */
816 public function setNotificationTimestampsForUser(
817 UserIdentity $user, $timestamp, array $targets = []
818 ) {
819 // Only registered user can have a watchlist
820 if ( !$user->isRegistered() || $this->readOnlyMode->isReadOnly() ) {
821 return false;
822 }
823
824 if ( !$targets ) {
825 // Backwards compatibility
826 $this->resetAllNotificationTimestampsForUser( $user, $timestamp );
827 return true;
828 }
829
830 $rows = $this->getTitleDbKeysGroupedByNamespace( $targets );
831
832 $dbw = $this->getConnectionRef( DB_MASTER );
833 if ( $timestamp !== null ) {
834 $timestamp = $dbw->timestamp( $timestamp );
835 }
836 $ticket = $this->lbFactory->getEmptyTransactionTicket( __METHOD__ );
837 $affectedSinceWait = 0;
838
839 // Batch update items per namespace
840 foreach ( $rows as $namespace => $namespaceTitles ) {
841 $rowBatches = array_chunk( $namespaceTitles, $this->updateRowsPerQuery );
842 foreach ( $rowBatches as $toUpdate ) {
843 $dbw->update(
844 'watchlist',
845 [ 'wl_notificationtimestamp' => $timestamp ],
846 [
847 'wl_user' => $user->getId(),
848 'wl_namespace' => $namespace,
849 'wl_title' => $toUpdate
850 ]
851 );
852 $affectedSinceWait += $dbw->affectedRows();
853 // Wait for replication every time we've touched updateRowsPerQuery rows
854 if ( $affectedSinceWait >= $this->updateRowsPerQuery ) {
855 $this->lbFactory->commitAndWaitForReplication( __METHOD__, $ticket );
856 $affectedSinceWait = 0;
857 }
858 }
859 }
860
861 $this->uncacheUser( $user );
862
863 return true;
864 }
865
866 public function getLatestNotificationTimestamp(
867 $timestamp, UserIdentity $user, LinkTarget $target
868 ) {
869 $timestamp = wfTimestampOrNull( TS_MW, $timestamp );
870 if ( $timestamp === null ) {
871 return null; // no notification
872 }
873
874 $seenTimestamps = $this->getPageSeenTimestamps( $user );
875 if (
876 $seenTimestamps &&
877 $seenTimestamps->get( $this->getPageSeenKey( $target ) ) >= $timestamp
878 ) {
879 // If a reset job did not yet run, then the "seen" timestamp will be higher
880 return null;
881 }
882
883 return $timestamp;
884 }
885
886 /**
887 * Schedule a DeferredUpdate that sets all of the "last viewed" timestamps for a given user
888 * to the same value.
889 * @param UserIdentity $user
890 * @param string|int|null $timestamp Value to set all timestamps to, null to clear them
891 */
892 public function resetAllNotificationTimestampsForUser( UserIdentity $user, $timestamp = null ) {
893 // Only registered user can have a watchlist
894 if ( !$user->isRegistered() ) {
895 return;
896 }
897
898 // If the page is watched by the user (or may be watched), update the timestamp
899 $job = new ClearWatchlistNotificationsJob( [
900 'userId' => $user->getId(), 'timestamp' => $timestamp, 'casTime' => time()
901 ] );
902
903 // Try to run this post-send
904 // Calls DeferredUpdates::addCallableUpdate in normal operation
905 call_user_func(
906 $this->deferredUpdatesAddCallableUpdateCallback,
907 function () use ( $job ) {
908 $job->run();
909 }
910 );
911 }
912
913 /**
914 * @since 1.27
915 * @param UserIdentity $editor
916 * @param LinkTarget $target
917 * @param string|int $timestamp
918 * @return int[]
919 */
920 public function updateNotificationTimestamp(
921 UserIdentity $editor, LinkTarget $target, $timestamp
922 ) {
923 $dbw = $this->getConnectionRef( DB_MASTER );
924 $uids = $dbw->selectFieldValues(
925 'watchlist',
926 'wl_user',
927 [
928 'wl_user != ' . intval( $editor->getId() ),
929 'wl_namespace' => $target->getNamespace(),
930 'wl_title' => $target->getDBkey(),
931 'wl_notificationtimestamp IS NULL',
932 ],
933 __METHOD__
934 );
935
936 $watchers = array_map( 'intval', $uids );
937 if ( $watchers ) {
938 // Update wl_notificationtimestamp for all watching users except the editor
939 $fname = __METHOD__;
940 DeferredUpdates::addCallableUpdate(
941 function () use ( $timestamp, $watchers, $target, $fname ) {
942 $dbw = $this->getConnectionRef( DB_MASTER );
943 $ticket = $this->lbFactory->getEmptyTransactionTicket( $fname );
944
945 $watchersChunks = array_chunk( $watchers, $this->updateRowsPerQuery );
946 foreach ( $watchersChunks as $watchersChunk ) {
947 $dbw->update( 'watchlist',
948 [ /* SET */
949 'wl_notificationtimestamp' => $dbw->timestamp( $timestamp )
950 ], [ /* WHERE - TODO Use wl_id T130067 */
951 'wl_user' => $watchersChunk,
952 'wl_namespace' => $target->getNamespace(),
953 'wl_title' => $target->getDBkey(),
954 ], $fname
955 );
956 if ( count( $watchersChunks ) > 1 ) {
957 $this->lbFactory->commitAndWaitForReplication(
958 $fname, $ticket, [ 'domain' => $dbw->getDomainID() ]
959 );
960 }
961 }
962 $this->uncacheLinkTarget( $target );
963 },
964 DeferredUpdates::POSTSEND,
965 $dbw
966 );
967 }
968
969 return $watchers;
970 }
971
972 /**
973 * @since 1.27
974 * @param UserIdentity $user
975 * @param LinkTarget $title
976 * @param string $force
977 * @param int $oldid
978 * @return bool
979 */
980 public function resetNotificationTimestamp(
981 UserIdentity $user, LinkTarget $title, $force = '', $oldid = 0
982 ) {
983 $time = time();
984
985 // Only registered user can have a watchlist
986 if ( $this->readOnlyMode->isReadOnly() || !$user->isRegistered() ) {
987 return false;
988 }
989
990 // Hook expects User and Title, not UserIdentity and LinkTarget
991 $userObj = User::newFromId( $user->getId() );
992 $titleObj = Title::castFromLinkTarget( $title );
993 if ( !Hooks::run( 'BeforeResetNotificationTimestamp',
994 [ &$userObj, &$titleObj, $force, &$oldid ] )
995 ) {
996 return false;
997 }
998 if ( !$userObj->equals( $user ) ) {
999 $user = $userObj;
1000 }
1001 if ( !$titleObj->equals( $title ) ) {
1002 $title = $titleObj;
1003 }
1004
1005 $item = null;
1006 if ( $force != 'force' ) {
1007 $item = $this->loadWatchedItem( $user, $title );
1008 if ( !$item || $item->getNotificationTimestamp() === null ) {
1009 return false;
1010 }
1011 }
1012
1013 // Get the timestamp (TS_MW) of this revision to track the latest one seen
1014 $id = $oldid;
1015 $seenTime = null;
1016 if ( !$id ) {
1017 $latestRev = $this->revisionLookup->getRevisionByTitle( $title );
1018 if ( $latestRev ) {
1019 $id = $latestRev->getId();
1020 // Save a DB query
1021 $seenTime = $latestRev->getTimestamp();
1022 }
1023 }
1024 if ( $seenTime === null ) {
1025 $seenTime = $this->revisionLookup->getTimestampFromId( $id );
1026 }
1027
1028 // Mark the item as read immediately in lightweight storage
1029 $this->stash->merge(
1030 $this->getPageSeenTimestampsKey( $user ),
1031 function ( $cache, $key, $current ) use ( $title, $seenTime ) {
1032 $value = $current ?: new MapCacheLRU( 300 );
1033 $subKey = $this->getPageSeenKey( $title );
1034
1035 if ( $seenTime > $value->get( $subKey ) ) {
1036 // Revision is newer than the last one seen
1037 $value->set( $subKey, $seenTime );
1038 $this->latestUpdateCache->set( $key, $value, IExpiringStore::TTL_PROC_LONG );
1039 } elseif ( $seenTime === false ) {
1040 // Revision does not exist
1041 $value->set( $subKey, wfTimestamp( TS_MW ) );
1042 $this->latestUpdateCache->set( $key, $value, IExpiringStore::TTL_PROC_LONG );
1043 } else {
1044 return false; // nothing to update
1045 }
1046
1047 return $value;
1048 },
1049 IExpiringStore::TTL_HOUR
1050 );
1051
1052 // If the page is watched by the user (or may be watched), update the timestamp
1053 $job = new ActivityUpdateJob(
1054 $title,
1055 [
1056 'type' => 'updateWatchlistNotification',
1057 'userid' => $user->getId(),
1058 'notifTime' => $this->getNotificationTimestamp( $user, $title, $item, $force, $oldid ),
1059 'curTime' => $time
1060 ]
1061 );
1062 // Try to enqueue this post-send
1063 $this->queueGroup->lazyPush( $job );
1064
1065 $this->uncache( $user, $title );
1066
1067 return true;
1068 }
1069
1070 /**
1071 * @param UserIdentity $user
1072 * @return MapCacheLRU|null The map contains prefixed title keys and TS_MW values
1073 */
1074 private function getPageSeenTimestamps( UserIdentity $user ) {
1075 $key = $this->getPageSeenTimestampsKey( $user );
1076
1077 return $this->latestUpdateCache->getWithSetCallback(
1078 $key,
1079 IExpiringStore::TTL_PROC_LONG,
1080 function () use ( $key ) {
1081 return $this->stash->get( $key ) ?: null;
1082 }
1083 );
1084 }
1085
1086 /**
1087 * @param UserIdentity $user
1088 * @return string
1089 */
1090 private function getPageSeenTimestampsKey( UserIdentity $user ) {
1091 return $this->stash->makeGlobalKey(
1092 'watchlist-recent-updates',
1093 $this->lbFactory->getLocalDomainID(),
1094 $user->getId()
1095 );
1096 }
1097
1098 /**
1099 * @param LinkTarget $target
1100 * @return string
1101 */
1102 private function getPageSeenKey( LinkTarget $target ) {
1103 return "{$target->getNamespace()}:{$target->getDBkey()}";
1104 }
1105
1106 /**
1107 * @param UserIdentity $user
1108 * @param LinkTarget $title
1109 * @param WatchedItem $item
1110 * @param bool $force
1111 * @param int|bool $oldid The ID of the last revision that the user viewed
1112 * @return bool|string|null
1113 */
1114 private function getNotificationTimestamp(
1115 UserIdentity $user, LinkTarget $title, $item, $force, $oldid
1116 ) {
1117 if ( !$oldid ) {
1118 // No oldid given, assuming latest revision; clear the timestamp.
1119 return null;
1120 }
1121
1122 $oldRev = $this->revisionLookup->getRevisionById( $oldid );
1123 if ( !$oldRev ) {
1124 // Oldid given but does not exist (probably deleted)
1125 return false;
1126 }
1127
1128 $nextRev = $this->revisionLookup->getNextRevision( $oldRev );
1129 if ( !$nextRev ) {
1130 // Oldid given and is the latest revision for this title; clear the timestamp.
1131 return null;
1132 }
1133
1134 if ( $item === null ) {
1135 $item = $this->loadWatchedItem( $user, $title );
1136 }
1137
1138 if ( !$item ) {
1139 // This can only happen if $force is enabled.
1140 return null;
1141 }
1142
1143 // Oldid given and isn't the latest; update the timestamp.
1144 // This will result in no further notification emails being sent!
1145 $notificationTimestamp = $this->revisionLookup->getTimestampFromId( $oldid );
1146 // @FIXME: this should use getTimestamp() for consistency with updates on new edits
1147 // $notificationTimestamp = $nextRev->getTimestamp(); // first unseen revision timestamp
1148
1149 // We need to go one second to the future because of various strict comparisons
1150 // throughout the codebase
1151 $ts = new MWTimestamp( $notificationTimestamp );
1152 $ts->timestamp->add( new DateInterval( 'PT1S' ) );
1153 $notificationTimestamp = $ts->getTimestamp( TS_MW );
1154
1155 if ( $notificationTimestamp < $item->getNotificationTimestamp() ) {
1156 if ( $force != 'force' ) {
1157 return false;
1158 } else {
1159 // This is a little silly…
1160 return $item->getNotificationTimestamp();
1161 }
1162 }
1163
1164 return $notificationTimestamp;
1165 }
1166
1167 /**
1168 * @since 1.27
1169 * @param UserIdentity $user
1170 * @param int|null $unreadLimit
1171 * @return int|bool
1172 */
1173 public function countUnreadNotifications( UserIdentity $user, $unreadLimit = null ) {
1174 $dbr = $this->getConnectionRef( DB_REPLICA );
1175
1176 $queryOptions = [];
1177 if ( $unreadLimit !== null ) {
1178 $unreadLimit = (int)$unreadLimit;
1179 $queryOptions['LIMIT'] = $unreadLimit;
1180 }
1181
1182 $conds = [
1183 'wl_user' => $user->getId(),
1184 'wl_notificationtimestamp IS NOT NULL'
1185 ];
1186
1187 $rowCount = $dbr->selectRowCount( 'watchlist', '1', $conds, __METHOD__, $queryOptions );
1188
1189 if ( $unreadLimit === null ) {
1190 return $rowCount;
1191 }
1192
1193 if ( $rowCount >= $unreadLimit ) {
1194 return true;
1195 }
1196
1197 return $rowCount;
1198 }
1199
1200 /**
1201 * @since 1.27
1202 * @param LinkTarget $oldTarget
1203 * @param LinkTarget $newTarget
1204 */
1205 public function duplicateAllAssociatedEntries( LinkTarget $oldTarget, LinkTarget $newTarget ) {
1206 // Duplicate first the subject page, then the talk page
1207 $this->duplicateEntry(
1208 $this->nsInfo->getSubjectPage( $oldTarget ),
1209 $this->nsInfo->getSubjectPage( $newTarget )
1210 );
1211 $this->duplicateEntry(
1212 $this->nsInfo->getTalkPage( $oldTarget ),
1213 $this->nsInfo->getTalkPage( $newTarget )
1214 );
1215 }
1216
1217 /**
1218 * @since 1.27
1219 * @param LinkTarget $oldTarget
1220 * @param LinkTarget $newTarget
1221 */
1222 public function duplicateEntry( LinkTarget $oldTarget, LinkTarget $newTarget ) {
1223 $dbw = $this->getConnectionRef( DB_MASTER );
1224
1225 $result = $dbw->select(
1226 'watchlist',
1227 [ 'wl_user', 'wl_notificationtimestamp' ],
1228 [
1229 'wl_namespace' => $oldTarget->getNamespace(),
1230 'wl_title' => $oldTarget->getDBkey(),
1231 ],
1232 __METHOD__,
1233 [ 'FOR UPDATE' ]
1234 );
1235
1236 $newNamespace = $newTarget->getNamespace();
1237 $newDBkey = $newTarget->getDBkey();
1238
1239 # Construct array to replace into the watchlist
1240 $values = [];
1241 foreach ( $result as $row ) {
1242 $values[] = [
1243 'wl_user' => $row->wl_user,
1244 'wl_namespace' => $newNamespace,
1245 'wl_title' => $newDBkey,
1246 'wl_notificationtimestamp' => $row->wl_notificationtimestamp,
1247 ];
1248 }
1249
1250 if ( !empty( $values ) ) {
1251 # Perform replace
1252 # Note that multi-row replace is very efficient for MySQL but may be inefficient for
1253 # some other DBMSes, mostly due to poor simulation by us
1254 $dbw->replace(
1255 'watchlist',
1256 [ [ 'wl_user', 'wl_namespace', 'wl_title' ] ],
1257 $values,
1258 __METHOD__
1259 );
1260 }
1261 }
1262
1263 /**
1264 * @param LinkTarget[] $titles
1265 * @return array
1266 */
1267 private function getTitleDbKeysGroupedByNamespace( array $titles ) {
1268 $rows = [];
1269 foreach ( $titles as $title ) {
1270 // Group titles by namespace.
1271 $rows[ $title->getNamespace() ][] = $title->getDBkey();
1272 }
1273 return $rows;
1274 }
1275
1276 /**
1277 * @param UserIdentity $user
1278 * @param LinkTarget[] $titles
1279 */
1280 private function uncacheTitlesForUser( UserIdentity $user, array $titles ) {
1281 foreach ( $titles as $title ) {
1282 $this->uncache( $user, $title );
1283 }
1284 }
1285
1286 }