Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.51% covered (success)
95.51%
766 / 802
74.55% covered (warning)
74.55%
41 / 55
CRAP
0.00% covered (danger)
0.00%
0 / 1
WatchedItemStore
95.51% covered (success)
95.51%
766 / 802
74.55% covered (warning)
74.55%
41 / 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
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.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
611        $dbr = $this->lbFactory->getReplicaDatabase();
612        $queryBuilder = $dbr->newSelectQueryBuilder()
613            ->select( [ 'wl_namespace', 'wl_title', 'watchers' => 'COUNT(*)' ] )
614            ->from( 'watchlist' )
615            ->where( [ $this->getVisitingWatchersCondition( $dbr, $targetsWithVisitThresholds ) ] )
616            ->groupBy( [ 'wl_namespace', 'wl_title' ] )
617            ->caller( __METHOD__ );
618        if ( $minimumWatchers !== null ) {
619            $queryBuilder->having( 'COUNT(*) >= ' . (int)$minimumWatchers );
620        }
621        $this->modifyQueryBuilderForExpiry( $queryBuilder, $dbr );
622
623        $res = $queryBuilder->fetchResultSet();
624
625        $watcherCounts = [];
626        foreach ( $targetsWithVisitThresholds as [ $target ] ) {
627            /** @var LinkTarget|PageIdentity $target */
628            $watcherCounts[$target->getNamespace()][$target->getDBkey()] = 0;
629        }
630
631        foreach ( $res as $row ) {
632            $watcherCounts[$row->wl_namespace][$row->wl_title] = (int)$row->watchers;
633        }
634
635        return $watcherCounts;
636    }
637
638    /**
639     * Generates condition for the query used in a batch count visiting watchers.
640     *
641     * @param IReadableDatabase $db
642     * @param array $targetsWithVisitThresholds array of pairs (LinkTarget|PageIdentity,
643     *              last visit threshold) - deprecated passing LinkTarget since 1.36
644     * @return string
645     */
646    private function getVisitingWatchersCondition(
647        IReadableDatabase $db,
648        array $targetsWithVisitThresholds
649    ): string {
650        $missingTargets = [];
651        $namespaceConds = [];
652        foreach ( $targetsWithVisitThresholds as [ $target, $threshold ] ) {
653            if ( $threshold === null ) {
654                $missingTargets[] = $target;
655                continue;
656            }
657            /** @var LinkTarget|PageIdentity $target */
658            $namespaceConds[$target->getNamespace()][] = $db->makeList( [
659                'wl_title = ' . $db->addQuotes( $target->getDBkey() ),
660                $db->makeList( [
661                    'wl_notificationtimestamp >= ' . $db->addQuotes( $db->timestamp( $threshold ) ),
662                    'wl_notificationtimestamp IS NULL'
663                ], LIST_OR )
664            ], LIST_AND );
665        }
666
667        $conds = [];
668        foreach ( $namespaceConds as $namespace => $pageConds ) {
669            $conds[] = $db->makeList( [
670                'wl_namespace = ' . $namespace,
671                '(' . $db->makeList( $pageConds, LIST_OR ) . ')'
672            ], LIST_AND );
673        }
674
675        if ( $missingTargets ) {
676            $lb = $this->linkBatchFactory->newLinkBatch( $missingTargets );
677            $conds[] = $lb->constructSet( 'wl', $db );
678        }
679
680        return $db->makeList( $conds, LIST_OR );
681    }
682
683    /**
684     * @since 1.27
685     * @param UserIdentity $user
686     * @param LinkTarget|PageIdentity $target deprecated passing LinkTarget since 1.36
687     * @return WatchedItem|false
688     */
689    public function getWatchedItem( UserIdentity $user, $target ) {
690        if ( !$user->isRegistered() ) {
691            return false;
692        }
693
694        $cached = $this->getCached( $user, $target );
695        if ( $cached && !$cached->isExpired() ) {
696            $this->stats->increment( 'WatchedItemStore.getWatchedItem.cached' );
697            return $cached;
698        }
699        $this->stats->increment( 'WatchedItemStore.getWatchedItem.load' );
700        return $this->loadWatchedItem( $user, $target );
701    }
702
703    /**
704     * @since 1.27
705     * @param UserIdentity $user
706     * @param LinkTarget|PageIdentity $target deprecated passing LinkTarget since 1.36
707     * @return WatchedItem|false
708     */
709    public function loadWatchedItem( UserIdentity $user, $target ) {
710        $item = $this->loadWatchedItemsBatch( $user, [ $target ] );
711        return $item ? $item[0] : false;
712    }
713
714    /**
715     * @since 1.36
716     * @param UserIdentity $user
717     * @param LinkTarget[]|PageIdentity[] $targets deprecated passing LinkTarget[] since 1.36
718     * @return WatchedItem[]|false
719     */
720    public function loadWatchedItemsBatch( UserIdentity $user, array $targets ) {
721        // Only registered user can have a watchlist
722        if ( !$user->isRegistered() ) {
723            return false;
724        }
725
726        $dbr = $this->lbFactory->getReplicaDatabase();
727
728        $rows = $this->fetchWatchedItems(
729            $dbr,
730            $user,
731            [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ],
732            [],
733            $targets
734        );
735
736        if ( !$rows ) {
737            return false;
738        }
739
740        $items = [];
741        foreach ( $rows as $row ) {
742            // TODO: convert to PageIdentity
743            $target = new TitleValue( (int)$row->wl_namespace, $row->wl_title );
744            $item = $this->getWatchedItemFromRow( $user, $target, $row );
745            $this->cache( $item );
746            $items[] = $item;
747        }
748
749        return $items;
750    }
751
752    /**
753     * @since 1.27
754     * @param UserIdentity $user
755     * @param array $options Supported options are:
756     *  - 'forWrite': whether to use the primary database instead of a replica
757     *  - 'sort': how to sort the titles, either SORT_ASC or SORT_DESC
758     *  - 'sortByExpiry': whether to also sort results by expiration, with temporarily watched titles
759     *                    above titles watched indefinitely and titles expiring soonest at the top
760     * @return WatchedItem[]
761     */
762    public function getWatchedItemsForUser( UserIdentity $user, array $options = [] ): array {
763        $options += [ 'forWrite' => false ];
764        $vars = [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ];
765        $orderBy = [];
766        if ( $options['forWrite'] ) {
767            $db = $this->lbFactory->getPrimaryDatabase();
768        } else {
769            $db = $this->lbFactory->getReplicaDatabase();
770        }
771        if ( array_key_exists( 'sort', $options ) ) {
772            Assert::parameter(
773                ( in_array( $options['sort'], [ self::SORT_ASC, self::SORT_DESC ] ) ),
774                '$options[\'sort\']',
775                'must be SORT_ASC or SORT_DESC'
776            );
777            $orderBy[] = "wl_namespace {$options['sort']}";
778            if ( $this->expiryEnabled
779                && array_key_exists( 'sortByExpiry', $options )
780                && $options['sortByExpiry']
781            ) {
782                // Add `wl_has_expiry` column to allow sorting by watched titles that have an expiration date first.
783                $vars['wl_has_expiry'] = $db->conditional( 'we_expiry IS NULL', '0', '1' );
784                // Display temporarily watched titles first.
785                // Order by expiration date, with the titles that will expire soonest at the top.
786                $orderBy[] = "wl_has_expiry DESC";
787                $orderBy[] = "we_expiry ASC";
788            }
789
790            $orderBy[] = "wl_title {$options['sort']}";
791        }
792
793        $res = $this->fetchWatchedItems(
794            $db,
795            $user,
796            $vars,
797            $orderBy
798        );
799
800        $watchedItems = [];
801        foreach ( $res as $row ) {
802            // TODO: convert to PageIdentity
803            $target = new TitleValue( (int)$row->wl_namespace, $row->wl_title );
804            // @todo: Should we add these to the process cache?
805            $watchedItems[] = $this->getWatchedItemFromRow( $user, $target, $row );
806        }
807
808        return $watchedItems;
809    }
810
811    /**
812     * Construct a new WatchedItem given a row from watchlist/watchlist_expiry.
813     * @param UserIdentity $user
814     * @param LinkTarget|PageIdentity $target deprecated passing LinkTarget since 1.36
815     * @param stdClass $row
816     * @return WatchedItem
817     */
818    private function getWatchedItemFromRow(
819        UserIdentity $user,
820        $target,
821        stdClass $row
822    ): WatchedItem {
823        return new WatchedItem(
824            $user,
825            $target,
826            $this->getLatestNotificationTimestamp(
827                $row->wl_notificationtimestamp, $user, $target ),
828            wfTimestampOrNull( TS_ISO_8601, $row->we_expiry ?? null )
829        );
830    }
831
832    /**
833     * Fetches either a single or all watched items for the given user, or a specific set of items.
834     * If a $target is given, IDatabase::selectRow() is called, otherwise select().
835     * If $wgWatchlistExpiry is enabled, expired items are not returned.
836     *
837     * @param IReadableDatabase $db
838     * @param UserIdentity $user
839     * @param array $vars we_expiry is added when $wgWatchlistExpiry is enabled.
840     * @param array $orderBy array of columns
841     * @param LinkTarget|LinkTarget[]|PageIdentity|PageIdentity[]|null $target null if selecting all
842     *        watched items - deprecated passing LinkTarget or LinkTarget[] since 1.36
843     * @return IResultWrapper|stdClass|false
844     */
845    private function fetchWatchedItems(
846        IReadableDatabase $db,
847        UserIdentity $user,
848        array $vars,
849        array $orderBy = [],
850        $target = null
851    ) {
852        $dbMethod = 'select';
853        $queryBuilder = $db->newSelectQueryBuilder()
854            ->select( $vars )
855            ->from( 'watchlist' )
856            ->where( [ 'wl_user' => $user->getId() ] )
857            ->caller( __METHOD__ );
858        if ( $target ) {
859            if ( $target instanceof LinkTarget || $target instanceof PageIdentity ) {
860                $queryBuilder->where( [
861                    'wl_namespace' => $target->getNamespace(),
862                    'wl_title' => $target->getDBkey(),
863                ] );
864                $dbMethod = 'selectRow';
865            } else {
866                $titleConds = [];
867                foreach ( $target as $linkTarget ) {
868                    $titleConds[] = $db->makeList(
869                        [
870                            'wl_namespace' => $linkTarget->getNamespace(),
871                            'wl_title' => $linkTarget->getDBkey(),
872                        ],
873                        $db::LIST_AND
874                    );
875                }
876                $queryBuilder->where( $db->makeList( $titleConds, $db::LIST_OR ) );
877            }
878        }
879
880        $this->modifyQueryBuilderForExpiry( $queryBuilder, $db );
881        if ( $this->expiryEnabled ) {
882            $queryBuilder->field( 'we_expiry' );
883        }
884        if ( $orderBy ) {
885            $queryBuilder->orderBy( $orderBy );
886        }
887
888        if ( $dbMethod == 'selectRow' ) {
889            return $queryBuilder->fetchRow();
890        }
891        return $queryBuilder->fetchResultSet();
892    }
893
894    /**
895     * @since 1.27
896     * @param UserIdentity $user
897     * @param LinkTarget|PageIdentity $target deprecated passing LinkTarget since 1.36
898     * @return bool
899     */
900    public function isWatched( UserIdentity $user, $target ): bool {
901        return (bool)$this->getWatchedItem( $user, $target );
902    }
903
904    /**
905     * Check if the user is temporarily watching the page.
906     * @since 1.35
907     * @param UserIdentity $user
908     * @param LinkTarget|PageIdentity $target deprecated passing LinkTarget since 1.36
909     * @return bool
910     */
911    public function isTempWatched( UserIdentity $user, $target ): bool {
912        $item = $this->getWatchedItem( $user, $target );
913        return $item && $item->getExpiry();
914    }
915
916    /**
917     * @since 1.27
918     * @param UserIdentity $user
919     * @param LinkTarget[] $targets
920     * @return (string|null|false)[][] two dimensional array, first is namespace, second is database key,
921     *                 value is the notification timestamp or null, or false if not available
922     */
923    public function getNotificationTimestampsBatch( UserIdentity $user, array $targets ): array {
924        $timestamps = [];
925        foreach ( $targets as $target ) {
926            $timestamps[$target->getNamespace()][$target->getDBkey()] = false;
927        }
928
929        if ( !$user->isRegistered() ) {
930            return $timestamps;
931        }
932
933        $targetsToLoad = [];
934        foreach ( $targets as $target ) {
935            $cachedItem = $this->getCached( $user, $target );
936            if ( $cachedItem ) {
937                $timestamps[$target->getNamespace()][$target->getDBkey()] =
938                    $cachedItem->getNotificationTimestamp();
939            } else {
940                $targetsToLoad[] = $target;
941            }
942        }
943
944        if ( !$targetsToLoad ) {
945            return $timestamps;
946        }
947
948        $dbr = $this->lbFactory->getReplicaDatabase();
949
950        $lb = $this->linkBatchFactory->newLinkBatch( $targetsToLoad );
951        $res = $dbr->newSelectQueryBuilder()
952            ->select( [ 'wl_namespace', 'wl_title', 'wl_notificationtimestamp' ] )
953            ->from( 'watchlist' )
954            ->where( [
955                $lb->constructSet( 'wl', $dbr ),
956                'wl_user' => $user->getId(),
957            ] )
958            ->caller( __METHOD__ )
959            ->fetchResultSet();
960
961        foreach ( $res as $row ) {
962            // TODO: convert to PageIdentity
963            $target = new TitleValue( (int)$row->wl_namespace, $row->wl_title );
964            $timestamps[$row->wl_namespace][$row->wl_title] =
965                $this->getLatestNotificationTimestamp(
966                    $row->wl_notificationtimestamp, $user, $target );
967        }
968
969        return $timestamps;
970    }
971
972    /**
973     * @since 1.27 Method added.
974     * @since 1.35 Accepts $expiry parameter.
975     * @param UserIdentity $user
976     * @param LinkTarget|PageIdentity $target deprecated passing LinkTarget since 1.36
977     * @param string|null $expiry Optional expiry in any format acceptable to wfTimestamp().
978     *   null will not create an expiry, or leave it unchanged should one already exist.
979     */
980    public function addWatch( UserIdentity $user, $target, ?string $expiry = null ) {
981        $this->addWatchBatchForUser( $user, [ $target ], $expiry );
982
983        if ( $this->expiryEnabled && !$expiry ) {
984            // When re-watching a page with a null $expiry, any existing expiry is left unchanged.
985            // However we must re-fetch the preexisting expiry or else the cached WatchedItem will
986            // incorrectly have a null expiry. Note that loadWatchedItem() does the caching.
987            // See T259379
988            $this->loadWatchedItem( $user, $target );
989        } else {
990            // Create a new WatchedItem and add it to the process cache.
991            // In this case we don't need to re-fetch the expiry.
992            $expiry = ExpiryDef::normalizeUsingMaxExpiry( $expiry, $this->maxExpiryDuration, TS_ISO_8601 );
993            $item = new WatchedItem(
994                $user,
995                $target,
996                null,
997                $expiry
998            );
999            $this->cache( $item );
1000        }
1001    }
1002
1003    /**
1004     * Add multiple items to the user's watchlist.
1005     * If you know you're adding a single page (and/or its talk page) use self::addWatch(),
1006     * since it will add the WatchedItem to the process cache.
1007     *
1008     * @since 1.27 Method added.
1009     * @since 1.35 Accepts $expiry parameter.
1010     * @param UserIdentity $user
1011     * @param LinkTarget[] $targets
1012     * @param string|null $expiry Optional expiry in a format acceptable to wfTimestamp(),
1013     *   null will not create expiries, or leave them unchanged should they already exist.
1014     * @return bool Whether database transactions were performed.
1015     */
1016    public function addWatchBatchForUser(
1017        UserIdentity $user,
1018        array $targets,
1019        ?string $expiry = null
1020    ): bool {
1021        // Only registered user can have a watchlist
1022        if ( !$user->isRegistered() || $this->readOnlyMode->isReadOnly() ) {
1023            return false;
1024        }
1025
1026        if ( !$targets ) {
1027            return true;
1028        }
1029        $expiry = ExpiryDef::normalizeUsingMaxExpiry( $expiry, $this->maxExpiryDuration, TS_ISO_8601 );
1030        $rows = [];
1031        foreach ( $targets as $target ) {
1032            $rows[] = [
1033                'wl_user' => $user->getId(),
1034                'wl_namespace' => $target->getNamespace(),
1035                'wl_title' => $target->getDBkey(),
1036                'wl_notificationtimestamp' => null,
1037            ];
1038            $this->uncache( $user, $target );
1039        }
1040
1041        $dbw = $this->lbFactory->getPrimaryDatabase();
1042        $ticket = count( $targets ) > $this->updateRowsPerQuery ?
1043            $this->lbFactory->getEmptyTransactionTicket( __METHOD__ ) : null;
1044        $affectedRows = 0;
1045        $rowBatches = array_chunk( $rows, $this->updateRowsPerQuery );
1046        foreach ( $rowBatches as $toInsert ) {
1047            // Use INSERT IGNORE to avoid overwriting the notification timestamp
1048            // if there's already an entry for this page
1049            $dbw->newInsertQueryBuilder()
1050                ->insertInto( 'watchlist' )
1051                ->ignore()
1052                ->rows( $toInsert )
1053                ->caller( __METHOD__ )->execute();
1054            $affectedRows += $dbw->affectedRows();
1055
1056            if ( $this->expiryEnabled ) {
1057                $affectedRows += $this->updateOrDeleteExpiries( $dbw, $user->getId(), $toInsert, $expiry );
1058            }
1059
1060            if ( $ticket ) {
1061                $this->lbFactory->commitAndWaitForReplication( __METHOD__, $ticket );
1062            }
1063        }
1064
1065        return (bool)$affectedRows;
1066    }
1067
1068    /**
1069     * Insert/update expiries, or delete them if the expiry is 'infinity'.
1070     *
1071     * @param IDatabase $dbw
1072     * @param int $userId
1073     * @param array $rows
1074     * @param string|null $expiry
1075     * @return int Number of affected rows.
1076     */
1077    private function updateOrDeleteExpiries(
1078        IDatabase $dbw,
1079        int $userId,
1080        array $rows,
1081        ?string $expiry = null
1082    ): int {
1083        if ( !$expiry ) {
1084            // if expiry is null (shouldn't change), 0 rows affected.
1085            return 0;
1086        }
1087
1088        // Build the giant `(...) OR (...)` part to be used with WHERE.
1089        $conds = [];
1090        foreach ( $rows as $row ) {
1091            $conds[] = $dbw->makeList(
1092                [
1093                    'wl_user' => $userId,
1094                    'wl_namespace' => $row['wl_namespace'],
1095                    'wl_title' => $row['wl_title']
1096                ],
1097                $dbw::LIST_AND
1098            );
1099        }
1100        $cond = $dbw->makeList( $conds, $dbw::LIST_OR );
1101
1102        if ( wfIsInfinity( $expiry ) ) {
1103            // Rows should be deleted rather than updated.
1104            $dbw->deleteJoin(
1105                'watchlist_expiry',
1106                'watchlist',
1107                'we_item',
1108                'wl_id',
1109                [ $cond ],
1110                __METHOD__
1111            );
1112
1113            return $dbw->affectedRows();
1114        }
1115
1116        return $this->updateExpiries( $dbw, $expiry, $cond );
1117    }
1118
1119    /**
1120     * Update the expiries for items found with the given $cond.
1121     * @param IDatabase $dbw
1122     * @param string $expiry
1123     * @param string $cond
1124     * @return int Number of affected rows.
1125     */
1126    private function updateExpiries( IDatabase $dbw, string $expiry, string $cond ): int {
1127        // First fetch the wl_ids from the watchlist table.
1128        // We'd prefer to do a INSERT/SELECT in the same query with IDatabase::insertSelect(),
1129        // but it doesn't allow us to use the "ON DUPLICATE KEY UPDATE" clause.
1130        $wlIds = $dbw->newSelectQueryBuilder()
1131            ->select( 'wl_id' )
1132            ->from( 'watchlist' )
1133            ->where( $cond )
1134            ->caller( __METHOD__ )
1135            ->fetchFieldValues();
1136
1137        if ( !$wlIds ) {
1138            return 0;
1139        }
1140
1141        $expiry = $dbw->timestamp( $expiry );
1142        $weRows = [];
1143        foreach ( $wlIds as $wlId ) {
1144            $weRows[] = [
1145                'we_item' => $wlId,
1146                'we_expiry' => $expiry
1147            ];
1148        }
1149
1150        // Insert into watchlist_expiry, updating the expiry for duplicate rows.
1151        $dbw->newInsertQueryBuilder()
1152            ->insertInto( 'watchlist_expiry' )
1153            ->rows( $weRows )
1154            ->onDuplicateKeyUpdate()
1155            ->uniqueIndexFields( [ 'we_item' ] )
1156            ->set( [ 'we_expiry' => $expiry ] )
1157            ->caller( __METHOD__ )->execute();
1158
1159        return $dbw->affectedRows();
1160    }
1161
1162    /**
1163     * @since 1.27
1164     * @param UserIdentity $user
1165     * @param LinkTarget|PageIdentity $target deprecated passing LinkTarget since 1.36
1166     * @return bool
1167     */
1168    public function removeWatch( UserIdentity $user, $target ): bool {
1169        return $this->removeWatchBatchForUser( $user, [ $target ] );
1170    }
1171
1172    /**
1173     * Set the "last viewed" timestamps for certain titles on a user's watchlist.
1174     *
1175     * If the $targets parameter is omitted or set to [], this method simply wraps
1176     * resetAllNotificationTimestampsForUser(), and in that case you should instead call that method
1177     * directly; support for omitting $targets is for backwards compatibility.
1178     *
1179     * If $targets is omitted or set to [], timestamps will be updated for every title on the user's
1180     * watchlist, and this will be done through a DeferredUpdate. If $targets is a non-empty array,
1181     * only the specified titles will be updated, and this will be done immediately (not deferred).
1182     *
1183     * @since 1.27
1184     * @param UserIdentity $user
1185     * @param string|int $timestamp Value to set the "last viewed" timestamp to (null to clear)
1186     * @param LinkTarget[] $targets Titles to set the timestamp for; [] means the entire watchlist
1187     * @return bool
1188     */
1189    public function setNotificationTimestampsForUser(
1190        UserIdentity $user,
1191        $timestamp,
1192        array $targets = []
1193    ): bool {
1194        // Only registered user can have a watchlist
1195        if ( !$user->isRegistered() || $this->readOnlyMode->isReadOnly() ) {
1196            return false;
1197        }
1198
1199        if ( !$targets ) {
1200            // Backwards compatibility
1201            $this->resetAllNotificationTimestampsForUser( $user, $timestamp );
1202            return true;
1203        }
1204
1205        $rows = $this->getTitleDbKeysGroupedByNamespace( $targets );
1206
1207        $dbw = $this->lbFactory->getPrimaryDatabase();
1208        if ( $timestamp !== null ) {
1209            $timestamp = $dbw->timestamp( $timestamp );
1210        }
1211        $ticket = $this->lbFactory->getEmptyTransactionTicket( __METHOD__ );
1212        $affectedSinceWait = 0;
1213
1214        // Batch update items per namespace
1215        foreach ( $rows as $namespace => $namespaceTitles ) {
1216            $rowBatches = array_chunk( $namespaceTitles, $this->updateRowsPerQuery );
1217            foreach ( $rowBatches as $toUpdate ) {
1218                // First fetch the wl_ids.
1219                $wlIds = $dbw->newSelectQueryBuilder()
1220                    ->select( 'wl_id' )
1221                    ->from( 'watchlist' )
1222                    ->where( [
1223                        'wl_user' => $user->getId(),
1224                        'wl_namespace' => $namespace,
1225                        'wl_title' => $toUpdate
1226                    ] )
1227                    ->caller( __METHOD__ )
1228                    ->fetchFieldValues();
1229                if ( $wlIds ) {
1230                    $wlIds = array_map( 'intval', $wlIds );
1231                    $dbw->newUpdateQueryBuilder()
1232                        ->update( 'watchlist' )
1233                        ->set( [ 'wl_notificationtimestamp' => $timestamp ] )
1234                        ->where( [ 'wl_id' => $wlIds ] )
1235                        ->caller( __METHOD__ )->execute();
1236
1237                    $affectedSinceWait += $dbw->affectedRows();
1238                    // Wait for replication every time we've touched updateRowsPerQuery rows
1239                    if ( $affectedSinceWait >= $this->updateRowsPerQuery ) {
1240                        $this->lbFactory->commitAndWaitForReplication( __METHOD__, $ticket );
1241                        $affectedSinceWait = 0;
1242                    }
1243                }
1244            }
1245        }
1246
1247        $this->uncacheUser( $user );
1248
1249        return true;
1250    }
1251
1252    /**
1253     * @param string|null $timestamp
1254     * @param UserIdentity $user
1255     * @param LinkTarget|PageIdentity $target deprecated passing LinkTarget since 1.36
1256     * @return bool|string|null
1257     */
1258    public function getLatestNotificationTimestamp(
1259        $timestamp,
1260        UserIdentity $user,
1261        $target
1262    ) {
1263        $timestamp = wfTimestampOrNull( TS_MW, $timestamp );
1264        if ( $timestamp === null ) {
1265            return null; // no notification
1266        }
1267
1268        $seenTimestamps = $this->getPageSeenTimestamps( $user );
1269        if ( $seenTimestamps ) {
1270            $seenKey = $this->getPageSeenKey( $target );
1271            if ( isset( $seenTimestamps[$seenKey] ) && $seenTimestamps[$seenKey] >= $timestamp ) {
1272                // If a reset job did not yet run, then the "seen" timestamp will be higher
1273                return null;
1274            }
1275        }
1276
1277        return $timestamp;
1278    }
1279
1280    /**
1281     * Schedule a DeferredUpdate that sets all of the "last viewed" timestamps for a given user
1282     * to the same value.
1283     * @param UserIdentity $user
1284     * @param string|int|null $timestamp Value to set all timestamps to, null to clear them
1285     */
1286    public function resetAllNotificationTimestampsForUser( UserIdentity $user, $timestamp = null ) {
1287        // Only registered user can have a watchlist
1288        if ( !$user->isRegistered() ) {
1289            return;
1290        }
1291
1292        // If the page is watched by the user (or may be watched), update the timestamp
1293        $job = new ClearWatchlistNotificationsJob( [
1294            'userId'  => $user->getId(), 'timestamp' => $timestamp, 'casTime' => time()
1295        ] );
1296
1297        // Try to run this post-send
1298        // Calls DeferredUpdates::addCallableUpdate in normal operation
1299        call_user_func(
1300            $this->deferredUpdatesAddCallableUpdateCallback,
1301            static function () use ( $job ) {
1302                $job->run();
1303            }
1304        );
1305    }
1306
1307    /**
1308     * Update wl_notificationtimestamp for all watching users except the editor
1309     * @since 1.27
1310     * @param UserIdentity $editor
1311     * @param LinkTarget|PageIdentity $target deprecated passing LinkTarget since 1.36
1312     * @param string|int $timestamp
1313     * @return int[]
1314     */
1315    public function updateNotificationTimestamp(
1316        UserIdentity $editor,
1317        $target,
1318        $timestamp
1319    ): array {
1320        $dbw = $this->lbFactory->getPrimaryDatabase();
1321        $queryBuilder = $dbw->newSelectQueryBuilder()
1322            ->select( [ 'wl_id', 'wl_user' ] )
1323            ->from( 'watchlist' )
1324            ->where(
1325                [
1326                    'wl_user != ' . $editor->getId(),
1327                    'wl_namespace' => $target->getNamespace(),
1328                    'wl_title' => $target->getDBkey(),
1329                    'wl_notificationtimestamp' => null,
1330                ]
1331            )
1332            ->caller( __METHOD__ );
1333
1334        $this->modifyQueryBuilderForExpiry( $queryBuilder, $dbw );
1335
1336        $res = $queryBuilder->fetchResultSet();
1337        $watchers = [];
1338        $wlIds = [];
1339        foreach ( $res as $row ) {
1340            $watchers[] = (int)$row->wl_user;
1341            $wlIds[] = (int)$row->wl_id;
1342        }
1343
1344        if ( $wlIds ) {
1345            $fname = __METHOD__;
1346            // Try to run this post-send
1347            // Calls DeferredUpdates::addCallableUpdate in normal operation
1348            call_user_func(
1349                $this->deferredUpdatesAddCallableUpdateCallback,
1350                function () use ( $timestamp, $wlIds, $target, $fname ) {
1351                    $dbw = $this->lbFactory->getPrimaryDatabase();
1352                    $ticket = $this->lbFactory->getEmptyTransactionTicket( $fname );
1353
1354                    $wlIdsChunks = array_chunk( $wlIds, $this->updateRowsPerQuery );
1355                    foreach ( $wlIdsChunks as $wlIdsChunk ) {
1356                        $dbw->newUpdateQueryBuilder()
1357                            ->update( 'watchlist' )
1358                            ->set( [ 'wl_notificationtimestamp' => $dbw->timestamp( $timestamp ) ] )
1359                            ->where( [ 'wl_id' => $wlIdsChunk ] )
1360                            ->caller( $fname )->execute();
1361
1362                        if ( count( $wlIdsChunks ) > 1 ) {
1363                            $this->lbFactory->commitAndWaitForReplication( $fname, $ticket );
1364                        }
1365                    }
1366                    $this->uncacheLinkTarget( $target );
1367                },
1368                DeferredUpdates::POSTSEND,
1369                $dbw
1370            );
1371        }
1372
1373        return $watchers;
1374    }
1375
1376    /**
1377     * @since 1.27
1378     * @param UserIdentity $user
1379     * @param LinkTarget|PageIdentity $title deprecated passing LinkTarget since 1.36
1380     * @param string $force
1381     * @param int $oldid
1382     * @return bool
1383     */
1384    public function resetNotificationTimestamp(
1385        UserIdentity $user,
1386        $title,
1387        $force = '',
1388        $oldid = 0
1389    ): bool {
1390        $time = time();
1391
1392        // Only registered user can have a watchlist
1393        if ( !$user->isRegistered() || $this->readOnlyMode->isReadOnly() ) {
1394            return false;
1395        }
1396
1397        $item = null;
1398        if ( $force != 'force' ) {
1399            $item = $this->getWatchedItem( $user, $title );
1400            if ( !$item || $item->getNotificationTimestamp() === null ) {
1401                return false;
1402            }
1403        }
1404
1405        // Get the timestamp (TS_MW) of this revision to track the latest one seen
1406        $id = $oldid;
1407        $seenTime = null;
1408        if ( !$id ) {
1409            $latestRev = $this->revisionLookup->getRevisionByTitle( $title );
1410            if ( $latestRev ) {
1411                $id = $latestRev->getId();
1412                // Save a DB query
1413                $seenTime = $latestRev->getTimestamp();
1414            }
1415        }
1416        if ( $seenTime === null ) {
1417            // @phan-suppress-next-line PhanTypeMismatchArgumentNullable getId does not return null here
1418            $seenTime = $this->revisionLookup->getTimestampFromId( $id );
1419        }
1420
1421        // Mark the item as read immediately in lightweight storage
1422        $this->stash->merge(
1423            $this->getPageSeenTimestampsKey( $user ),
1424            function ( $cache, $key, $current ) use ( $title, $seenTime ) {
1425                if ( !$current ) {
1426                    $value = new MapCacheLRU( 300 );
1427                } elseif ( is_array( $current ) ) {
1428                    $value = MapCacheLRU::newFromArray( $current, 300 );
1429                } else {
1430                    // Backwards compatibility for T282105
1431                    $value = $current;
1432                }
1433                $subKey = $this->getPageSeenKey( $title );
1434
1435                if ( $seenTime > $value->get( $subKey ) ) {
1436                    // Revision is newer than the last one seen
1437                    $value->set( $subKey, $seenTime );
1438
1439                    $this->latestUpdateCache->set( $key, $value->toArray(), BagOStuff::TTL_PROC_LONG );
1440                } elseif ( $seenTime === false ) {
1441                    // Revision does not exist
1442                    $value->set( $subKey, wfTimestamp( TS_MW ) );
1443                    $this->latestUpdateCache->set( $key,
1444                        $value->toArray(),
1445                        BagOStuff::TTL_PROC_LONG );
1446                } else {
1447                    return false; // nothing to update
1448                }
1449
1450                return $value->toArray();
1451            },
1452            BagOStuff::TTL_HOUR
1453        );
1454
1455        // If the page is watched by the user (or may be watched), update the timestamp
1456        // ActivityUpdateJob accepts both LinkTarget and PageReference
1457        $job = new ActivityUpdateJob(
1458            $title,
1459            [
1460                'type'      => 'updateWatchlistNotification',
1461                'userid'    => $user->getId(),
1462                'notifTime' => $this->getNotificationTimestamp( $user, $title, $item, $force, $oldid ),
1463                'curTime'   => $time
1464            ]
1465        );
1466        // Try to enqueue this post-send
1467        $this->queueGroup->lazyPush( $job );
1468
1469        $this->uncache( $user, $title );
1470
1471        return true;
1472    }
1473
1474    /**
1475     * @param UserIdentity $user
1476     * @return array|null The map contains prefixed title keys and TS_MW values
1477     */
1478    private function getPageSeenTimestamps( UserIdentity $user ) {
1479        $key = $this->getPageSeenTimestampsKey( $user );
1480
1481        $cache = $this->latestUpdateCache->getWithSetCallback(
1482            $key,
1483            BagOStuff::TTL_PROC_LONG,
1484            function () use ( $key ) {
1485                return $this->stash->get( $key ) ?: null;
1486            }
1487        );
1488        // Backwards compatibility for T282105
1489        if ( $cache instanceof MapCacheLRU ) {
1490            $cache = $cache->toArray();
1491        }
1492        return $cache;
1493    }
1494
1495    /**
1496     * @param UserIdentity $user
1497     * @return string
1498     */
1499    private function getPageSeenTimestampsKey( UserIdentity $user ): string {
1500        return $this->stash->makeGlobalKey(
1501            'watchlist-recent-updates',
1502            $this->lbFactory->getLocalDomainID(),
1503            $user->getId()
1504        );
1505    }
1506
1507    /**
1508     * @param LinkTarget|PageIdentity $target
1509     * @return string
1510     */
1511    private function getPageSeenKey( $target ): string {
1512        return "{$target->getNamespace()}:{$target->getDBkey()}";
1513    }
1514
1515    /**
1516     * @param UserIdentity $user
1517     * @param LinkTarget|PageIdentity $title deprecated passing LinkTarget since 1.36
1518     * @param WatchedItem|null $item
1519     * @param string $force
1520     * @param int|false $oldid The ID of the last revision that the user viewed
1521     * @return string|null|false
1522     */
1523    private function getNotificationTimestamp(
1524        UserIdentity $user,
1525        $title,
1526        $item,
1527        $force,
1528        $oldid
1529    ) {
1530        if ( !$oldid ) {
1531            // No oldid given, assuming latest revision; clear the timestamp.
1532            return null;
1533        }
1534
1535        $oldRev = $this->revisionLookup->getRevisionById( $oldid );
1536        if ( !$oldRev ) {
1537            // Oldid given but does not exist (probably deleted)
1538            return false;
1539        }
1540
1541        $nextRev = $this->revisionLookup->getNextRevision( $oldRev );
1542        if ( !$nextRev ) {
1543            // Oldid given and is the latest revision for this title; clear the timestamp.
1544            return null;
1545        }
1546
1547        if ( $item === null ) {
1548            $item = $this->loadWatchedItem( $user, $title );
1549        }
1550
1551        if ( !$item ) {
1552            // This can only happen if $force is enabled.
1553            return null;
1554        }
1555
1556        // Oldid given and isn't the latest; update the timestamp.
1557        // This will result in no further notification emails being sent!
1558        $notificationTimestamp = $this->revisionLookup->getTimestampFromId( $oldid );
1559        // @FIXME: this should use getTimestamp() for consistency with updates on new edits
1560        // $notificationTimestamp = $nextRev->getTimestamp(); // first unseen revision timestamp
1561
1562        // We need to go one second to the future because of various strict comparisons
1563        // throughout the codebase
1564        $ts = new MWTimestamp( $notificationTimestamp );
1565        $ts->timestamp->add( new DateInterval( 'PT1S' ) );
1566        $notificationTimestamp = $ts->getTimestamp( TS_MW );
1567
1568        if ( $notificationTimestamp < $item->getNotificationTimestamp() ) {
1569            if ( $force != 'force' ) {
1570                return false;
1571            } else {
1572                // This is a little silly…
1573                return $item->getNotificationTimestamp();
1574            }
1575        }
1576
1577        return $notificationTimestamp;
1578    }
1579
1580    /**
1581     * @since 1.27
1582     * @param UserIdentity $user
1583     * @param int|null $unreadLimit
1584     * @return int|bool
1585     */
1586    public function countUnreadNotifications( UserIdentity $user, $unreadLimit = null ) {
1587        $queryBuilder = $this->lbFactory->getReplicaDatabase()->newSelectQueryBuilder()
1588            ->select( '1' )
1589            ->from( 'watchlist' )
1590            ->where( [
1591                'wl_user' => $user->getId(),
1592                'wl_notificationtimestamp IS NOT NULL'
1593            ] )
1594            ->caller( __METHOD__ );
1595        if ( $unreadLimit !== null ) {
1596            $unreadLimit = (int)$unreadLimit;
1597            $queryBuilder->limit( $unreadLimit );
1598        }
1599
1600        $rowCount = $queryBuilder->fetchRowCount();
1601
1602        if ( $unreadLimit === null ) {
1603            return $rowCount;
1604        }
1605
1606        if ( $rowCount >= $unreadLimit ) {
1607            return true;
1608        }
1609
1610        return $rowCount;
1611    }
1612
1613    /**
1614     * @since 1.27
1615     * @param LinkTarget|PageIdentity $oldTarget deprecated passing LinkTarget since 1.36
1616     * @param LinkTarget|PageIdentity $newTarget deprecated passing LinkTarget since 1.36
1617     */
1618    public function duplicateAllAssociatedEntries( $oldTarget, $newTarget ) {
1619        // Duplicate first the subject page, then the talk page
1620        // TODO: convert to PageIdentity
1621        $this->duplicateEntry(
1622            new TitleValue( $this->nsInfo->getSubject( $oldTarget->getNamespace() ), $oldTarget->getDBkey() ),
1623            new TitleValue( $this->nsInfo->getSubject( $newTarget->getNamespace() ), $newTarget->getDBkey() )
1624        );
1625        $this->duplicateEntry(
1626            new TitleValue( $this->nsInfo->getTalk( $oldTarget->getNamespace() ), $oldTarget->getDBkey() ),
1627            new TitleValue( $this->nsInfo->getTalk( $newTarget->getNamespace() ), $newTarget->getDBkey() )
1628        );
1629    }
1630
1631    /**
1632     * @since 1.27
1633     * @param LinkTarget|PageIdentity $oldTarget deprecated passing LinkTarget since 1.36
1634     * @param LinkTarget|PageIdentity $newTarget deprecated passing LinkTarget since 1.36
1635     */
1636    public function duplicateEntry( $oldTarget, $newTarget ) {
1637        $dbw = $this->lbFactory->getPrimaryDatabase();
1638        $result = $this->fetchWatchedItemsForPage( $dbw, $oldTarget );
1639        $newNamespace = $newTarget->getNamespace();
1640        $newDBkey = $newTarget->getDBkey();
1641
1642        # Construct array to replace into the watchlist
1643        $values = [];
1644        $expiries = [];
1645        foreach ( $result as $row ) {
1646            $values[] = [
1647                'wl_user' => $row->wl_user,
1648                'wl_namespace' => $newNamespace,
1649                'wl_title' => $newDBkey,
1650                'wl_notificationtimestamp' => $row->wl_notificationtimestamp,
1651            ];
1652
1653            if ( $this->expiryEnabled && $row->we_expiry ) {
1654                $expiries[$row->wl_user] = $row->we_expiry;
1655            }
1656        }
1657
1658        if ( !$values ) {
1659            return;
1660        }
1661
1662        // Perform a replace on the watchlist table rows.
1663        // Note that multi-row replace is very efficient for MySQL but may be inefficient for
1664        // some other DBMSes, mostly due to poor simulation by us.
1665        $dbw->newReplaceQueryBuilder()
1666            ->replaceInto( 'watchlist' )
1667            ->uniqueIndexFields( [ 'wl_user', 'wl_namespace', 'wl_title' ] )
1668            ->rows( $values )
1669            ->caller( __METHOD__ )->execute();
1670
1671        if ( $this->expiryEnabled ) {
1672            $this->updateExpiriesAfterMove( $dbw, $expiries, $newNamespace, $newDBkey );
1673        }
1674    }
1675
1676    /**
1677     * @param IReadableDatabase $dbr
1678     * @param LinkTarget|PageIdentity $target
1679     * @return IResultWrapper
1680     */
1681    private function fetchWatchedItemsForPage(
1682        IReadableDatabase $dbr,
1683        $target
1684    ): IResultWrapper {
1685        $queryBuilder = $dbr->newSelectQueryBuilder()
1686            ->select( [ 'wl_user', 'wl_notificationtimestamp' ] )
1687            ->from( 'watchlist' )
1688            ->where( [
1689                'wl_namespace' => $target->getNamespace(),
1690                'wl_title' => $target->getDBkey(),
1691            ] )
1692            ->caller( __METHOD__ )
1693            ->forUpdate();
1694
1695        if ( $this->expiryEnabled ) {
1696            $queryBuilder->leftJoin( 'watchlist_expiry', null, [ 'wl_id = we_item' ] )
1697                ->field( 'we_expiry' );
1698        }
1699
1700        return $queryBuilder->fetchResultSet();
1701    }
1702
1703    /**
1704     * @param IDatabase $dbw
1705     * @param array $expiries
1706     * @param int $namespace
1707     * @param string $dbKey
1708     */
1709    private function updateExpiriesAfterMove(
1710        IDatabase $dbw,
1711        array $expiries,
1712        int $namespace,
1713        string $dbKey
1714    ): void {
1715        $method = __METHOD__;
1716        DeferredUpdates::addCallableUpdate(
1717            function () use ( $dbw, $expiries, $namespace, $dbKey, $method ) {
1718                // First fetch new wl_ids.
1719                $res = $dbw->newSelectQueryBuilder()
1720                    ->select( [ 'wl_user', 'wl_id' ] )
1721                    ->from( 'watchlist' )
1722                    ->where( [
1723                        'wl_namespace' => $namespace,
1724                        'wl_title' => $dbKey,
1725                    ] )
1726                    ->caller( $method )
1727                    ->fetchResultSet();
1728
1729                // Build new array to INSERT into multiple rows at once.
1730                $expiryData = [];
1731                foreach ( $res as $row ) {
1732                    if ( !empty( $expiries[$row->wl_user] ) ) {
1733                        $expiryData[] = [
1734                            'we_item' => $row->wl_id,
1735                            'we_expiry' => $expiries[$row->wl_user],
1736                        ];
1737                    }
1738                }
1739
1740                // Batch the insertions.
1741                $batches = array_chunk( $expiryData, $this->updateRowsPerQuery );
1742                foreach ( $batches as $toInsert ) {
1743                    $dbw->newReplaceQueryBuilder()
1744                        ->replaceInto( 'watchlist_expiry' )
1745                        ->uniqueIndexFields( [ 'we_item' ] )
1746                        ->rows( $toInsert )
1747                        ->caller( $method )->execute();
1748                }
1749            },
1750            DeferredUpdates::POSTSEND,
1751            $dbw
1752        );
1753    }
1754
1755    /**
1756     * @param LinkTarget[]|PageIdentity[] $titles
1757     * @return array
1758     */
1759    private function getTitleDbKeysGroupedByNamespace( array $titles ) {
1760        $rows = [];
1761        foreach ( $titles as $title ) {
1762            // Group titles by namespace.
1763            $rows[ $title->getNamespace() ][] = $title->getDBkey();
1764        }
1765        return $rows;
1766    }
1767
1768    /**
1769     * @param UserIdentity $user
1770     * @param LinkTarget[]|PageIdentity[] $titles
1771     */
1772    private function uncacheTitlesForUser( UserIdentity $user, array $titles ) {
1773        foreach ( $titles as $title ) {
1774            $this->uncache( $user, $title );
1775        }
1776    }
1777
1778    /**
1779     * @inheritDoc
1780     */
1781    public function countExpired(): int {
1782        $dbr = $this->lbFactory->getReplicaDatabase();
1783        return $dbr->newSelectQueryBuilder()
1784            ->select( '*' )
1785            ->from( 'watchlist_expiry' )
1786            ->where( [ 'we_expiry <= ' . $dbr->addQuotes( $dbr->timestamp() ) ] )
1787            ->caller( __METHOD__ )
1788            ->fetchRowCount();
1789    }
1790
1791    /**
1792     * @inheritDoc
1793     */
1794    public function removeExpired( int $limit, bool $deleteOrphans = false ): void {
1795        $dbr = $this->lbFactory->getReplicaDatabase();
1796        $dbw = $this->lbFactory->getPrimaryDatabase();
1797        $ticket = $this->lbFactory->getEmptyTransactionTicket( __METHOD__ );
1798
1799        // Get a batch of watchlist IDs to delete.
1800        $toDelete = $dbr->newSelectQueryBuilder()
1801            ->select( 'we_item' )
1802            ->from( 'watchlist_expiry' )
1803            ->where( [ 'we_expiry <= ' . $dbr->addQuotes( $dbr->timestamp() ) ] )
1804            ->limit( $limit )
1805            ->caller( __METHOD__ )
1806            ->fetchFieldValues();
1807
1808        if ( count( $toDelete ) > 0 ) {
1809            // Delete them from the watchlist and watchlist_expiry table.
1810            $dbw->newDeleteQueryBuilder()
1811                ->deleteFrom( 'watchlist' )
1812                ->where( [ 'wl_id' => $toDelete ] )
1813                ->caller( __METHOD__ )->execute();
1814            $dbw->newDeleteQueryBuilder()
1815                ->deleteFrom( 'watchlist_expiry' )
1816                ->where( [ 'we_item' => $toDelete ] )
1817                ->caller( __METHOD__ )->execute();
1818        }
1819
1820        // Also delete any orphaned or null-expiry watchlist_expiry rows
1821        // (they should not exist, but might because not everywhere knows about the expiry table yet).
1822        if ( $deleteOrphans ) {
1823            $expiryToDelete = $dbr->newSelectQueryBuilder()
1824                ->select( 'we_item' )
1825                ->from( 'watchlist_expiry' )
1826                ->leftJoin( 'watchlist', null, 'wl_id = we_item' )
1827                ->where( $dbr->makeList(
1828                    [ 'wl_id' => null, 'we_expiry' => null ],
1829                    $dbr::LIST_OR
1830                ) )
1831                ->caller( __METHOD__ )
1832                ->fetchFieldValues();
1833            if ( count( $expiryToDelete ) > 0 ) {
1834                $dbw->newDeleteQueryBuilder()
1835                    ->deleteFrom( 'watchlist_expiry' )
1836                    ->where( [ 'we_item' => $expiryToDelete ] )
1837                    ->caller( __METHOD__ )->execute();
1838            }
1839        }
1840
1841        $this->lbFactory->commitAndWaitForReplication( __METHOD__, $ticket );
1842    }
1843}