Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.49% covered (success)
95.49%
762 / 798
74.55% covered (warning)
74.55%
41 / 55
CRAP
0.00% covered (danger)
0.00%
0 / 1
WatchedItemStore
95.49% covered (success)
95.49%
762 / 798
74.55% covered (warning)
74.55%
41 / 55
191
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
1
 setStatsdDataFactory
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 overrideDeferredUpdatesAddCallableUpdateCallback
66.67% covered (warning)
66.67%
6 / 9
0.00% covered (danger)
0.00%
0 / 1
2.15
 getCacheKey
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 cache
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 uncache
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 uncacheLinkTarget
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 uncacheUser
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 getCached
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 modifyQueryBuilderForExpiry
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 clearUserWatchedItems
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
4
 mustClearWatchedItemsUsingJobQueue
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 uncacheAllItemsForUser
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
8
 clearUserWatchedItemsUsingJobQueue
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 maybeEnqueueWatchlistExpiryJob
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 getMaxId
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 countWatchedItems
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 countWatchers
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 countVisitingWatchers
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
1
 removeWatchBatchForUser
95.00% covered (success)
95.00%
38 / 40
0.00% covered (danger)
0.00%
0 / 1
10
 countWatchersMultiple
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
5
 countVisitingWatchersMultiple
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
5.00
 getVisitingWatchersCondition
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
5
 getWatchedItem
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 loadWatchedItem
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 loadWatchedItemsBatch
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
4
 getWatchedItemsForUser
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
7
 getWatchedItemFromRow
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 fetchWatchedItems
80.65% covered (warning)
80.65%
25 / 31
0.00% covered (danger)
0.00%
0 / 1
8.46
 isWatched
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isTempWatched
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 getNotificationTimestampsBatch
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
7
 addWatch
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 addWatchBatchForUser
96.30% covered (success)
96.30%
26 / 27
0.00% covered (danger)
0.00%
0 / 1
9
 updateOrDeleteExpiries
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
4
 updateExpiries
95.65% covered (success)
95.65%
22 / 23
0.00% covered (danger)
0.00%
0 / 1
2
 removeWatch
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setNotificationTimestampsForUser
94.59% covered (success)
94.59%
35 / 37
0.00% covered (danger)
0.00%
0 / 1
9.01
 getLatestNotificationTimestamp
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 resetAllNotificationTimestampsForUser
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
2.00
 updateNotificationTimestamp
97.62% covered (success)
97.62%
41 / 42
0.00% covered (danger)
0.00%
0 / 1
5
 resetNotificationTimestamp
90.20% covered (success)
90.20%
46 / 51
0.00% covered (danger)
0.00%
0 / 1
13.16
 getPageSeenTimestamps
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 getPageSeenTimestampsKey
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 getPageSeenKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getNotificationTimestamp
95.24% covered (success)
95.24%
20 / 21
0.00% covered (danger)
0.00%
0 / 1
8
 countUnreadNotifications
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
4
 duplicateAllAssociatedEntries
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 duplicateEntry
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
6
 fetchWatchedItemsForPage
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
2
 updateExpiriesAfterMove
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
4
 getTitleDbKeysGroupedByNamespace
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 uncacheTitlesForUser
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 countExpired
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 removeExpired
100.00% covered (success)
100.00%
36 / 36
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2
3use Liuggio\StatsdClient\Factory\StatsdDataFactoryInterface;
4use MediaWiki\Cache\LinkBatchFactory;
5use MediaWiki\Config\ServiceOptions;
6use MediaWiki\Deferred\DeferredUpdates;
7use MediaWiki\Linker\LinkTarget;
8use MediaWiki\MainConfigNames;
9use MediaWiki\Page\PageIdentity;
10use MediaWiki\Revision\RevisionLookup;
11use MediaWiki\Title\NamespaceInfo;
12use MediaWiki\Title\TitleValue;
13use MediaWiki\User\UserIdentity;
14use MediaWiki\Utils\MWTimestamp;
15use Wikimedia\Assert\Assert;
16use Wikimedia\ParamValidator\TypeDef\ExpiryDef;
17use Wikimedia\Rdbms\IDatabase;
18use Wikimedia\Rdbms\ILBFactory;
19use Wikimedia\Rdbms\IResultWrapper;
20use Wikimedia\Rdbms\ReadOnlyMode;
21use Wikimedia\Rdbms\SelectQueryBuilder;
22use Wikimedia\ScopedCallback;
23
24/**
25 * Storage layer class for WatchedItems.
26 * Database interaction & caching
27 * TODO caching should be factored out into a CachingWatchedItemStore class
28 *
29 * @author Addshore
30 * @since 1.27
31 */
32class WatchedItemStore implements WatchedItemStoreInterface, StatsdAwareInterface {
33
34    /**
35     * @internal For use by ServiceWiring
36     */
37    public const CONSTRUCTOR_OPTIONS = [
38        MainConfigNames::UpdateRowsPerQuery,
39        MainConfigNames::WatchlistExpiry,
40        MainConfigNames::WatchlistExpiryMaxDuration,
41        MainConfigNames::WatchlistPurgeRate,
42    ];
43
44    /**
45     * @var ILBFactory
46     */
47    private $lbFactory;
48
49    /**
50     * @var JobQueueGroup
51     */
52    private $queueGroup;
53
54    /**
55     * @var BagOStuff
56     */
57    private $stash;
58
59    /**
60     * @var ReadOnlyMode
61     */
62    private $readOnlyMode;
63
64    /**
65     * @var HashBagOStuff
66     */
67    private $cache;
68
69    /**
70     * @var HashBagOStuff
71     */
72    private $latestUpdateCache;
73
74    /**
75     * @var array[][] Looks like $cacheIndex[Namespace ID][Target DB Key][User Id] => 'key'
76     * The index is needed so that on mass changes all relevant items can be un-cached.
77     * For example: Clearing a users watchlist of all items or updating notification timestamps
78     *              for all users watching a single target.
79     * @phan-var array<int,array<string,array<int,string>>>
80     */
81    private $cacheIndex = [];
82
83    /**
84     * @var callable|null
85     */
86    private $deferredUpdatesAddCallableUpdateCallback;
87
88    /**
89     * @var int
90     */
91    private $updateRowsPerQuery;
92
93    /**
94     * @var NamespaceInfo
95     */
96    private $nsInfo;
97
98    /**
99     * @var RevisionLookup
100     */
101    private $revisionLookup;
102
103    /**
104     * @var StatsdDataFactoryInterface
105     */
106    private $stats;
107
108    /**
109     * @var bool Correlates to $wgWatchlistExpiry feature flag.
110     */
111    private $expiryEnabled;
112
113    /**
114     * @var LinkBatchFactory
115     */
116    private $linkBatchFactory;
117
118    /**
119     * @var string|null Maximum configured relative expiry.
120     */
121    private $maxExpiryDuration;
122
123    /** @var float corresponds to $wgWatchlistPurgeRate value */
124    private $watchlistPurgeRate;
125
126    /**
127     * @param ServiceOptions $options
128     * @param ILBFactory $lbFactory
129     * @param JobQueueGroup $queueGroup
130     * @param BagOStuff $stash
131     * @param HashBagOStuff $cache
132     * @param ReadOnlyMode $readOnlyMode
133     * @param NamespaceInfo $nsInfo
134     * @param RevisionLookup $revisionLookup
135     * @param LinkBatchFactory $linkBatchFactory
136     */
137    public function __construct(
138        ServiceOptions $options,
139        ILBFactory $lbFactory,
140        JobQueueGroup $queueGroup,
141        BagOStuff $stash,
142        HashBagOStuff $cache,
143        ReadOnlyMode $readOnlyMode,
144        NamespaceInfo $nsInfo,
145        RevisionLookup $revisionLookup,
146        LinkBatchFactory $linkBatchFactory
147    ) {
148        $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
149        $this->updateRowsPerQuery = $options->get( MainConfigNames::UpdateRowsPerQuery );
150        $this->expiryEnabled = $options->get( MainConfigNames::WatchlistExpiry );
151        $this->maxExpiryDuration = $options->get( MainConfigNames::WatchlistExpiryMaxDuration );
152        $this->watchlistPurgeRate = $options->get( MainConfigNames::WatchlistPurgeRate );
153
154        $this->lbFactory = $lbFactory;
155        $this->queueGroup = $queueGroup;
156        $this->stash = $stash;
157        $this->cache = $cache;
158        $this->readOnlyMode = $readOnlyMode;
159        $this->stats = new NullStatsdDataFactory();
160        $this->deferredUpdatesAddCallableUpdateCallback =
161            [ DeferredUpdates::class, 'addCallableUpdate' ];
162        $this->nsInfo = $nsInfo;
163        $this->revisionLookup = $revisionLookup;
164        $this->linkBatchFactory = $linkBatchFactory;
165
166        $this->latestUpdateCache = new HashBagOStuff( [ 'maxKeys' => 3 ] );
167    }
168
169    /**
170     * @param StatsdDataFactoryInterface $stats
171     */
172    public function setStatsdDataFactory( StatsdDataFactoryInterface $stats ) {
173        $this->stats = $stats;
174    }
175
176    /**
177     * Overrides the DeferredUpdates::addCallableUpdate callback
178     * This is intended for use while testing and will fail if MW_PHPUNIT_TEST is not defined.
179     *
180     * @param callable $callback
181     *
182     * @see DeferredUpdates::addCallableUpdate for callback signiture
183     *
184     * @return ScopedCallback to reset the overridden value
185     */
186    public function overrideDeferredUpdatesAddCallableUpdateCallback( callable $callback ) {
187        if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
188            throw new LogicException(
189                'Cannot override DeferredUpdates::addCallableUpdate callback in operation.'
190            );
191        }
192        $previousValue = $this->deferredUpdatesAddCallableUpdateCallback;
193        $this->deferredUpdatesAddCallableUpdateCallback = $callback;
194        return new ScopedCallback( function () use ( $previousValue ) {
195            $this->deferredUpdatesAddCallableUpdateCallback = $previousValue;
196        } );
197    }
198
199    /**
200     * @param UserIdentity $user
201     * @param LinkTarget|PageIdentity $target
202     * @return string
203     */
204    private function getCacheKey( UserIdentity $user, $target ): string {
205        return $this->cache->makeKey(
206            (string)$target->getNamespace(),
207            $target->getDBkey(),
208            (string)$user->getId()
209        );
210    }
211
212    /**
213     * @param WatchedItem $item
214     */
215    private function cache( WatchedItem $item ) {
216        $user = $item->getUserIdentity();
217        $target = $item->getTarget();
218        $key = $this->getCacheKey( $user, $target );
219        $this->cache->set( $key, $item );
220        $this->cacheIndex[$target->getNamespace()][$target->getDBkey()][$user->getId()] = $key;
221        $this->stats->increment( 'WatchedItemStore.cache' );
222    }
223
224    /**
225     * @param UserIdentity $user
226     * @param LinkTarget|PageIdentity $target
227     */
228    private function uncache( UserIdentity $user, $target ) {
229        $this->cache->delete( $this->getCacheKey( $user, $target ) );
230        unset( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()][$user->getId()] );
231        $this->stats->increment( 'WatchedItemStore.uncache' );
232    }
233
234    /**
235     * @param LinkTarget|PageIdentity $target
236     */
237    private function uncacheLinkTarget( $target ) {
238        $this->stats->increment( 'WatchedItemStore.uncacheLinkTarget' );
239        if ( !isset( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()] ) ) {
240            return;
241        }
242        foreach ( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()] as $key ) {
243            $this->stats->increment( 'WatchedItemStore.uncacheLinkTarget.items' );
244            $this->cache->delete( $key );
245        }
246    }
247
248    /**
249     * @param UserIdentity $user
250     */
251    private function uncacheUser( UserIdentity $user ) {
252        $this->stats->increment( 'WatchedItemStore.uncacheUser' );
253        foreach ( $this->cacheIndex as $dbKeyArray ) {
254            foreach ( $dbKeyArray as $userArray ) {
255                if ( isset( $userArray[$user->getId()] ) ) {
256                    $this->stats->increment( 'WatchedItemStore.uncacheUser.items' );
257                    $this->cache->delete( $userArray[$user->getId()] );
258                }
259            }
260        }
261
262        $pageSeenKey = $this->getPageSeenTimestampsKey( $user );
263        $this->latestUpdateCache->delete( $pageSeenKey );
264        $this->stash->delete( $pageSeenKey );
265    }
266
267    /**
268     * @param UserIdentity $user
269     * @param LinkTarget|PageIdentity $target
270     *
271     * @return WatchedItem|false
272     */
273    private function getCached( UserIdentity $user, $target ) {
274        return $this->cache->get( $this->getCacheKey( $user, $target ) );
275    }
276
277    /**
278     * Helper method to deduplicate logic around queries that need to be modified
279     * if watchlist expiration is enabled
280     *
281     * @param SelectQueryBuilder $queryBuilder
282     * @param IDatabase $db
283     */
284    private function modifyQueryBuilderForExpiry(
285        SelectQueryBuilder $queryBuilder,
286        IDatabase $db
287    ) {
288        if ( $this->expiryEnabled ) {
289            $queryBuilder->where( 'we_expiry IS NULL OR we_expiry > ' . $db->addQuotes( $db->timestamp() ) );
290            $queryBuilder->leftJoin( 'watchlist_expiry', null, 'wl_id = we_item' );
291        }
292    }
293
294    /**
295     * Deletes ALL watched items for the given user when under
296     * $updateRowsPerQuery entries exist.
297     *
298     * @since 1.30
299     *
300     * @param UserIdentity $user
301     *
302     * @return bool true on success, false when too many items are watched
303     */
304    public function clearUserWatchedItems( UserIdentity $user ): bool {
305        if ( $this->mustClearWatchedItemsUsingJobQueue( $user ) ) {
306            return false;
307        }
308
309        $dbw = $this->lbFactory->getPrimaryDatabase();
310
311        if ( $this->expiryEnabled ) {
312            $ticket = $this->lbFactory->getEmptyTransactionTicket( __METHOD__ );
313            // First fetch the wl_ids.
314            $wlIds = $dbw->newSelectQueryBuilder()
315                ->select( 'wl_id' )
316                ->from( 'watchlist' )
317                ->where( [ 'wl_user' => $user->getId() ] )
318                ->caller( __METHOD__ )
319                ->fetchFieldValues();
320            if ( $wlIds ) {
321                // Delete rows from both the watchlist and watchlist_expiry tables.
322                $dbw->newDeleteQueryBuilder()
323                    ->deleteFrom( 'watchlist' )
324                    ->where( [ 'wl_id' => $wlIds ] )
325                    ->caller( __METHOD__ )->execute();
326
327                $dbw->newDeleteQueryBuilder()
328                    ->deleteFrom( 'watchlist_expiry' )
329                    ->where( [ 'we_item' => $wlIds ] )
330                    ->caller( __METHOD__ )->execute();
331            }
332            $this->lbFactory->commitAndWaitForReplication( __METHOD__, $ticket );
333        } else {
334            $dbw->newDeleteQueryBuilder()
335                ->deleteFrom( 'watchlist' )
336                ->where( [ 'wl_user' => $user->getId() ] )
337                ->caller( __METHOD__ )->execute();
338        }
339
340        $this->uncacheAllItemsForUser( $user );
341
342        return true;
343    }
344
345    /**
346     * @param UserIdentity $user
347     * @return bool
348     */
349    public function mustClearWatchedItemsUsingJobQueue( UserIdentity $user ): bool {
350        return $this->countWatchedItems( $user ) > $this->updateRowsPerQuery;
351    }
352
353    /**
354     * @param UserIdentity $user
355     */
356    private function uncacheAllItemsForUser( UserIdentity $user ) {
357        $userId = $user->getId();
358        foreach ( $this->cacheIndex as $ns => $dbKeyIndex ) {
359            foreach ( $dbKeyIndex as $dbKey => $userIndex ) {
360                if ( array_key_exists( $userId, $userIndex ) ) {
361                    $this->cache->delete( $userIndex[$userId] );
362                    unset( $this->cacheIndex[$ns][$dbKey][$userId] );
363                }
364            }
365        }
366
367        // Cleanup empty cache keys
368        foreach ( $this->cacheIndex as $ns => $dbKeyIndex ) {
369            foreach ( $dbKeyIndex as $dbKey => $userIndex ) {
370                if ( empty( $this->cacheIndex[$ns][$dbKey] ) ) {
371                    unset( $this->cacheIndex[$ns][$dbKey] );
372                }
373            }
374            if ( empty( $this->cacheIndex[$ns] ) ) {
375                unset( $this->cacheIndex[$ns] );
376            }
377        }
378    }
379
380    /**
381     * Queues a job that will clear the users watchlist using the Job Queue.
382     *
383     * @since 1.31
384     *
385     * @param UserIdentity $user
386     */
387    public function clearUserWatchedItemsUsingJobQueue( UserIdentity $user ) {
388        $job = ClearUserWatchlistJob::newForUser( $user, $this->getMaxId() );
389        $this->queueGroup->push( $job );
390    }
391
392    /**
393     * @inheritDoc
394     */
395    public function maybeEnqueueWatchlistExpiryJob(): void {
396        if ( !$this->expiryEnabled ) {
397            // No need to purge expired entries if there are none
398            return;
399        }
400
401        $max = mt_getrandmax();
402        if ( mt_rand( 0, $max ) < $max * $this->watchlistPurgeRate ) {
403            // The higher the watchlist purge rate, the more likely we are to enqueue a job.
404            $this->queueGroup->lazyPush( new WatchlistExpiryJob() );
405        }
406    }
407
408    /**
409     * @since 1.31
410     * @return int The maximum current wl_id
411     */
412    public function getMaxId(): int {
413        return (int)$this->lbFactory->getReplicaDatabase()->newSelectQueryBuilder()
414            ->select( 'MAX(wl_id)' )
415            ->from( 'watchlist' )
416            ->caller( __METHOD__ )
417            ->fetchField();
418    }
419
420    /**
421     * @since 1.31
422     * @param UserIdentity $user
423     * @return int
424     */
425    public function countWatchedItems( UserIdentity $user ): int {
426        $dbr = $this->lbFactory->getReplicaDatabase();
427        $queryBuilder = $this->lbFactory->getReplicaDatabase()->newSelectQueryBuilder()
428            ->select( 'COUNT(*)' )
429            ->from( 'watchlist' )
430            ->where( [ 'wl_user' => $user->getId() ] )
431            ->caller( __METHOD__ );
432
433        $this->modifyQueryBuilderForExpiry( $queryBuilder, $dbr );
434
435        return (int)$queryBuilder->fetchField();
436    }
437
438    /**
439     * @since 1.27
440     * @param LinkTarget|PageIdentity $target deprecated passing LinkTarget since 1.36
441     * @return int
442     */
443    public function countWatchers( $target ): int {
444        $dbr = $this->lbFactory->getReplicaDatabase();
445        $queryBuilder = $dbr->newSelectQueryBuilder()
446            ->select( 'COUNT(*)' )
447            ->from( 'watchlist' )
448            ->where( [
449                'wl_namespace' => $target->getNamespace(),
450                'wl_title' => $target->getDBkey()
451            ] )
452            ->caller( __METHOD__ );
453
454        $this->modifyQueryBuilderForExpiry( $queryBuilder, $dbr );
455
456        return (int)$queryBuilder->fetchField();
457    }
458
459    /**
460     * @since 1.27
461     * @param LinkTarget|PageIdentity $target deprecated passing LinkTarget since 1.36
462     * @param string|int $threshold
463     * @return int
464     */
465    public function countVisitingWatchers( $target, $threshold ): int {
466        $dbr = $this->lbFactory->getReplicaDatabase();
467        $queryBuilder = $dbr->newSelectQueryBuilder()
468            ->select( 'COUNT(*)' )
469            ->from( 'watchlist' )
470            ->where( [
471                'wl_namespace' => $target->getNamespace(),
472                'wl_title' => $target->getDBkey(),
473                'wl_notificationtimestamp >= ' .
474                $dbr->addQuotes( $dbr->timestamp( $threshold ) ) .
475                ' OR wl_notificationtimestamp IS NULL'
476            ] )
477            ->caller( __METHOD__ );
478
479        $this->modifyQueryBuilderForExpiry( $queryBuilder, $dbr );
480
481        return (int)$queryBuilder->fetchField();
482    }
483
484    /**
485     * @param UserIdentity $user
486     * @param LinkTarget[]|PageIdentity[] $titles deprecated passing LinkTarget[] since 1.36
487     * @return bool
488     */
489    public function removeWatchBatchForUser( UserIdentity $user, array $titles ): bool {
490        if ( !$user->isRegistered() || $this->readOnlyMode->isReadOnly() ) {
491            return false;
492        }
493        if ( !$titles ) {
494            return true;
495        }
496
497        $rows = $this->getTitleDbKeysGroupedByNamespace( $titles );
498        $this->uncacheTitlesForUser( $user, $titles );
499
500        $dbw = $this->lbFactory->getPrimaryDatabase();
501        $ticket = count( $titles ) > $this->updateRowsPerQuery ?
502            $this->lbFactory->getEmptyTransactionTicket( __METHOD__ ) : null;
503        $affectedRows = 0;
504
505        // Batch delete items per namespace.
506        foreach ( $rows as $namespace => $namespaceTitles ) {
507            $rowBatches = array_chunk( $namespaceTitles, $this->updateRowsPerQuery );
508            foreach ( $rowBatches as $toDelete ) {
509                // First fetch the wl_ids.
510                $wlIds = $dbw->newSelectQueryBuilder()
511                    ->select( 'wl_id' )
512                    ->from( 'watchlist' )
513                    ->where(
514                        [
515                            'wl_user' => $user->getId(),
516                            'wl_namespace' => $namespace,
517                            'wl_title' => $toDelete
518                        ]
519                    )
520                    ->caller( __METHOD__ )
521                    ->fetchFieldValues();
522
523                if ( $wlIds ) {
524                    // Delete rows from both the watchlist and watchlist_expiry tables.
525                    $dbw->newDeleteQueryBuilder()
526                        ->deleteFrom( 'watchlist' )
527                        ->where( [ 'wl_id' => $wlIds ] )
528                        ->caller( __METHOD__ )->execute();
529                    $affectedRows += $dbw->affectedRows();
530
531                    if ( $this->expiryEnabled ) {
532                        $dbw->newDeleteQueryBuilder()
533                            ->deleteFrom( 'watchlist_expiry' )
534                            ->where( [ 'we_item' => $wlIds ] )
535                            ->caller( __METHOD__ )->execute();
536                        $affectedRows += $dbw->affectedRows();
537                    }
538                }
539
540                if ( $ticket ) {
541                    $this->lbFactory->commitAndWaitForReplication( __METHOD__, $ticket );
542                }
543            }
544        }
545
546        return (bool)$affectedRows;
547    }
548
549    /**
550     * @since 1.27
551     * @param LinkTarget[]|PageIdentity[] $targets deprecated passing LinkTarget[] since 1.36
552     * @param array $options Supported options are:
553     *  - 'minimumWatchers': filter for pages that have at least a minimum number of watchers
554     * @return array
555     */
556    public function countWatchersMultiple( array $targets, array $options = [] ): array {
557        $linkTargets = array_map( static function ( $target ) {
558            if ( !$target instanceof LinkTarget ) {
559                return new TitleValue( $target->getNamespace(), $target->getDBkey() );
560            }
561            return $target;
562        }, $targets );
563        $lb = $this->linkBatchFactory->newLinkBatch( $linkTargets );
564        $dbr = $this->lbFactory->getReplicaDatabase();
565        $queryBuilder = $dbr->newSelectQueryBuilder();
566        $queryBuilder
567            ->select( [ 'wl_title', 'wl_namespace', 'watchers' => 'COUNT(*)' ] )
568            ->from( 'watchlist' )
569            ->where( [ $lb->constructSet( 'wl', $dbr ) ] )
570            ->groupBy( [ 'wl_namespace', 'wl_title' ] )
571            ->caller( __METHOD__ );
572
573        if ( array_key_exists( 'minimumWatchers', $options ) ) {
574            $queryBuilder->having( 'COUNT(*) >= ' . (int)$options['minimumWatchers'] );
575        }
576
577        $this->modifyQueryBuilderForExpiry( $queryBuilder, $dbr );
578
579        $res = $queryBuilder->fetchResultSet();
580
581        $watchCounts = [];
582        foreach ( $targets as $linkTarget ) {
583            $watchCounts[$linkTarget->getNamespace()][$linkTarget->getDBkey()] = 0;
584        }
585
586        foreach ( $res as $row ) {
587            $watchCounts[$row->wl_namespace][$row->wl_title] = (int)$row->watchers;
588        }
589
590        return $watchCounts;
591    }
592
593    /**
594     * @since 1.27
595     * @param array $targetsWithVisitThresholds array of LinkTarget[]|PageIdentity[] (not type
596     *        hinted since it annoys phan) - deprecated passing LinkTarget[] since 1.36
597     * @param int|null $minimumWatchers
598     * @return int[][] two dimensional array, first is namespace, second is database key,
599     *                 value is the number of watchers
600     */
601    public function countVisitingWatchersMultiple(
602        array $targetsWithVisitThresholds,
603        $minimumWatchers = null
604    ): array {
605        if ( $targetsWithVisitThresholds === [] ) {
606            // No titles requested => no results returned
607            return [];
608        }
609
610        $dbr