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