Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.69% covered (success)
96.69%
117 / 121
69.23% covered (warning)
69.23%
9 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
UserEditTracker
96.69% covered (success)
96.69%
117 / 121
69.23% covered (warning)
69.23%
9 / 13
40
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getUserEditCount
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
4
 preloadUserEditCountCache
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
7
 initializeUserEditCount
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
2.00
 incrementUserEditCount
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 getFirstEditTimestamp
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
5
 getLatestEditTimestamp
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getUserEditTimestamp
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
5
 clearUserEditCache
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 setCachedUserEditCount
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 invalidateCachedFirstEditTimestamps
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
5.03
 getCacheKey
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 getCacheKeyByUserId
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3namespace MediaWiki\User;
4
5use InvalidArgumentException;
6use LogicException;
7use MediaWiki\DAO\WikiAwareEntity;
8use MediaWiki\Deferred\DeferredUpdates;
9use MediaWiki\Deferred\UserEditCountUpdate;
10use MediaWiki\JobQueue\JobQueueGroup;
11use MediaWiki\WikiMap\WikiMap;
12use Wikimedia\ObjectCache\WANObjectCache;
13use Wikimedia\Rdbms\IConnectionProvider;
14use Wikimedia\Rdbms\IDBAccessObject;
15use Wikimedia\Rdbms\SelectQueryBuilder;
16use Wikimedia\Timestamp\ConvertibleTimestamp;
17use Wikimedia\Timestamp\TimestampFormat as TS;
18
19/**
20 * Track info about user edit counts and timings
21 *
22 * @since 1.35
23 * @ingroup User
24 * @author DannyS712
25 */
26class UserEditTracker {
27
28    private const FIRST_EDIT = 1;
29    private const LATEST_EDIT = 2;
30
31    private const CACHE_FIRST_EDIT = 'firsteditts';
32    private const CACHE_EDIT_COUNT = 'editcount';
33
34    /** @var int[] */
35    private array $userEditCountCache = [];
36
37    public function __construct(
38        private readonly ActorStoreFactory $actorStoreFactory,
39        private readonly IConnectionProvider $dbProvider,
40        private readonly JobQueueGroup $jobQueueGroup,
41        private readonly WANObjectCache $wanObjectCache,
42    ) {
43    }
44
45    /**
46     * Get a user's edit count from the user_editcount field, falling back to initialize
47     *
48     * @param UserIdentity $user
49     * @return int|null Null for anonymous users
50     */
51    public function getUserEditCount( UserIdentity $user ): ?int {
52        if ( !$user->isRegistered() ) {
53            return null;
54        }
55
56        $cacheKey = $this->getCacheKey( self::CACHE_EDIT_COUNT, $user );
57        if ( isset( $this->userEditCountCache[ $cacheKey ] ) ) {
58            return $this->userEditCountCache[ $cacheKey ];
59        }
60
61        $wikiId = $user->getWikiId();
62        $userId = $user->getId( $wikiId );
63        $count = $this->dbProvider->getReplicaDatabase( $wikiId )->newSelectQueryBuilder()
64            ->select( 'user_editcount' )
65            ->from( 'user' )
66            ->where( [ 'user_id' => $userId ] )
67            ->caller( __METHOD__ )->fetchField();
68
69        if ( $count === null ) {
70            // it has not been initialized. do so.
71            $count = $this->initializeUserEditCount( $user );
72        }
73
74        $this->userEditCountCache[ $cacheKey ] = $count;
75        return $count;
76    }
77
78    /**
79     * Preloads the internal edit count cache for the given users.
80     *
81     * Use this when calls to {@link self::getUserEditCount()} are expected for
82     * multiple users, so that the queries can be batched instead of performing
83     * one query per user.
84     *
85     * Unlike {@link self::getUserEditCount()}, this will not try to update the
86     * edit counts stored in user_editcount for users for which the count was
87     * not previously initialized.
88     *
89     * @param UserIdentity[] $users
90     * @since 1.46
91     * @return void
92     */
93    public function preloadUserEditCountCache( array $users ): void {
94        $userIds = [];
95
96        foreach ( $users as $user ) {
97            if (
98                $user->isRegistered() &&
99                $user->getWikiId() === UserIdentity::LOCAL
100            ) {
101                $userIds[] = $user->getId();
102            }
103        }
104
105        $userIds = array_unique( $userIds );
106
107        $dbr = $this->dbProvider->getReplicaDatabase();
108
109        foreach ( array_chunk( $userIds, 500 ) as $batch ) {
110            $rows = $dbr->newSelectQueryBuilder()
111                ->select( [ 'user_id', 'user_editcount' ] )
112                ->from( 'user' )
113                ->where( [ 'user_id' => $batch ] )
114                ->caller( __METHOD__ )
115                ->fetchResultSet();
116
117            foreach ( $rows as $row ) {
118                if ( $row->user_editcount !== null ) {
119                    $key = $this->getCacheKeyByUserId( self::CACHE_EDIT_COUNT, (int)$row->user_id );
120
121                    $this->userEditCountCache[$key] = (int)$row->user_editcount;
122                }
123            }
124        }
125    }
126
127    /**
128     * @internal For use in UserEditCountUpdate class
129     * @param UserIdentity $user
130     * @return int
131     */
132    public function initializeUserEditCount( UserIdentity $user ): int {
133        if ( $user->getWikiId() !== UserIdentity::LOCAL ) {
134            // Don't record edits on remote wikis
135            throw new LogicException( __METHOD__ . ' only supports local users' );
136        }
137
138        $actorStore = $this->actorStoreFactory->getActorStore( $user->getWikiId() );
139        $dbr = $this->dbProvider->getReplicaDatabase();
140        $count = (int)$dbr->newSelectQueryBuilder()
141            ->select( 'COUNT(*)' )
142            ->from( 'revision' )
143            ->where( [ 'rev_actor' => $actorStore->findActorId( $user, $dbr ) ] )
144            ->caller( __METHOD__ )
145            ->fetchField();
146
147        // Defer updating the edit count via a job (T259719)
148        $this->jobQueueGroup->push( new UserEditCountInitJob( [
149            'userId' => $user->getId(),
150            'editCount' => $count,
151        ] ) );
152
153        return $count;
154    }
155
156    /**
157     * Schedule a job to increase a user's edit count
158     *
159     * @since 1.37
160     * @param UserIdentity $user
161     */
162    public function incrementUserEditCount( UserIdentity $user ) {
163        if ( !$user->isRegistered() ) {
164            // Can't store editcount without user row (i.e. unregistered)
165            return;
166        }
167
168        DeferredUpdates::addUpdate(
169            new UserEditCountUpdate( $user, 1 ),
170            DeferredUpdates::POSTSEND
171        );
172    }
173
174    /**
175     * Get the user's first edit timestamp. First edit timestamp is fairly immutable and therefore it's cached.
176     * If you need to obtain uncached data, use $flags different from READ_NORMAL.
177     *
178     * @param UserIdentity $user
179     * @param int $flags bit field, see IDBAccessObject::READ_XXX
180     * @return string|false Timestamp of first edit, or false for non-existent/anonymous user
181     *  accounts.
182     */
183    public function getFirstEditTimestamp(
184        UserIdentity $user,
185        int $flags = IDBAccessObject::READ_NORMAL
186    ): string|false {
187        if ( !$user->isRegistered() ) {
188            // User is unregistered, quick to determine, no need to cache
189            return false;
190        }
191        if ( $flags !== IDBAccessObject::READ_NORMAL ) {
192            return $this->getUserEditTimestamp( $user, self::FIRST_EDIT, $flags );
193        }
194
195        // For users with edits, the first edit timestamp is fairly stable, only deleting their first edit can
196        // alter it, so we can cache it for a long time.
197        $timestamp = $this->wanObjectCache->getWithSetCallback(
198            $this->getCacheKey( self::CACHE_FIRST_EDIT, $user ),
199            WANObjectCache::TTL_MONTH,
200            function () use ( $user ) {
201                $timestamp = $this->getUserEditTimestamp( $user, self::FIRST_EDIT );
202                if ( $timestamp === false ) {
203                    $timestamp = 0;
204                }
205                return $timestamp;
206            },
207            [
208                'lockTSE' => 30,
209                'staleTTL' => WANObjectCache::TTL_DAY,
210                // First edit timestamp is very stable; reduce popularity-based preemptive refresh rate
211                // After the initial hour passes (newAge), the timestamp will be refreshed on average every
212                // 10k requests (i.e. once every 6 hours if requests come at 1 req/sec)
213                'newAge' => 3600,
214                'hotTTR' => 21600,
215            ]
216        );
217        if ( $timestamp === 0 ) {
218            return false;
219        }
220        return $timestamp;
221    }
222
223    /**
224     * Get the user's latest edit timestamp
225     *
226     * @param UserIdentity $user
227     * @param int $flags bit field, see IDBAccessObject::READ_XXX
228     * @return string|false Timestamp of latest edit, or false for non-existent/anonymous user
229     *  accounts.
230     */
231    public function getLatestEditTimestamp( UserIdentity $user, int $flags = IDBAccessObject::READ_NORMAL ) {
232        return $this->getUserEditTimestamp( $user, self::LATEST_EDIT, $flags );
233    }
234
235    /**
236     * Get the timestamp of a user's edit, either their first or latest
237     *
238     * @param UserIdentity $user
239     * @param int $type either self::FIRST_EDIT or ::LATEST_EDIT
240     * @param int $flags bit field, see IDBAccessObject::READ_XXX
241     * @return string|false Timestamp of edit, or false for non-existent/anonymous user accounts.
242     */
243    private function getUserEditTimestamp( UserIdentity $user, int $type, int $flags = IDBAccessObject::READ_NORMAL ) {
244        if ( !$user->isRegistered() ) {
245            return false;
246        }
247        if ( $flags & IDBAccessObject::READ_LATEST ) {
248            $db = $this->dbProvider->getPrimaryDatabase( $user->getWikiId() );
249        } else {
250            $db = $this->dbProvider->getReplicaDatabase( $user->getWikiId() );
251        }
252
253        $actorStore = $this->actorStoreFactory->getActorStore( $user->getWikiId() );
254        $sortOrder = ( $type === self::FIRST_EDIT ) ? SelectQueryBuilder::SORT_ASC : SelectQueryBuilder::SORT_DESC;
255        $time = $db->newSelectQueryBuilder()
256            ->select( 'rev_timestamp' )
257            ->from( 'revision' )
258            ->where( [ 'rev_actor' => $actorStore->findActorId( $user, $db ) ] )
259            ->orderBy( 'rev_timestamp', $sortOrder )
260            ->caller( __METHOD__ )
261            ->fetchField();
262
263        if ( !$time ) {
264            return false; // no edits
265        }
266
267        return ConvertibleTimestamp::convert( TS::MW, $time );
268    }
269
270    /**
271     * @internal For use by User::clearInstanceCache()
272     * @param UserIdentity $user
273     */
274    public function clearUserEditCache( UserIdentity $user ) {
275        if ( !$user->isRegistered() ) {
276            return;
277        }
278
279        $cacheKey = $this->getCacheKey( self::CACHE_EDIT_COUNT, $user );
280        unset( $this->userEditCountCache[ $cacheKey ] );
281    }
282
283    /**
284     * @internal For use by User::loadFromRow() and tests
285     * @param UserIdentity $user
286     * @param int $editCount
287     * @throws InvalidArgumentException If the user is not registered
288     */
289    public function setCachedUserEditCount( UserIdentity $user, int $editCount ) {
290        if ( !$user->isRegistered() ) {
291            throw new InvalidArgumentException( __METHOD__ . ' with an anonymous user' );
292        }
293
294        $cacheKey = $this->getCacheKey( self::CACHE_EDIT_COUNT, $user );
295        $this->userEditCountCache[ $cacheKey ] = $editCount;
296    }
297
298    /**
299     * Invalidates the timestamps of the first edits by users, if they are equal to the timestamps
300     * passed together with users.
301     * @param array<array{0:UserIdentity,1:string|false}> $users Array of pairs (UserIdentity, timestamp).
302     */
303    public function invalidateCachedFirstEditTimestamps( array $users ): void {
304        foreach ( $users as [ $user, $timestamp ] ) {
305            if ( !$user->isRegistered() ) {
306                continue;
307            }
308
309            if ( $timestamp === false ) {
310                // We cache "no first edit" as 0, because false means "don't cache"
311                $timestamp = 0;
312            }
313
314            $key = $this->getCacheKey( self::CACHE_FIRST_EDIT, $user );
315            $cachedTimestamp = $this->wanObjectCache->get( $key );
316
317            if ( $timestamp === $cachedTimestamp ) {
318                $this->wanObjectCache->delete( $key );
319            }
320        }
321    }
322
323    /**
324     * Returns the cache key to be used for reading from or updating the cache
325     * for a given user, identified by its user ID and the ID of the wiki it
326     * belongs to. This key can be used for WANObjectCache and array-based caches.
327     *
328     * @param string $keygroup Key group component, to separate different things.
329     * @param UserIdentity $user User to get the cache key for.
330     * @return string
331     */
332    private function getCacheKey( string $keygroup, UserIdentity $user ): string {
333        if ( !$user->isRegistered() ) {
334            throw new InvalidArgumentException( 'Cannot prepare cache key for an anonymous user' );
335        }
336
337        $wikiId = $user->getWikiId();
338
339        return $this->getCacheKeyByUserId( $keygroup, $user->getId( $wikiId ), $wikiId );
340    }
341
342    /**
343     * Returns the cache key to be used for reading from or updating the cache
344     * for a given user, identified by its user ID and the ID of the wiki it
345     * belongs to. This key can be used for WANObjectCache and array-based caches.
346     *
347     * @param string $keygroup Key group component, to separate different things.
348     * @param int $userId ID of the user to get the cache key for.
349     * @param string|false $wikiId ID of the wiki the user belongs to.
350     * @return string
351     */
352    private function getCacheKeyByUserId(
353        string $keygroup,
354        int $userId,
355        string|bool $wikiId = WikiAwareEntity::LOCAL
356    ): string {
357        if ( $wikiId === WikiAwareEntity::LOCAL ) {
358            $wikiId = WikiMap::getCurrentWikiId();
359        }
360        return $this->wanObjectCache->makeKey( $keygroup, $userId, $wikiId );
361    }
362}