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 ActorStoreFactory $actorStoreFactory,
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 $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
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
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
231 public function getLatestEditTimestamp( UserIdentity $user, int $flags = IDBAccessObject::READ_NORMAL ) {
232 return $this->getUserEditTimestamp( $user, self::LATEST_EDIT, $flags );
233 }
234
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
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
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
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
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
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}
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.
ActorStore factory for any wiki domain.
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.
__construct(private readonly ActorStoreFactory $actorStoreFactory, private readonly IConnectionProvider $dbProvider, private readonly JobQueueGroup $jobQueueGroup, private readonly WANObjectCache $wanObjectCache,)
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...
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.
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.