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