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