]> scripts.mit.edu Git - autoinstallsdev/mediawiki.git/blob - includes/WatchedItemStore.php
MediaWiki 1.30.2
[autoinstallsdev/mediawiki.git] / includes / WatchedItemStore.php
1 <?php
2
3 use Wikimedia\Rdbms\IDatabase;
4 use Liuggio\StatsdClient\Factory\StatsdDataFactoryInterface;
5 use MediaWiki\Linker\LinkTarget;
6 use MediaWiki\MediaWikiServices;
7 use Wikimedia\Assert\Assert;
8 use Wikimedia\ScopedCallback;
9 use Wikimedia\Rdbms\LoadBalancer;
10 use Wikimedia\Rdbms\DBUnexpectedError;
11
12 /**
13  * Storage layer class for WatchedItems.
14  * Database interaction.
15  *
16  * Uses database because this uses User::isAnon
17  *
18  * @group Database
19  *
20  * @author Addshore
21  * @since 1.27
22  */
23 class WatchedItemStore implements StatsdAwareInterface {
24
25         const SORT_DESC = 'DESC';
26         const SORT_ASC = 'ASC';
27
28         /**
29          * @var LoadBalancer
30          */
31         private $loadBalancer;
32
33         /**
34          * @var ReadOnlyMode
35          */
36         private $readOnlyMode;
37
38         /**
39          * @var HashBagOStuff
40          */
41         private $cache;
42
43         /**
44          * @var array[] Looks like $cacheIndex[Namespace ID][Target DB Key][User Id] => 'key'
45          * The index is needed so that on mass changes all relevant items can be un-cached.
46          * For example: Clearing a users watchlist of all items or updating notification timestamps
47          *              for all users watching a single target.
48          */
49         private $cacheIndex = [];
50
51         /**
52          * @var callable|null
53          */
54         private $deferredUpdatesAddCallableUpdateCallback;
55
56         /**
57          * @var callable|null
58          */
59         private $revisionGetTimestampFromIdCallback;
60
61         /**
62          * @var StatsdDataFactoryInterface
63          */
64         private $stats;
65
66         /**
67          * @param LoadBalancer $loadBalancer
68          * @param HashBagOStuff $cache
69          * @param ReadOnlyMode $readOnlyMode
70          */
71         public function __construct(
72                 LoadBalancer $loadBalancer,
73                 HashBagOStuff $cache,
74                 ReadOnlyMode $readOnlyMode
75         ) {
76                 $this->loadBalancer = $loadBalancer;
77                 $this->cache = $cache;
78                 $this->readOnlyMode = $readOnlyMode;
79                 $this->stats = new NullStatsdDataFactory();
80                 $this->deferredUpdatesAddCallableUpdateCallback = [ 'DeferredUpdates', 'addCallableUpdate' ];
81                 $this->revisionGetTimestampFromIdCallback = [ 'Revision', 'getTimestampFromId' ];
82         }
83
84         public function setStatsdDataFactory( StatsdDataFactoryInterface $stats ) {
85                 $this->stats = $stats;
86         }
87
88         /**
89          * Overrides the DeferredUpdates::addCallableUpdate callback
90          * This is intended for use while testing and will fail if MW_PHPUNIT_TEST is not defined.
91          *
92          * @param callable $callback
93          *
94          * @see DeferredUpdates::addCallableUpdate for callback signiture
95          *
96          * @return ScopedCallback to reset the overridden value
97          * @throws MWException
98          */
99         public function overrideDeferredUpdatesAddCallableUpdateCallback( callable $callback ) {
100                 if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
101                         throw new MWException(
102                                 'Cannot override DeferredUpdates::addCallableUpdate callback in operation.'
103                         );
104                 }
105                 $previousValue = $this->deferredUpdatesAddCallableUpdateCallback;
106                 $this->deferredUpdatesAddCallableUpdateCallback = $callback;
107                 return new ScopedCallback( function () use ( $previousValue ) {
108                         $this->deferredUpdatesAddCallableUpdateCallback = $previousValue;
109                 } );
110         }
111
112         /**
113          * Overrides the Revision::getTimestampFromId callback
114          * This is intended for use while testing and will fail if MW_PHPUNIT_TEST is not defined.
115          *
116          * @param callable $callback
117          * @see Revision::getTimestampFromId for callback signiture
118          *
119          * @return ScopedCallback to reset the overridden value
120          * @throws MWException
121          */
122         public function overrideRevisionGetTimestampFromIdCallback( callable $callback ) {
123                 if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
124                         throw new MWException(
125                                 'Cannot override Revision::getTimestampFromId callback in operation.'
126                         );
127                 }
128                 $previousValue = $this->revisionGetTimestampFromIdCallback;
129                 $this->revisionGetTimestampFromIdCallback = $callback;
130                 return new ScopedCallback( function () use ( $previousValue ) {
131                         $this->revisionGetTimestampFromIdCallback = $previousValue;
132                 } );
133         }
134
135         private function getCacheKey( User $user, LinkTarget $target ) {
136                 return $this->cache->makeKey(
137                         (string)$target->getNamespace(),
138                         $target->getDBkey(),
139                         (string)$user->getId()
140                 );
141         }
142
143         private function cache( WatchedItem $item ) {
144                 $user = $item->getUser();
145                 $target = $item->getLinkTarget();
146                 $key = $this->getCacheKey( $user, $target );
147                 $this->cache->set( $key, $item );
148                 $this->cacheIndex[$target->getNamespace()][$target->getDBkey()][$user->getId()] = $key;
149                 $this->stats->increment( 'WatchedItemStore.cache' );
150         }
151
152         private function uncache( User $user, LinkTarget $target ) {
153                 $this->cache->delete( $this->getCacheKey( $user, $target ) );
154                 unset( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()][$user->getId()] );
155                 $this->stats->increment( 'WatchedItemStore.uncache' );
156         }
157
158         private function uncacheLinkTarget( LinkTarget $target ) {
159                 $this->stats->increment( 'WatchedItemStore.uncacheLinkTarget' );
160                 if ( !isset( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()] ) ) {
161                         return;
162                 }
163                 foreach ( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()] as $key ) {
164                         $this->stats->increment( 'WatchedItemStore.uncacheLinkTarget.items' );
165                         $this->cache->delete( $key );
166                 }
167         }
168
169         private function uncacheUser( User $user ) {
170                 $this->stats->increment( 'WatchedItemStore.uncacheUser' );
171                 foreach ( $this->cacheIndex as $ns => $dbKeyArray ) {
172                         foreach ( $dbKeyArray as $dbKey => $userArray ) {
173                                 if ( isset( $userArray[$user->getId()] ) ) {
174                                         $this->stats->increment( 'WatchedItemStore.uncacheUser.items' );
175                                         $this->cache->delete( $userArray[$user->getId()] );
176                                 }
177                         }
178                 }
179         }
180
181         /**
182          * @param User $user
183          * @param LinkTarget $target
184          *
185          * @return WatchedItem|false
186          */
187         private function getCached( User $user, LinkTarget $target ) {
188                 return $this->cache->get( $this->getCacheKey( $user, $target ) );
189         }
190
191         /**
192          * Return an array of conditions to select or update the appropriate database
193          * row.
194          *
195          * @param User $user
196          * @param LinkTarget $target
197          *
198          * @return array
199          */
200         private function dbCond( User $user, LinkTarget $target ) {
201                 return [
202                         'wl_user' => $user->getId(),
203                         'wl_namespace' => $target->getNamespace(),
204                         'wl_title' => $target->getDBkey(),
205                 ];
206         }
207
208         /**
209          * @param int $dbIndex DB_MASTER or DB_REPLICA
210          *
211          * @return IDatabase
212          * @throws MWException
213          */
214         private function getConnectionRef( $dbIndex ) {
215                 return $this->loadBalancer->getConnectionRef( $dbIndex, [ 'watchlist' ] );
216         }
217
218         /**
219          * Count the number of individual items that are watched by the user.
220          * If a subject and corresponding talk page are watched this will return 2.
221          *
222          * @param User $user
223          *
224          * @return int
225          */
226         public function countWatchedItems( User $user ) {
227                 $dbr = $this->getConnectionRef( DB_REPLICA );
228                 $return = (int)$dbr->selectField(
229                         'watchlist',
230                         'COUNT(*)',
231                         [
232                                 'wl_user' => $user->getId()
233                         ],
234                         __METHOD__
235                 );
236
237                 return $return;
238         }
239
240         /**
241          * @param LinkTarget $target
242          *
243          * @return int
244          */
245         public function countWatchers( LinkTarget $target ) {
246                 $dbr = $this->getConnectionRef( DB_REPLICA );
247                 $return = (int)$dbr->selectField(
248                         'watchlist',
249                         'COUNT(*)',
250                         [
251                                 'wl_namespace' => $target->getNamespace(),
252                                 'wl_title' => $target->getDBkey(),
253                         ],
254                         __METHOD__
255                 );
256
257                 return $return;
258         }
259
260         /**
261          * Number of page watchers who also visited a "recent" edit
262          *
263          * @param LinkTarget $target
264          * @param mixed $threshold timestamp accepted by wfTimestamp
265          *
266          * @return int
267          * @throws DBUnexpectedError
268          * @throws MWException
269          */
270         public function countVisitingWatchers( LinkTarget $target, $threshold ) {
271                 $dbr = $this->getConnectionRef( DB_REPLICA );
272                 $visitingWatchers = (int)$dbr->selectField(
273                         'watchlist',
274                         'COUNT(*)',
275                         [
276                                 'wl_namespace' => $target->getNamespace(),
277                                 'wl_title' => $target->getDBkey(),
278                                 'wl_notificationtimestamp >= ' .
279                                 $dbr->addQuotes( $dbr->timestamp( $threshold ) ) .
280                                 ' OR wl_notificationtimestamp IS NULL'
281                         ],
282                         __METHOD__
283                 );
284
285                 return $visitingWatchers;
286         }
287
288         /**
289          * @param LinkTarget[] $targets
290          * @param array $options Allowed keys:
291          *        'minimumWatchers' => int
292          *
293          * @return array multi dimensional like $return[$namespaceId][$titleString] = int $watchers
294          *         All targets will be present in the result. 0 either means no watchers or the number
295          *         of watchers was below the minimumWatchers option if passed.
296          */
297         public function countWatchersMultiple( array $targets, array $options = [] ) {
298                 if ( $targets === [] ) {
299                         // No titles requested => no results returned
300                         return [];
301                 }
302
303                 $dbOptions = [ 'GROUP BY' => [ 'wl_namespace', 'wl_title' ] ];
304
305                 $dbr = $this->getConnectionRef( DB_REPLICA );
306
307                 if ( array_key_exists( 'minimumWatchers', $options ) ) {
308                         $dbOptions['HAVING'] = 'COUNT(*) >= ' . (int)$options['minimumWatchers'];
309                 }
310
311                 $lb = new LinkBatch( $targets );
312                 $res = $dbr->select(
313                         'watchlist',
314                         [ 'wl_title', 'wl_namespace', 'watchers' => 'COUNT(*)' ],
315                         [ $lb->constructSet( 'wl', $dbr ) ],
316                         __METHOD__,
317                         $dbOptions
318                 );
319
320                 $watchCounts = [];
321                 foreach ( $targets as $linkTarget ) {
322                         $watchCounts[$linkTarget->getNamespace()][$linkTarget->getDBkey()] = 0;
323                 }
324
325                 foreach ( $res as $row ) {
326                         $watchCounts[$row->wl_namespace][$row->wl_title] = (int)$row->watchers;
327                 }
328
329                 return $watchCounts;
330         }
331
332         /**
333          * Number of watchers of each page who have visited recent edits to that page
334          *
335          * @param array $targetsWithVisitThresholds array of pairs (LinkTarget $target, mixed $threshold),
336          *        $threshold is:
337          *        - a timestamp of the recent edit if $target exists (format accepted by wfTimestamp)
338          *        - null if $target doesn't exist
339          * @param int|null $minimumWatchers
340          * @return array multi-dimensional like $return[$namespaceId][$titleString] = $watchers,
341          *         where $watchers is an int:
342          *         - if the page exists, number of users watching who have visited the page recently
343          *         - if the page doesn't exist, number of users that have the page on their watchlist
344          *         - 0 means there are no visiting watchers or their number is below the minimumWatchers
345          *         option (if passed).
346          */
347         public function countVisitingWatchersMultiple(
348                 array $targetsWithVisitThresholds,
349                 $minimumWatchers = null
350         ) {
351                 $dbr = $this->getConnectionRef( DB_REPLICA );
352
353                 $conds = $this->getVisitingWatchersCondition( $dbr, $targetsWithVisitThresholds );
354
355                 $dbOptions = [ 'GROUP BY' => [ 'wl_namespace', 'wl_title' ] ];
356                 if ( $minimumWatchers !== null ) {
357                         $dbOptions['HAVING'] = 'COUNT(*) >= ' . (int)$minimumWatchers;
358                 }
359                 $res = $dbr->select(
360                         'watchlist',
361                         [ 'wl_namespace', 'wl_title', 'watchers' => 'COUNT(*)' ],
362                         $conds,
363                         __METHOD__,
364                         $dbOptions
365                 );
366
367                 $watcherCounts = [];
368                 foreach ( $targetsWithVisitThresholds as list( $target ) ) {
369                         /* @var LinkTarget $target */
370                         $watcherCounts[$target->getNamespace()][$target->getDBkey()] = 0;
371                 }
372
373                 foreach ( $res as $row ) {
374                         $watcherCounts[$row->wl_namespace][$row->wl_title] = (int)$row->watchers;
375                 }
376
377                 return $watcherCounts;
378         }
379
380         /**
381          * Generates condition for the query used in a batch count visiting watchers.
382          *
383          * @param IDatabase $db
384          * @param array $targetsWithVisitThresholds array of pairs (LinkTarget, last visit threshold)
385          * @return string
386          */
387         private function getVisitingWatchersCondition(
388                 IDatabase $db,
389                 array $targetsWithVisitThresholds
390         ) {
391                 $missingTargets = [];
392                 $namespaceConds = [];
393                 foreach ( $targetsWithVisitThresholds as list( $target, $threshold ) ) {
394                         if ( $threshold === null ) {
395                                 $missingTargets[] = $target;
396                                 continue;
397                         }
398                         /* @var LinkTarget $target */
399                         $namespaceConds[$target->getNamespace()][] = $db->makeList( [
400                                 'wl_title = ' . $db->addQuotes( $target->getDBkey() ),
401                                 $db->makeList( [
402                                         'wl_notificationtimestamp >= ' . $db->addQuotes( $db->timestamp( $threshold ) ),
403                                         'wl_notificationtimestamp IS NULL'
404                                 ], LIST_OR )
405                         ], LIST_AND );
406                 }
407
408                 $conds = [];
409                 foreach ( $namespaceConds as $namespace => $pageConds ) {
410                         $conds[] = $db->makeList( [
411                                 'wl_namespace = ' . $namespace,
412                                 '(' . $db->makeList( $pageConds, LIST_OR ) . ')'
413                         ], LIST_AND );
414                 }
415
416                 if ( $missingTargets ) {
417                         $lb = new LinkBatch( $missingTargets );
418                         $conds[] = $lb->constructSet( 'wl', $db );
419                 }
420
421                 return $db->makeList( $conds, LIST_OR );
422         }
423
424         /**
425          * Get an item (may be cached)
426          *
427          * @param User $user
428          * @param LinkTarget $target
429          *
430          * @return WatchedItem|false
431          */
432         public function getWatchedItem( User $user, LinkTarget $target ) {
433                 if ( $user->isAnon() ) {
434                         return false;
435                 }
436
437                 $cached = $this->getCached( $user, $target );
438                 if ( $cached ) {
439                         $this->stats->increment( 'WatchedItemStore.getWatchedItem.cached' );
440                         return $cached;
441                 }
442                 $this->stats->increment( 'WatchedItemStore.getWatchedItem.load' );
443                 return $this->loadWatchedItem( $user, $target );
444         }
445
446         /**
447          * Loads an item from the db
448          *
449          * @param User $user
450          * @param LinkTarget $target
451          *
452          * @return WatchedItem|false
453          */
454         public function loadWatchedItem( User $user, LinkTarget $target ) {
455                 // Only loggedin user can have a watchlist
456                 if ( $user->isAnon() ) {
457                         return false;
458                 }
459
460                 $dbr = $this->getConnectionRef( DB_REPLICA );
461                 $row = $dbr->selectRow(
462                         'watchlist',
463                         'wl_notificationtimestamp',
464                         $this->dbCond( $user, $target ),
465                         __METHOD__
466                 );
467
468                 if ( !$row ) {
469                         return false;
470                 }
471
472                 $item = new WatchedItem(
473                         $user,
474                         $target,
475                         wfTimestampOrNull( TS_MW, $row->wl_notificationtimestamp )
476                 );
477                 $this->cache( $item );
478
479                 return $item;
480         }
481
482         /**
483          * @param User $user
484          * @param array $options Allowed keys:
485          *        'forWrite' => bool defaults to false
486          *        'sort' => string optional sorting by namespace ID and title
487          *                     one of the self::SORT_* constants
488          *
489          * @return WatchedItem[]
490          */
491         public function getWatchedItemsForUser( User $user, array $options = [] ) {
492                 $options += [ 'forWrite' => false ];
493
494                 $dbOptions = [];
495                 if ( array_key_exists( 'sort', $options ) ) {
496                         Assert::parameter(
497                                 ( in_array( $options['sort'], [ self::SORT_ASC, self::SORT_DESC ] ) ),
498                                 '$options[\'sort\']',
499                                 'must be SORT_ASC or SORT_DESC'
500                         );
501                         $dbOptions['ORDER BY'] = [
502                                 "wl_namespace {$options['sort']}",
503                                 "wl_title {$options['sort']}"
504                         ];
505                 }
506                 $db = $this->getConnectionRef( $options['forWrite'] ? DB_MASTER : DB_REPLICA );
507
508                 $res = $db->select(
509                         'watchlist',
510                         [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
511                         [ 'wl_user' => $user->getId() ],
512                         __METHOD__,
513                         $dbOptions
514                 );
515
516                 $watchedItems = [];
517                 foreach ( $res as $row ) {
518                         // @todo: Should we add these to the process cache?
519                         $watchedItems[] = new WatchedItem(
520                                 $user,
521                                 new TitleValue( (int)$row->wl_namespace, $row->wl_title ),
522                                 $row->wl_notificationtimestamp
523                         );
524                 }
525
526                 return $watchedItems;
527         }
528
529         /**
530          * Must be called separately for Subject & Talk namespaces
531          *
532          * @param User $user
533          * @param LinkTarget $target
534          *
535          * @return bool
536          */
537         public function isWatched( User $user, LinkTarget $target ) {
538                 return (bool)$this->getWatchedItem( $user, $target );
539         }
540
541         /**
542          * @param User $user
543          * @param LinkTarget[] $targets
544          *
545          * @return array multi-dimensional like $return[$namespaceId][$titleString] = $timestamp,
546          *         where $timestamp is:
547          *         - string|null value of wl_notificationtimestamp,
548          *         - false if $target is not watched by $user.
549          */
550         public function getNotificationTimestampsBatch( User $user, array $targets ) {
551                 $timestamps = [];
552                 foreach ( $targets as $target ) {
553                         $timestamps[$target->getNamespace()][$target->getDBkey()] = false;
554                 }
555
556                 if ( $user->isAnon() ) {
557                         return $timestamps;
558                 }
559
560                 $targetsToLoad = [];
561                 foreach ( $targets as $target ) {
562                         $cachedItem = $this->getCached( $user, $target );
563                         if ( $cachedItem ) {
564                                 $timestamps[$target->getNamespace()][$target->getDBkey()] =
565                                         $cachedItem->getNotificationTimestamp();
566                         } else {
567                                 $targetsToLoad[] = $target;
568                         }
569                 }
570
571                 if ( !$targetsToLoad ) {
572                         return $timestamps;
573                 }
574
575                 $dbr = $this->getConnectionRef( DB_REPLICA );
576
577                 $lb = new LinkBatch( $targetsToLoad );
578                 $res = $dbr->select(
579                         'watchlist',
580                         [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
581                         [
582                                 $lb->constructSet( 'wl', $dbr ),
583                                 'wl_user' => $user->getId(),
584                         ],
585                         __METHOD__
586                 );
587
588                 foreach ( $res as $row ) {
589                         $timestamps[$row->wl_namespace][$row->wl_title] =
590                                 wfTimestampOrNull( TS_MW, $row->wl_notificationtimestamp );
591                 }
592
593                 return $timestamps;
594         }
595
596         /**
597          * Must be called separately for Subject & Talk namespaces
598          *
599          * @param User $user
600          * @param LinkTarget $target
601          */
602         public function addWatch( User $user, LinkTarget $target ) {
603                 $this->addWatchBatchForUser( $user, [ $target ] );
604         }
605
606         /**
607          * @param User $user
608          * @param LinkTarget[] $targets
609          *
610          * @return bool success
611          */
612         public function addWatchBatchForUser( User $user, array $targets ) {
613                 if ( $this->readOnlyMode->isReadOnly() ) {
614                         return false;
615                 }
616                 // Only loggedin user can have a watchlist
617                 if ( $user->isAnon() ) {
618                         return false;
619                 }
620
621                 if ( !$targets ) {
622                         return true;
623                 }
624
625                 $rows = [];
626                 $items = [];
627                 foreach ( $targets as $target ) {
628                         $rows[] = [
629                                 'wl_user' => $user->getId(),
630                                 'wl_namespace' => $target->getNamespace(),
631                                 'wl_title' => $target->getDBkey(),
632                                 'wl_notificationtimestamp' => null,
633                         ];
634                         $items[] = new WatchedItem(
635                                 $user,
636                                 $target,
637                                 null
638                         );
639                         $this->uncache( $user, $target );
640                 }
641
642                 $dbw = $this->getConnectionRef( DB_MASTER );
643                 foreach ( array_chunk( $rows, 100 ) as $toInsert ) {
644                         // Use INSERT IGNORE to avoid overwriting the notification timestamp
645                         // if there's already an entry for this page
646                         $dbw->insert( 'watchlist', $toInsert, __METHOD__, 'IGNORE' );
647                 }
648                 // Update process cache to ensure skin doesn't claim that the current
649                 // page is unwatched in the response of action=watch itself (T28292).
650                 // This would otherwise be re-queried from a slave by isWatched().
651                 foreach ( $items as $item ) {
652                         $this->cache( $item );
653                 }
654
655                 return true;
656         }
657
658         /**
659          * Removes the an entry for the User watching the LinkTarget
660          * Must be called separately for Subject & Talk namespaces
661          *
662          * @param User $user
663          * @param LinkTarget $target
664          *
665          * @return bool success
666          * @throws DBUnexpectedError
667          * @throws MWException
668          */
669         public function removeWatch( User $user, LinkTarget $target ) {
670                 // Only logged in user can have a watchlist
671                 if ( $this->readOnlyMode->isReadOnly() || $user->isAnon() ) {
672                         return false;
673                 }
674
675                 $this->uncache( $user, $target );
676
677                 $dbw = $this->getConnectionRef( DB_MASTER );
678                 $dbw->delete( 'watchlist',
679                         [
680                                 'wl_user' => $user->getId(),
681                                 'wl_namespace' => $target->getNamespace(),
682                                 'wl_title' => $target->getDBkey(),
683                         ], __METHOD__
684                 );
685                 $success = (bool)$dbw->affectedRows();
686
687                 return $success;
688         }
689
690         /**
691          * @param User $user The user to set the timestamp for
692          * @param string|null $timestamp Set the update timestamp to this value
693          * @param LinkTarget[] $targets List of targets to update. Default to all targets
694          *
695          * @return bool success
696          */
697         public function setNotificationTimestampsForUser( User $user, $timestamp, array $targets = [] ) {
698                 // Only loggedin user can have a watchlist
699                 if ( $user->isAnon() ) {
700                         return false;
701                 }
702
703                 $dbw = $this->getConnectionRef( DB_MASTER );
704
705                 $conds = [ 'wl_user' => $user->getId() ];
706                 if ( $targets ) {
707                         $batch = new LinkBatch( $targets );
708                         $conds[] = $batch->constructSet( 'wl', $dbw );
709                 }
710
711                 if ( $timestamp !== null ) {
712                         $timestamp = $dbw->timestamp( $timestamp );
713                 }
714
715                 $success = $dbw->update(
716                         'watchlist',
717                         [ 'wl_notificationtimestamp' => $timestamp ],
718                         $conds,
719                         __METHOD__
720                 );
721
722                 $this->uncacheUser( $user );
723
724                 return $success;
725         }
726
727         /**
728          * @param User $editor The editor that triggered the update. Their notification
729          *  timestamp will not be updated(they have already seen it)
730          * @param LinkTarget $target The target to update timestamps for
731          * @param string $timestamp Set the update timestamp to this value
732          *
733          * @return int[] Array of user IDs the timestamp has been updated for
734          */
735         public function updateNotificationTimestamp( User $editor, LinkTarget $target, $timestamp ) {
736                 $dbw = $this->getConnectionRef( DB_MASTER );
737                 $uids = $dbw->selectFieldValues(
738                         'watchlist',
739                         'wl_user',
740                         [
741                                 'wl_user != ' . intval( $editor->getId() ),
742                                 'wl_namespace' => $target->getNamespace(),
743                                 'wl_title' => $target->getDBkey(),
744                                 'wl_notificationtimestamp IS NULL',
745                         ],
746                         __METHOD__
747                 );
748
749                 $watchers = array_map( 'intval', $uids );
750                 if ( $watchers ) {
751                         // Update wl_notificationtimestamp for all watching users except the editor
752                         $fname = __METHOD__;
753                         DeferredUpdates::addCallableUpdate(
754                                 function () use ( $timestamp, $watchers, $target, $fname ) {
755                                         global $wgUpdateRowsPerQuery;
756
757                                         $dbw = $this->getConnectionRef( DB_MASTER );
758                                         $factory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
759                                         $ticket = $factory->getEmptyTransactionTicket( __METHOD__ );
760
761                                         $watchersChunks = array_chunk( $watchers, $wgUpdateRowsPerQuery );
762                                         foreach ( $watchersChunks as $watchersChunk ) {
763                                                 $dbw->update( 'watchlist',
764                                                         [ /* SET */
765                                                                 'wl_notificationtimestamp' => $dbw->timestamp( $timestamp )
766                                                         ], [ /* WHERE - TODO Use wl_id T130067 */
767                                                                 'wl_user' => $watchersChunk,
768                                                                 'wl_namespace' => $target->getNamespace(),
769                                                                 'wl_title' => $target->getDBkey(),
770                                                         ], $fname
771                                                 );
772                                                 if ( count( $watchersChunks ) > 1 ) {
773                                                         $factory->commitAndWaitForReplication(
774                                                                 __METHOD__, $ticket, [ 'domain' => $dbw->getDomainID() ]
775                                                         );
776                                                 }
777                                         }
778                                         $this->uncacheLinkTarget( $target );
779                                 },
780                                 DeferredUpdates::POSTSEND,
781                                 $dbw
782                         );
783                 }
784
785                 return $watchers;
786         }
787
788         /**
789          * Reset the notification timestamp of this entry
790          *
791          * @param User $user
792          * @param Title $title
793          * @param string $force Whether to force the write query to be executed even if the
794          *    page is not watched or the notification timestamp is already NULL.
795          *    'force' in order to force
796          * @param int $oldid The revision id being viewed. If not given or 0, latest revision is assumed.
797          *
798          * @return bool success
799          */
800         public function resetNotificationTimestamp( User $user, Title $title, $force = '', $oldid = 0 ) {
801                 // Only loggedin user can have a watchlist
802                 if ( $this->readOnlyMode->isReadOnly() || $user->isAnon() ) {
803                         return false;
804                 }
805
806                 $item = null;
807                 if ( $force != 'force' ) {
808                         $item = $this->loadWatchedItem( $user, $title );
809                         if ( !$item || $item->getNotificationTimestamp() === null ) {
810                                 return false;
811                         }
812                 }
813
814                 // If the page is watched by the user (or may be watched), update the timestamp
815                 $job = new ActivityUpdateJob(
816                         $title,
817                         [
818                                 'type'      => 'updateWatchlistNotification',
819                                 'userid'    => $user->getId(),
820                                 'notifTime' => $this->getNotificationTimestamp( $user, $title, $item, $force, $oldid ),
821                                 'curTime'   => time()
822                         ]
823                 );
824
825                 // Try to run this post-send
826                 // Calls DeferredUpdates::addCallableUpdate in normal operation
827                 call_user_func(
828                         $this->deferredUpdatesAddCallableUpdateCallback,
829                         function () use ( $job ) {
830                                 $job->run();
831                         }
832                 );
833
834                 $this->uncache( $user, $title );
835
836                 return true;
837         }
838
839         private function getNotificationTimestamp( User $user, Title $title, $item, $force, $oldid ) {
840                 if ( !$oldid ) {
841                         // No oldid given, assuming latest revision; clear the timestamp.
842                         return null;
843                 }
844
845                 if ( !$title->getNextRevisionID( $oldid ) ) {
846                         // Oldid given and is the latest revision for this title; clear the timestamp.
847                         return null;
848                 }
849
850                 if ( $item === null ) {
851                         $item = $this->loadWatchedItem( $user, $title );
852                 }
853
854                 if ( !$item ) {
855                         // This can only happen if $force is enabled.
856                         return null;
857                 }
858
859                 // Oldid given and isn't the latest; update the timestamp.
860                 // This will result in no further notification emails being sent!
861                 // Calls Revision::getTimestampFromId in normal operation
862                 $notificationTimestamp = call_user_func(
863                         $this->revisionGetTimestampFromIdCallback,
864                         $title,
865                         $oldid
866                 );
867
868                 // We need to go one second to the future because of various strict comparisons
869                 // throughout the codebase
870                 $ts = new MWTimestamp( $notificationTimestamp );
871                 $ts->timestamp->add( new DateInterval( 'PT1S' ) );
872                 $notificationTimestamp = $ts->getTimestamp( TS_MW );
873
874                 if ( $notificationTimestamp < $item->getNotificationTimestamp() ) {
875                         if ( $force != 'force' ) {
876                                 return false;
877                         } else {
878                                 // This is a little silly…
879                                 return $item->getNotificationTimestamp();
880                         }
881                 }
882
883                 return $notificationTimestamp;
884         }
885
886         /**
887          * @param User $user
888          * @param int $unreadLimit
889          *
890          * @return int|bool The number of unread notifications
891          *                  true if greater than or equal to $unreadLimit
892          */
893         public function countUnreadNotifications( User $user, $unreadLimit = null ) {
894                 $queryOptions = [];
895                 if ( $unreadLimit !== null ) {
896                         $unreadLimit = (int)$unreadLimit;
897                         $queryOptions['LIMIT'] = $unreadLimit;
898                 }
899
900                 $dbr = $this->getConnectionRef( DB_REPLICA );
901                 $rowCount = $dbr->selectRowCount(
902                         'watchlist',
903                         '1',
904                         [
905                                 'wl_user' => $user->getId(),
906                                 'wl_notificationtimestamp IS NOT NULL',
907                         ],
908                         __METHOD__,
909                         $queryOptions
910                 );
911
912                 if ( !isset( $unreadLimit ) ) {
913                         return $rowCount;
914                 }
915
916                 if ( $rowCount >= $unreadLimit ) {
917                         return true;
918                 }
919
920                 return $rowCount;
921         }
922
923         /**
924          * Check if the given title already is watched by the user, and if so
925          * add a watch for the new title.
926          *
927          * To be used for page renames and such.
928          *
929          * @param LinkTarget $oldTarget
930          * @param LinkTarget $newTarget
931          */
932         public function duplicateAllAssociatedEntries( LinkTarget $oldTarget, LinkTarget $newTarget ) {
933                 $oldTarget = Title::newFromLinkTarget( $oldTarget );
934                 $newTarget = Title::newFromLinkTarget( $newTarget );
935
936                 $this->duplicateEntry( $oldTarget->getSubjectPage(), $newTarget->getSubjectPage() );
937                 $this->duplicateEntry( $oldTarget->getTalkPage(), $newTarget->getTalkPage() );
938         }
939
940         /**
941          * Check if the given title already is watched by the user, and if so
942          * add a watch for the new title.
943          *
944          * To be used for page renames and such.
945          * This must be called separately for Subject and Talk pages
946          *
947          * @param LinkTarget $oldTarget
948          * @param LinkTarget $newTarget
949          */
950         public function duplicateEntry( LinkTarget $oldTarget, LinkTarget $newTarget ) {
951                 $dbw = $this->getConnectionRef( DB_MASTER );
952
953                 $result = $dbw->select(
954                         'watchlist',
955                         [ 'wl_user', 'wl_notificationtimestamp' ],
956                         [
957                                 'wl_namespace' => $oldTarget->getNamespace(),
958                                 'wl_title' => $oldTarget->getDBkey(),
959                         ],
960                         __METHOD__,
961                         [ 'FOR UPDATE' ]
962                 );
963
964                 $newNamespace = $newTarget->getNamespace();
965                 $newDBkey = $newTarget->getDBkey();
966
967                 # Construct array to replace into the watchlist
968                 $values = [];
969                 foreach ( $result as $row ) {
970                         $values[] = [
971                                 'wl_user' => $row->wl_user,
972                                 'wl_namespace' => $newNamespace,
973                                 'wl_title' => $newDBkey,
974                                 'wl_notificationtimestamp' => $row->wl_notificationtimestamp,
975                         ];
976                 }
977
978                 if ( !empty( $values ) ) {
979                         # Perform replace
980                         # Note that multi-row replace is very efficient for MySQL but may be inefficient for
981                         # some other DBMSes, mostly due to poor simulation by us
982                         $dbw->replace(
983                                 'watchlist',
984                                 [ [ 'wl_user', 'wl_namespace', 'wl_title' ] ],
985                                 $values,
986                                 __METHOD__
987                         );
988                 }
989         }
990
991 }