Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.33% covered (success)
95.33%
776 / 814
76.79% covered (warning)
76.79%
43 / 56
CRAP
0.00% covered (danger)
0.00%
0 / 1
WatchedItemStore
95.33% covered (success)
95.33%
776 / 814
76.79% covered (warning)
76.79%
43 / 56
191
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
18 / 18
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
 getConnectionRef
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%
30 / 30
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
93.18% covered (success)
93.18%
41 / 44
0.00% covered (danger)
0.00%
0 / 1
10.03
 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%
29 / 29
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.55% covered (success)
96.55%
28 / 29
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
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
1
 removeWatch
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setNotificationTimestampsForUser
94.74% covered (success)
94.74%
36 / 38
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
93.33% covered (success)
93.33%
42 / 45
0.00% covered (danger)
0.00%
0 / 1
5.01
 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%
25 / 25
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%
31 / 31
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%
39 / 39
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\Linker\LinkTarget;
7use MediaWiki\MainConfigNames;
8use MediaWiki\Page\PageIdentity;
9use MediaWiki\Revision\RevisionLookup;
10use MediaWiki\User\UserIdentity;
11use Wikimedia\Assert\Assert;
12use Wikimedia\ParamValidator\TypeDef\ExpiryDef;
13use Wikimedia\Rdbms\IDatabase;
14use Wikimedia\Rdbms\ILBFactory;
15use Wikimedia\Rdbms\IResultWrapper;
16use Wikimedia\Rdbms\LoadBalancer;
17use Wikimedia\Rdbms\SelectQueryBuilder;
18use Wikimedia\ScopedCallback;
19
20/**
21 * Storage layer class for WatchedItems.
22 * Database interaction & caching
23 * TODO caching should be factored out into a CachingWatchedItemStore class
24 *
25 * @author Addshore
26 * @since 1.27
27 */
28class WatchedItemStore implements WatchedItemStoreInterface, StatsdAwareInterface {
29
30    /**
31     * @internal For use by ServiceWiring
32     */
33    public const CONSTRUCTOR_OPTIONS = [
34        MainConfigNames::UpdateRowsPerQuery,
35        MainConfigNames::WatchlistExpiry,
36        MainConfigNames::WatchlistExpiryMaxDuration,
37        MainConfigNames::WatchlistPurgeRate,
38    ];
39
40    /**
41     * @var ILBFactory
42     */
43    private $lbFactory;
44
45    /**
46     * @var LoadBalancer
47     */
48    private $loadBalancer;
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->loadBalancer = $lbFactory->getMainLB();
157        $this->queueGroup = $queueGroup;
158        $this->stash = $stash;
159        $this->cache = $cache;
160        $this->readOnlyMode = $readOnlyMode;
161        $this->stats = new NullStatsdDataFactory();
162        $this->deferredUpdatesAddCallableUpdateCallback =
163            [ DeferredUpdates::class, 'addCallableUpdate' ];
164        $this->nsInfo = $nsInfo;
165        $this->revisionLookup = $revisionLookup;
166        $this->linkBatchFactory = $linkBatchFactory;
167
168        $this->latestUpdateCache = new HashBagOStuff( [ 'maxKeys' => 3 ] );
169    }
170
171    /**
172     * @param StatsdDataFactoryInterface $stats
173     */
174    public function setStatsdDataFactory( StatsdDataFactoryInterface $stats ) {
175        $this->stats = $stats;
176    }
177
178    /**
179     * Overrides the DeferredUpdates::addCallableUpdate callback
180     * This is intended for use while testing and will fail if MW_PHPUNIT_TEST is not defined.
181     *
182     * @param callable $callback
183     *
184     * @see DeferredUpdates::addCallableUpdate for callback signiture
185     *
186     * @return ScopedCallback to reset the overridden value
187     * @throws MWException
188     */
189    public function overrideDeferredUpdatesAddCallableUpdateCallback( callable $callback ) {
190        if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
191            throw new MWException(
192                'Cannot override DeferredUpdates::addCallableUpdate callback in operation.'
193            );
194        }
195        $previousValue = $this->deferredUpdatesAddCallableUpdateCallback;
196        $this->deferredUpdatesAddCallableUpdateCallback = $callback;
197        return new ScopedCallback( function () use ( $previousValue ) {
198            $this->deferredUpdatesAddCallableUpdateCallback = $previousValue;
199        } );
200    }
201
202    /**
203     * @param UserIdentity $user
204     * @param LinkTarget|PageIdentity $target
205     * @return string
206     */
207    private function getCacheKey( UserIdentity $user, $target ): string {
208        return $this->cache->makeKey(
209            (string)$target->getNamespace(),
210            $target->getDBkey(),
211            (string)$user->getId()
212        );
213    }
214
215    /**
216     * @param WatchedItem $item
217     */
218    private function cache( WatchedItem $item ) {
219        $user = $item->getUserIdentity();
220        $target = $item->getTarget();
221        $key = $this->getCacheKey( $user, $target );
222        $this->cache->set( $key, $item );
223        $this->cacheIndex[$target->getNamespace()][$target->getDBkey()][$user->getId()] = $key;
224        $this->stats->increment( 'WatchedItemStore.cache' );
225    }
226
227    /**
228     * @param UserIdentity $user
229     * @param LinkTarget|PageIdentity $target
230     */
231    private function uncache( UserIdentity $user, $target ) {
232        $this->cache->delete( $this->getCacheKey( $user, $target ) );
233        unset( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()][$user->getId()] );
234        $this->stats->increment( 'WatchedItemStore.uncache' );
235    }
236
237    /**
238     * @param LinkTarget|PageIdentity $target
239     */
240    private function uncacheLinkTarget( $target ) {
241        $this->stats->increment( 'WatchedItemStore.uncacheLinkTarget' );
242        if ( !isset( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()] ) ) {
243            return;
244        }
245        foreach ( $this->cacheIndex[$target->getNamespace()][$target->getDBkey()] as $key ) {
246            $this->stats->increment( 'WatchedItemStore.uncacheLinkTarget.items' );
247            $this->cache->delete( $key );
248        }
249    }
250
251    /**
252     * @param UserIdentity $user
253     */
254    private function uncacheUser( UserIdentity $user ) {
255        $this->stats->increment( 'WatchedItemStore.uncacheUser' );
256        foreach ( $this->cacheIndex as $dbKeyArray ) {
257            foreach ( $dbKeyArray as $userArray ) {
258                if ( isset( $userArray[$user->getId()] ) ) {
259                    $this->stats->increment( 'WatchedItemStore.uncacheUser.items' );
260                    $this->cache->delete( $userArray[$user->getId()] );
261                }
262            }
263        }
264
265        $pageSeenKey = $this->getPageSeenTimestampsKey( $user );
266        $this->latestUpdateCache->delete( $pageSeenKey );
267        $this->stash->delete( $pageSeenKey );
268    }
269
270    /**
271     * @param UserIdentity $user
272     * @param LinkTarget|PageIdentity $target
273     *
274     * @return WatchedItem|false
275     */
276    private function getCached( UserIdentity $user, $target ) {
277        return $this->cache->get( $this->getCacheKey( $user, $target ) );
278    }
279
280    /**
281     * @param int $dbIndex DB_PRIMARY or DB_REPLICA
282     *
283     * @return IDatabase
284     */
285    private function getConnectionRef( $dbIndex ): IDatabase {
286        return $this->loadBalancer->getConnectionRef( $dbIndex );
287    }
288
289    /**
290     * Helper method to deduplicate logic around queries that need to be modified
291     * if watchlist expiration is enabled
292     *
293     * @param SelectQueryBuilder $queryBuilder
294     * @param IDatabase $db
295     */
296    private function modifyQueryBuilderForExpiry(
297        SelectQueryBuilder $queryBuilder,
298        IDatabase $db
299    ) {
300        if ( $this->expiryEnabled ) {
301            $queryBuilder->where( 'we_expiry IS NULL OR we_expiry > ' . $db->addQuotes( $db->timestamp() ) );
302            $queryBuilder->leftJoin( 'watchlist_expiry', null, 'wl_id = we_item' );
303        }
304    }
305
306    /**
307     * Deletes ALL watched items for the given user when under
308     * $updateRowsPerQuery entries exist.
309     *
310     * @since 1.30
311     *
312     * @param UserIdentity $user
313     *
314     * @return bool true on success, false when too many items are watched
315     */
316    public function clearUserWatchedItems( UserIdentity $user ): bool {
317        if ( $this->mustClearWatchedItemsUsingJobQueue( $user ) ) {
318            return false;
319        }
320
321        $dbw = $this->loadBalancer->getConnectionRef( DB_PRIMARY );
322
323        if ( $this->expiryEnabled ) {
324            $ticket = $this->lbFactory->getEmptyTransactionTicket( __METHOD__ );
325            // First fetch the wl_ids.
326            $wlIds = $dbw->newSelectQueryBuilder()
327                ->select( 'wl_id' )
328                ->from( 'watchlist' )
329                ->where( [ 'wl_user' => $user->getId() ] )
330                ->caller( __METHOD__ )
331                ->fetchFieldValues();
332            if ( $wlIds ) {
333                // Delete rows from both the watchlist and watchlist_expiry tables.
334                $dbw->delete(
335                    'watchlist',
336                    [ 'wl_id' => $wlIds ],
337                    __METHOD__
338                );
339
340                $dbw->delete(
341                    'watchlist_expiry',
342                    [ 'we_item' => $wlIds ],
343                    __METHOD__
344                );
345            }
346            $this->lbFactory->commitAndWaitForReplication( __METHOD__, $ticket );
347        } else {
348            $dbw->delete(
349                'watchlist',
350                [ 'wl_user' => $user->getId() ],
351                __METHOD__
352            );
353        }
354
355        $this->uncacheAllItemsForUser( $user );
356
357        return true;
358    }
359
360    /**
361     * @param UserIdentity $user
362     * @return bool
363     */
364    public function mustClearWatchedItemsUsingJobQueue( UserIdentity $user ): bool {
365        return $this->countWatchedItems( $user ) > $this->updateRowsPerQuery;
366    }
367
368    /**
369     * @param UserIdentity $user
370     */
371    private function uncacheAllItemsForUser( UserIdentity $user ) {
372        $userId = $user->getId();
373        foreach ( $this->cacheIndex as $ns => $dbKeyIndex ) {
374            foreach ( $dbKeyIndex as $dbKey => $userIndex ) {
375                if ( array_key_exists( $userId, $userIndex ) ) {
376                    $this->cache->delete( $userIndex[$userId] );
377                    unset( $this->cacheIndex[$ns][$dbKey][$userId] );
378                }
379            }
380        }
381
382        // Cleanup empty cache keys
383        foreach ( $this->cacheIndex as $ns => $dbKeyIndex ) {
384            foreach ( $dbKeyIndex as $dbKey => $userIndex ) {
385                if ( empty( $this->cacheIndex[$ns][$dbKey] ) ) {
386                    unset( $this->cacheIndex[$ns][$dbKey] );
387                }
388            }
389            if ( empty( $this->cacheIndex[$ns] ) ) {
390                unset( $this->cacheIndex[$ns] );
391            }
392        }
393    }
394
395    /**
396     * Queues a job that will clear the users watchlist using the Job Queue.
397     *
398     * @since 1.31
399     *
400     * @param UserIdentity $user
401     */
402    public function clearUserWatchedItemsUsingJobQueue( UserIdentity $user ) {
403        $job = ClearUserWatchlistJob::newForUser( $user, $this->getMaxId() );
404        $this->queueGroup->push( $job );
405    }
406
407    /**
408     * @inheritDoc
409     */
410    public function maybeEnqueueWatchlistExpiryJob(): void {
411        if ( !$this->expiryEnabled ) {
412            // No need to purge expired entries if there are none
413            return;
414        }
415
416        $max = mt_getrandmax();
417        if ( mt_rand( 0, $max ) < $max * $this->watchlistPurgeRate ) {
418            // The higher the watchlist purge rate, the more likely we are to enqueue a job.
419            $this->queueGroup->lazyPush( new WatchlistExpiryJob() );
420        }
421    }
422
423    /**
424     * @since 1.31
425     * @return int The maximum current wl_id
426     */
427    public function getMaxId(): int {
428        return (int)$this->getConnectionRef( DB_REPLICA )->newSelectQueryBuilder()
429            ->select( 'MAX(wl_id)' )
430            ->from( 'watchlist' )
431            ->caller( __METHOD__ )
432            ->fetchField();
433    }
434
435    /**
436     * @since 1.31
437     * @param UserIdentity $user
438     * @return int
439     */
440    public function countWatchedItems( UserIdentity $user ): int {
441        $dbr = $this->getConnectionRef( DB_REPLICA );
442        $queryBuilder = $this->getConnectionRef( DB_REPLICA )->newSelectQueryBuilder()
443            ->select( 'COUNT(*)' )
444            ->from( 'watchlist' )
445            ->where( [ 'wl_user' => $user->getId() ] )
446            ->caller( __METHOD__ );
447
448        $this->modifyQueryBuilderForExpiry( $queryBuilder, $dbr );
449
450        return (int)$queryBuilder->fetchField();
451    }
452
453    /**
454     * @since 1.27
455     * @param LinkTarget|PageIdentity $target deprecated passing LinkTarget since 1.36
456     * @return int
457     */
458    public function countWatchers( $target ): int {
459        $dbr = $this->getConnectionRef( DB_REPLICA );
460        $queryBuilder = $dbr->newSelectQueryBuilder()
461            ->select( 'COUNT(*)' )
462            ->from( 'watchlist' )
463            ->where( [
464                'wl_namespace' => $target->getNamespace(),
465                'wl_title' => $target->getDBkey()
466            ] )
467            ->caller( __METHOD__ );
468
469        $this->modifyQueryBuilderForExpiry( $queryBuilder, $dbr );
470
471        return (int)$queryBuilder->fetchField();
472    }
473
474    /**
475     * @since 1.27
476     * @param LinkTarget|PageIdentity $target deprecated passing LinkTarget since 1.36
477     * @param string|int $threshold
478     * @return int
479     */
480    public function countVisitingWatchers( $target, $threshold ): int {
481        $dbr = $this->getConnectionRef( DB_REPLICA );
482        $queryBuilder = $dbr->newSelectQueryBuilder()
483            ->select( 'COUNT(*)' )
484            ->from( 'watchlist' )
485            ->where( [
486                'wl_namespace' => $target->getNamespace(),
487                'wl_title' => $target->getDBkey(),
488                'wl_notificationtimestamp >= ' .
489                $dbr->addQuotes( $dbr->timestamp( $threshold ) ) .
490                ' OR wl_notificationtimestamp IS NULL'
491            ] )
492            ->caller( __METHOD__ );
493
494        $this->modifyQueryBuilderForExpiry( $queryBuilder, $dbr );
495
496        return (int)$queryBuilder->fetchField();
497    }
498
499    /**
500     * @param UserIdentity $user
501     * @param LinkTarget[]|PageIdentity[] $titles deprecated passing LinkTarget[] since 1.36
502     * @return bool
503     */
504    public function removeWatchBatchForUser( UserIdentity $user, array $titles ): bool {
505        if ( $this->readOnlyMode->isReadOnly() ) {
506            return false;
507        }
508        if ( !$user->isRegistered() ) {
509            return false;
510        }
511        if ( !$titles ) {
512            return true;
513        }
514
515        $rows = $this->getTitleDbKeysGroupedByNamespace( $titles );
516        $this->uncacheTitlesForUser( $user, $titles );
517
518        $dbw = $this->getConnectionRef( DB_PRIMARY );
519        $ticket = count( $titles ) > $this->updateRowsPerQuery ?
520            $this->lbFactory->getEmptyTransactionTicket( __METHOD__ ) : null;
521        $affectedRows = 0;
522
523        // Batch delete items per namespace.
524        foreach ( $rows as $namespace => $namespaceTitles ) {
525            $rowBatches = array_chunk( $namespaceTitles, $this->updateRowsPerQuery );
526            foreach ( $rowBatches as $toDelete ) {
527                // First fetch the wl_ids.
528                $wlIds = $dbw->newSelectQueryBuilder()
529                    ->select( 'wl_id' )
530                    ->from( 'watchlist' )
531                    ->where(
532                        [
533                            'wl_user' => $user->getId(),
534                            'wl_namespace' => $namespace,
535                            'wl_title' => $toDelete
536                        ]
537                    )
538                    ->caller( __METHOD__ )
539                    ->fetchFieldValues();
540
541                if ( $wlIds ) {
542                    // Delete rows from both the watchlist and watchlist_expiry tables.
543                    $dbw->delete(
544                        'watchlist',
545                        [ 'wl_id' => $wlIds ],
546                        __METHOD__
547                    );
548                    $affectedRows += $dbw->affectedRows();
549
550                    if ( $this->expiryEnabled ) {
551                        $dbw->delete(
552                            'watchlist_expiry',
553                            [ 'we_item' => $wlIds ],
554                            __METHOD__
555                        );
556                        $affectedRows += $dbw->affectedRows();
557                    }
558                }
559
560                if ( $ticket ) {
561                    $this->lbFactory->commitAndWaitForReplication( __METHOD__, $ticket );
562                }
563            }
564        }
565
566        return (bool)$affectedRows;
567    }
568
569    /**
570     * @since 1.27
571     * @param LinkTarget[]|PageIdentity[] $targets deprecated passing LinkTarget[] since 1.36
572     * @param array $options Supported options are:
573     *  - 'minimumWatchers': filter for pages that have at least a minimum number of watchers
574     * @return array
575     */
576    public function countWatchersMultiple( array $targets, array $options = [] ): array {
577        $linkTargets = array_map( static function ( $target ) {
578            if ( !$target instanceof LinkTarget ) {
579                return new TitleValue( $target->getNamespace(), $target->getDBkey() );
580            }
581            return $target;