MediaWiki master
UserEditTracker.php
Go to the documentation of this file.
1<?php
2
3namespace MediaWiki\User;
4
5use InvalidArgumentException;
6use LogicException;
16use Wikimedia\Timestamp\ConvertibleTimestamp;
17use Wikimedia\Timestamp\TimestampFormat as TS;
18
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
35 private array $userEditCountCache = [];
36
37 public function __construct(
38 private readonly ActorNormalization $actorNormalization,
39 private readonly IConnectionProvider $dbProvider,
40 private readonly JobQueueGroup $jobQueueGroup,
41 private readonly WANObjectCache $wanObjectCache,
42 ) {
43 }
44
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
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
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 $dbr = $this->dbProvider->getReplicaDatabase();
139 $count = (int)$dbr->newSelectQueryBuilder()
140 ->select( 'COUNT(*)' )
141 ->from( 'revision' )
142 ->where( [ 'rev_actor' => $this->actorNormalization->findActorId( $user, $dbr ) ] )
143 ->caller( __METHOD__ )
144 ->fetchField();
145
146 // Defer updating the edit count via a job (T259719)
147 $this->jobQueueGroup->push( new UserEditCountInitJob( [
148 'userId' => $user->getId(),
149 'editCount' => $count,
150 ] ) );
151
152 return $count;
153 }
154
161 public function incrementUserEditCount( UserIdentity $user ) {
162 if ( !$user->isRegistered() ) {
163 // Can't store editcount without user row (i.e. unregistered)
164 return;
165 }
166
167 DeferredUpdates::addUpdate(
168 new UserEditCountUpdate( $user, 1 ),
169 DeferredUpdates::POSTSEND
170 );
171 }
172
182 public function getFirstEditTimestamp(
183 UserIdentity $user,
184 int $flags = IDBAccessObject::READ_NORMAL
185 ): string|false {
186 if ( !$user->isRegistered() ) {
187 // User is unregistered, quick to determine, no need to cache
188 return false;
189 }
190 if ( $flags !== IDBAccessObject::READ_NORMAL ) {
191 return $this->getUserEditTimestamp( $user, self::FIRST_EDIT, $flags );
192 }
193
194 // For users with edits, the first edit timestamp is fairly stable, only deleting their first edit can
195 // alter it, so we can cache it for a long time.
196 $timestamp = $this->wanObjectCache->getWithSetCallback(
197 $this->getCacheKey( self::CACHE_FIRST_EDIT, $user ),
198 WANObjectCache::TTL_MONTH,
199 function () use ( $user ) {
200 $timestamp = $this->getUserEditTimestamp( $user, self::FIRST_EDIT );
201 if ( $timestamp === false ) {
202 $timestamp = 0;
203 }
204 return $timestamp;
205 },
206 [
207 'lockTSE' => 30,
208 'staleTTL' => WANObjectCache::TTL_DAY,
209 // First edit timestamp is very stable; reduce popularity-based preemptive refresh rate
210 // After the initial hour passes (newAge), the timestamp will be refreshed on average every
211 // 10k requests (i.e. once every 6 hours if requests come at 1 req/sec)
212 'newAge' => 3600,
213 'hotTTR' => 21600,
214 ]
215 );
216 if ( $timestamp === 0 ) {
217 return false;
218 }
219 return $timestamp;
220 }
221
230 public function getLatestEditTimestamp( UserIdentity $user, int $flags = IDBAccessObject::READ_NORMAL ) {
231 return $this->getUserEditTimestamp( $user, self::LATEST_EDIT, $flags );
232 }
233
242 private function getUserEditTimestamp( UserIdentity $user, int $type, int $flags = IDBAccessObject::READ_NORMAL ) {
243 if ( !$user->isRegistered() ) {
244 return false;
245 }
246 if ( $flags & IDBAccessObject::READ_LATEST ) {
247 $db = $this->dbProvider->getPrimaryDatabase( $user->getWikiId() );
248 } else {
249 $db = $this->dbProvider->getReplicaDatabase( $user->getWikiId() );
250 }
251
252 $sortOrder = ( $type === self::FIRST_EDIT ) ? SelectQueryBuilder::SORT_ASC : SelectQueryBuilder::SORT_DESC;
253 $time = $db->newSelectQueryBuilder()
254 ->select( 'rev_timestamp' )
255 ->from( 'revision' )
256 ->where( [ 'rev_actor' => $this->actorNormalization->findActorId( $user, $db ) ] )
257 ->orderBy( 'rev_timestamp', $sortOrder )
258 ->caller( __METHOD__ )
259 ->fetchField();
260
261 if ( !$time ) {
262 return false; // no edits
263 }
264
265 return ConvertibleTimestamp::convert( TS::MW, $time );
266 }
267
272 public function clearUserEditCache( UserIdentity $user ) {
273 if ( !$user->isRegistered() ) {
274 return;
275 }
276
277 $cacheKey = $this->getCacheKey( self::CACHE_EDIT_COUNT, $user );
278 unset( $this->userEditCountCache[ $cacheKey ] );
279 }
280
287 public function setCachedUserEditCount( UserIdentity $user, int $editCount ) {
288 if ( !$user->isRegistered() ) {
289 throw new InvalidArgumentException( __METHOD__ . ' with an anonymous user' );
290 }
291
292 $cacheKey = $this->getCacheKey( self::CACHE_EDIT_COUNT, $user );
293 $this->userEditCountCache[ $cacheKey ] = $editCount;
294 }
295
301 public function invalidateCachedFirstEditTimestamps( array $users ): void {
302 foreach ( $users as [ $user, $timestamp ] ) {
303 if ( !$user->isRegistered() ) {
304 continue;
305 }
306
307 if ( $timestamp === false ) {
308 // We cache "no first edit" as 0, because false means "don't cache"
309 $timestamp = 0;
310 }
311
312 $key = $this->getCacheKey( self::CACHE_FIRST_EDIT, $user );
313 $cachedTimestamp = $this->wanObjectCache->get( $key );
314
315 if ( $timestamp === $cachedTimestamp ) {
316 $this->wanObjectCache->delete( $key );
317 }
318 }
319 }
320
330 private function getCacheKey( string $keygroup, UserIdentity $user ): string {
331 if ( !$user->isRegistered() ) {
332 throw new InvalidArgumentException( 'Cannot prepare cache key for an anonymous user' );
333 }
334
335 $wikiId = $user->getWikiId();
336
337 return $this->getCacheKeyByUserId( $keygroup, $user->getId( $wikiId ), $wikiId );
338 }
339
350 private function getCacheKeyByUserId(
351 string $keygroup,
352 int $userId,
353 string|bool $wikiId = WikiAwareEntity::LOCAL
354 ): string {
355 if ( $wikiId === WikiAwareEntity::LOCAL ) {
356 $wikiId = WikiMap::getCurrentWikiId();
357 }
358 return $this->wanObjectCache->makeKey( $keygroup, $userId, $wikiId );
359 }
360}
if(!defined('MW_SETUP_CALLBACK'))
Definition WebStart.php:71
Defer callable updates to run later in the PHP process.
Handles increment the edit count for a given set of users.
Handle enqueueing of background jobs.
Job that initializes an user's edit count.
Track info about user edit counts and timings.
getLatestEditTimestamp(UserIdentity $user, int $flags=IDBAccessObject::READ_NORMAL)
Get the user's latest edit timestamp.
preloadUserEditCountCache(array $users)
Preloads the internal edit count cache for the given users.
getUserEditCount(UserIdentity $user)
Get a user's edit count from the user_editcount field, falling back to initialize.
getFirstEditTimestamp(UserIdentity $user, int $flags=IDBAccessObject::READ_NORMAL)
Get the user's first edit timestamp.
incrementUserEditCount(UserIdentity $user)
Schedule a job to increase a user's edit count.
initializeUserEditCount(UserIdentity $user)
clearUserEditCache(UserIdentity $user)
invalidateCachedFirstEditTimestamps(array $users)
Invalidates the timestamps of the first edits by users, if they are equal to the timestamps passed to...
__construct(private readonly ActorNormalization $actorNormalization, private readonly IConnectionProvider $dbProvider, private readonly JobQueueGroup $jobQueueGroup, private readonly WANObjectCache $wanObjectCache,)
setCachedUserEditCount(UserIdentity $user, int $editCount)
Tools for dealing with other locally-hosted wikis.
Definition WikiMap.php:19
Multi-datacenter aware caching interface.
Build SELECT queries with a fluent interface.
Marker interface for entities aware of the wiki they belong to.
getWikiId()
Get the ID of the wiki this page belongs to.
const LOCAL
Wiki ID value to use with instances that are defined relative to the local wiki.
Service for dealing with the actor table.
Interface for objects representing user identity.
isRegistered()
This must be equivalent to getId() != 0 and is provided for code readability.
getId( $wikiId=self::LOCAL)
Provide primary and replica IDatabase connections.
Interface for database access objects.