Translate extension for MediaWiki
 
Loading...
Searching...
No Matches
GroupSynchronizationCache.php
1<?php
2declare( strict_types = 1 );
3
5
6use DateTime;
7use InvalidArgumentException;
8use LogicException;
11use RuntimeException;
12
36 private $cache;
38 private $initialTimeoutSeconds;
40 private $incrementalTimeoutSeconds;
41
43 private const GROUP_LIST_TAG = 'gsc_%group_in_sync%';
45 private const GROUP_ERROR_TAG = 'gsc_%group_with_error%';
47 private const GROUP_IN_REVIEW_TAG = 'gsc_%group_in_review%';
48
49 // The timeout is set to 40 minutes initially, and then incremented by 10 minutes
50 // each time a message is marked as processed if group is about to expire.
51 public function __construct(
52 PersistentCache $cache,
53 int $initialTimeoutSeconds = 2400,
54 int $incrementalTimeoutSeconds = 600
55
56 ) {
57 $this->cache = $cache;
58 $this->initialTimeoutSeconds = $initialTimeoutSeconds;
59 $this->incrementalTimeoutSeconds = $incrementalTimeoutSeconds;
60 }
61
66 public function getGroupsInSync(): array {
67 $groupsInSyncEntries = $this->cache->getByTag( self::GROUP_LIST_TAG );
69 $groups = [];
70 foreach ( $groupsInSyncEntries as $entry ) {
71 $groups[] = $entry->value();
72 }
73
74 return $groups;
75 }
76
78 public function markGroupForSync( string $groupId ): void {
79 $expTime = $this->getExpireTime( $this->initialTimeoutSeconds );
80 $this->cache->set(
82 $this->getGroupKey( $groupId ),
83 $groupId,
84 $expTime,
85 self::GROUP_LIST_TAG
86 )
87 );
88 }
89
90 public function getSyncEndTime( string $groupId ): ?int {
91 $cacheEntry = $this->cache->get( $this->getGroupKey( $groupId ) );
92 return $cacheEntry ? $cacheEntry[0]->exptime() : null;
93 }
94
96 public function endSync( string $groupId ): void {
97 if ( $this->cache->hasEntryWithTag( $this->getGroupTag( $groupId ) ) ) {
98 throw new InvalidArgumentException(
99 'Cannot end synchronization for a group that still has messages to be processed.'
100 );
101 }
102
103 $groupKey = $this->getGroupKey( $groupId );
104 $this->cache->delete( $groupKey );
105 }
106
108 public function forceEndSync( string $groupId ): void {
109 $this->cache->deleteEntriesWithTag( $this->getGroupTag( $groupId ) );
110 $this->endSync( $groupId );
111 }
112
114 public function addMessages( string $groupId, MessageUpdateParameter ...$messageParams ): void {
115 $messagesToAdd = [];
116 $groupTag = $this->getGroupTag( $groupId );
117 foreach ( $messageParams as $messageParam ) {
118 $titleKey = $this->getMessageKeys( $groupId, $messageParam->getPageName() )[0];
119 $messagesToAdd[] = new PersistentCacheEntry(
120 $titleKey,
121 $messageParam,
122 null,
123 $groupTag
124 );
125 }
126
127 $this->cache->set( ...$messagesToAdd );
128 }
129
131 public function isGroupBeingProcessed( string $groupId ): bool {
132 $groupEntry = $this->cache->get( $this->getGroupKey( $groupId ) );
133 return $groupEntry !== [];
134 }
135
142 public function getGroupMessages( string $groupId ): array {
143 $messageEntries = $this->cache->getByTag( $this->getGroupTag( $groupId ) );
144
145 $allMessageParams = [];
146 foreach ( $messageEntries as $entry ) {
147 $message = $entry->value();
148 if ( $message instanceof MessageUpdateParameter ) {
149 $allMessageParams[$message->getPageName()] = $message;
150 } else {
151 // Should not happen, but handle primarily to keep phan happy.
152 throw $this->invalidArgument( $message, MessageUpdateParameter::class );
153 }
154 }
155
156 return $allMessageParams;
157 }
158
160 public function isMessageBeingProcessed( string $groupId, string $messageKey ): bool {
161 $messageCacheKey = $this->getMessageKeys( $groupId, $messageKey );
162 return $this->cache->has( $messageCacheKey[0] );
163 }
164
166 public function getSynchronizationStatus( string $groupId ): GroupSynchronizationResponse {
167 if ( !$this->isGroupBeingProcessed( $groupId ) ) {
168 // Group is currently not being processed.
169 throw new LogicException(
170 'Sync requested for a group currently not being processed. Check if ' .
171 'group is being processed by calling isGroupBeingProcessed() first'
172 );
173 }
174
175 $remainingMessages = $this->getGroupMessages( $groupId );
176
177 // No messages are present
178 if ( !$remainingMessages ) {
179 return new GroupSynchronizationResponse( $groupId, [], false );
180 }
181
182 $syncExpTime = $this->getSyncEndTime( $groupId );
183 if ( $syncExpTime === null ) {
184 // This should not happen
185 throw new RuntimeException(
186 "Unexpected condition. Group: $groupId; Messages present, but group key not found."
187 );
188 }
189
190 $hasTimedOut = $this->hasGroupTimedOut( $syncExpTime );
191
192 return new GroupSynchronizationResponse(
193 $groupId,
194 $remainingMessages,
195 $hasTimedOut
196 );
197 }
198
200 public function removeMessages( string $groupId, string ...$messageKeys ): void {
201 $messageCacheKeys = $this->getMessageKeys( $groupId, ...$messageKeys );
202
203 $this->cache->delete( ...$messageCacheKeys );
204 }
205
206 public function addGroupErrors( GroupSynchronizationResponse $response ): void {
207 $groupId = $response->getGroupId();
208 $remainingMessages = $response->getRemainingMessages();
209
210 if ( !$remainingMessages ) {
211 throw new LogicException( 'Cannot add a group without any remaining messages to the errors list' );
212 }
213
214 $groupMessageErrorTag = $this->getGroupMessageErrorTag( $groupId );
215
216 $entriesToSave = [];
217 foreach ( $remainingMessages as $messageParam ) {
218 $titleErrorKey = $this->getMessageErrorKey( $groupId, $messageParam->getPageName() )[0];
219 $entriesToSave[] = new PersistentCacheEntry(
220 $titleErrorKey,
221 $messageParam,
222 null,
223 $groupMessageErrorTag
224 );
225 }
226
227 $this->cache->set( ...$entriesToSave );
228
229 $groupErrorKey = $this->getGroupErrorKey( $groupId );
230
231 // Check if the group already has errors
232 $groupInfo = $this->cache->get( $groupErrorKey );
233 if ( $groupInfo ) {
234 return;
235 }
236
237 // Group did not have an error previously, add it now. When adding,
238 // remove the remaining messages from the GroupSynchronizationResponse to
239 // avoid the value in the cache becoming too big. The remaining messages
240 // are stored as separate items in the cache.
241 $trimmedGroupSyncResponse = new GroupSynchronizationResponse(
242 $groupId,
243 [],
244 $response->hasTimedOut()
245 );
246
247 $entriesToSave[] = new PersistentCacheEntry(
248 $groupErrorKey,
249 $trimmedGroupSyncResponse,
250 null,
251 self::GROUP_ERROR_TAG
252 );
253
254 $this->cache->set( ...$entriesToSave );
255 }
256
261 public function getGroupsWithErrors(): array {
262 $groupsInSyncEntries = $this->cache->getByTag( self::GROUP_ERROR_TAG );
264 $groupIds = [];
265 foreach ( $groupsInSyncEntries as $entry ) {
266 $groupResponse = $entry->value();
267 if ( $groupResponse instanceof GroupSynchronizationResponse ) {
268 $groupIds[] = $groupResponse->getGroupId();
269 } else {
270 // Should not happen, but handle primarily to keep phan happy.
271 throw $this->invalidArgument( $groupResponse, GroupSynchronizationResponse::class );
272 }
273 }
274
275 return $groupIds;
276 }
277
279 public function getGroupErrorInfo( string $groupId ): GroupSynchronizationResponse {
280 $groupMessageErrorTag = $this->getGroupMessageErrorTag( $groupId );
281 $groupMessageEntries = $this->cache->getByTag( $groupMessageErrorTag );
282
283 $groupErrorKey = $this->getGroupErrorKey( $groupId );
284 $groupResponseEntry = $this->cache->get( $groupErrorKey );
285 $groupResponse = $groupResponseEntry[0] ? $groupResponseEntry[0]->value() : null;
286 if ( $groupResponse ) {
287 if ( !$groupResponse instanceof GroupSynchronizationResponse ) {
288 // Should not happen, but handle primarily to keep phan happy.
289 throw $this->invalidArgument( $groupResponse, GroupSynchronizationResponse::class );
290 }
291 } else {
292 throw new LogicException( 'Requested to fetch errors for a group that has no errors.' );
293 }
294
295 $messageParams = [];
296 foreach ( $groupMessageEntries as $messageEntries ) {
297 $messageParam = $messageEntries->value();
298 if ( $messageParam instanceof MessageUpdateParameter ) {
299 $messageParams[] = $messageParam;
300 } else {
301 // Should not happen, but handle primarily to keep phan happy.
302 throw $this->invalidArgument( $messageParam, MessageUpdateParameter::class );
303 }
304 }
305
306 return new GroupSynchronizationResponse(
307 $groupId,
308 $messageParams,
309 $groupResponse->hasTimedOut()
310 );
311 }
312
314 public function markGroupAsResolved( string $groupId ): GroupSynchronizationResponse {
315 $groupSyncResponse = $this->getGroupErrorInfo( $groupId );
316 $errorMessages = $groupSyncResponse->getRemainingMessages();
317
318 $errorMessageKeys = [];
319 foreach ( $errorMessages as $message ) {
320 $errorMessageKeys[] = $this->getMessageErrorKey( $groupId, $message->getPageName() )[0];
321 }
322
323 $this->cache->delete( ...$errorMessageKeys );
324 return $this->syncGroupErrors( $groupId );
325 }
326
328 public function markMessageAsResolved( string $groupId, string $messagePageName ): void {
329 $messageErrorKey = $this->getMessageErrorKey( $groupId, $messagePageName )[0];
330 $messageInCache = $this->cache->get( $messageErrorKey );
331 if ( !$messageInCache ) {
332 throw new InvalidArgumentException(
333 'Message does not appear to have synchronization errors'
334 );
335 }
336
337 $this->cache->delete( $messageErrorKey );
338 }
339
341 public function groupHasErrors( string $groupId ): bool {
342 $groupErrorKey = $this->getGroupErrorKey( $groupId );
343 return $this->cache->has( $groupErrorKey );
344 }
345
347 public function syncGroupErrors( string $groupId ): GroupSynchronizationResponse {
348 $groupSyncResponse = $this->getGroupErrorInfo( $groupId );
349 if ( $groupSyncResponse->getRemainingMessages() ) {
350 return $groupSyncResponse;
351 }
352
353 // No remaining messages left, remove group from errors list.
354 $groupErrorKey = $this->getGroupErrorKey( $groupId );
355 $this->cache->delete( $groupErrorKey );
356
357 return $groupSyncResponse;
358 }
359
360 public function markGroupAsInReview( string $groupId ): void {
361 $groupReviewKey = $this->getGroupReviewKey( $groupId );
362 $this->cache->set(
363 new PersistentCacheEntry(
364 $groupReviewKey,
365 $groupId,
366 null,
367 self::GROUP_IN_REVIEW_TAG
368 )
369 );
370 }
371
372 public function markGroupAsReviewed( string $groupId ): void {
373 $groupReviewKey = $this->getGroupReviewKey( $groupId );
374 $this->cache->delete( $groupReviewKey );
375 }
376
377 public function isGroupInReview( string $groupId ): bool {
378 return $this->cache->has( $this->getGroupReviewKey( $groupId ) );
379 }
380
381 public function extendGroupExpiryTime( string $groupId ): void {
382 $groupKey = $this->getGroupKey( $groupId );
383 $groupEntry = $this->cache->get( $groupKey );
384
385 if ( $groupEntry === [] ) {
386 // Group is currently not being processed.
387 throw new LogicException(
388 'Requested extension of expiry time for a group that is not being processed. ' .
389 'Check if group is being processed by calling isGroupBeingProcessed() first'
390 );
391 }
392
393 if ( $groupEntry[0]->hasExpired() ) {
394 throw new InvalidArgumentException(
395 'Cannot extend expiry time for a group that has already expired.'
396 );
397 }
398
399 $newExpiryTime = $this->getExpireTime( $this->incrementalTimeoutSeconds );
400
401 // We start with the initial timeout minutes, we only change the timeout if the group
402 // is actually about to expire.
403 if ( $newExpiryTime < $groupEntry[0]->exptime() ) {
404 return;
405 }
406
407 $this->cache->setExpiry( $groupKey, $newExpiryTime );
408 }
409
411 public function getGroupExpiryTime( string $groupId ): int {
412 $groupKey = $this->getGroupKey( $groupId );
413 $groupEntry = $this->cache->get( $groupKey );
414 if ( $groupEntry === [] ) {
415 throw new InvalidArgumentException( "$groupId currently not in processing!" );
416 }
417
418 return $groupEntry[0]->exptime();
419 }
420
421 private function hasGroupTimedOut( int $syncExpTime ): bool {
422 return ( new DateTime() )->getTimestamp() > $syncExpTime;
423 }
424
425 private function getExpireTime( int $timeoutSeconds ): int {
426 $currentTime = ( new DateTime() )->getTimestamp();
427 $expTime = ( new DateTime() )
428 ->setTimestamp( $currentTime + $timeoutSeconds )
429 ->getTimestamp();
430
431 return $expTime;
432 }
433
434 private function invalidArgument( $value, string $expectedType ): RuntimeException {
435 $valueType = $value ? get_class( $value ) : gettype( $value );
436 return new RuntimeException( "Expected $expectedType, got $valueType" );
437 }
438
439 // Cache keys / tag related functions start here.
440
441 private function getGroupTag( string $groupId ): string {
442 return 'gsc_' . $groupId;
443 }
444
445 private function getGroupKey( string $groupId ): string {
446 $hash = substr( hash( 'sha256', $groupId ), 0, 40 );
447 return substr( "{$hash}_$groupId", 0, 255 );
448 }
449
451 private function getMessageKeys( string $groupId, string ...$messages ): array {
452 $messageKeys = [];
453 foreach ( $messages as $message ) {
454 $key = $groupId . '_' . $message;
455 $hash = substr( hash( 'sha256', $key ), 0, 40 );
456 $finalKey = substr( $hash . '_' . $key, 0, 255 );
457 $messageKeys[] = $finalKey;
458 }
459
460 return $messageKeys;
461 }
462
463 private function getGroupErrorKey( string $groupId ): string {
464 $hash = substr( hash( 'sha256', $groupId ), 0, 40 );
465 return substr( "{$hash}_gsc_error_$groupId", 0, 255 );
466 }
467
469 private function getMessageErrorKey( string $groupId, string ...$messages ): array {
470 $messageKeys = [];
471 foreach ( $messages as $message ) {
472 $key = $groupId . '_' . $message;
473 $hash = substr( hash( 'sha256', $key ), 0, 40 );
474 $finalKey = substr( $hash . '_gsc_error_' . $key, 0, 255 );
475 $messageKeys[] = $finalKey;
476 }
477
478 return $messageKeys;
479 }
480
481 private function getGroupMessageErrorTag( string $groupId ): string {
482 return "gsc_%error%_$groupId";
483 }
484
485 private function getGroupReviewKey( string $groupId ): string {
486 $hash = substr( hash( 'sha256', $groupId ), 0, 40 );
487 return substr( "{$hash}_gsc_%review%_$groupId", 0, 255 );
488 }
489}
return[ 'Translate:ConfigHelper'=> static function():ConfigHelper { return new ConfigHelper();}, 'Translate:CsvTranslationImporter'=> static function(MediaWikiServices $services):CsvTranslationImporter { return new CsvTranslationImporter( $services->getWikiPageFactory());}, 'Translate:EntitySearch'=> static function(MediaWikiServices $services):EntitySearch { return new EntitySearch($services->getMainWANObjectCache(), $services->getCollationFactory() ->makeCollation( 'uca-default-u-kn'), MessageGroups::singleton(), $services->getNamespaceInfo(), $services->get( 'Translate:MessageIndex'), $services->getTitleParser(), $services->getTitleFormatter());}, 'Translate:ExternalMessageSourceStateImporter'=> static function(MediaWikiServices $services):ExternalMessageSourceStateImporter { return new ExternalMessageSourceStateImporter($services->getMainConfig(), $services->get( 'Translate:GroupSynchronizationCache'), $services->getJobQueueGroup(), LoggerFactory::getInstance( 'Translate.GroupSynchronization'), $services->get( 'Translate:MessageIndex'));}, 'Translate:FileFormatFactory'=> static function(MediaWikiServices $services):FileFormatFactory { return new FileFormatFactory( $services->getObjectFactory());}, 'Translate:GroupSynchronizationCache'=> static function(MediaWikiServices $services):GroupSynchronizationCache { return new GroupSynchronizationCache( $services->get( 'Translate:PersistentCache'));}, 'Translate:HookRunner'=> static function(MediaWikiServices $services):HookRunner { return new HookRunner( $services->getHookContainer());}, 'Translate:MessageBundleStore'=> static function(MediaWikiServices $services):MessageBundleStore { return new MessageBundleStore($services->get( 'Translate:RevTagStore'), $services->getJobQueueGroup(), $services->getLanguageNameUtils(), $services->get( 'Translate:MessageIndex'));}, 'Translate:MessageGroupReviewStore'=> static function(MediaWikiServices $services):MessageGroupReviewStore { return new MessageGroupReviewStore($services->getDBLoadBalancer(), $services->get( 'Translate:HookRunner'));}, 'Translate:MessageGroupStatsTableFactory'=> static function(MediaWikiServices $services):MessageGroupStatsTableFactory { return new MessageGroupStatsTableFactory($services->get( 'Translate:ProgressStatsTableFactory'), $services->getDBLoadBalancer(), $services->getLinkRenderer(), $services->get( 'Translate:MessageGroupReviewStore'), $services->getMainConfig() ->get( 'TranslateWorkflowStates') !==false);}, 'Translate:MessageIndex'=> static function(MediaWikiServices $services):MessageIndex { $params=$services->getMainConfig() ->get( 'TranslateMessageIndex');if(is_string( $params)) { $params=(array) $params;} $class=array_shift( $params);return new $class( $params);}, 'Translate:MessagePrefixStats'=> static function(MediaWikiServices $services):MessagePrefixStats { return new MessagePrefixStats( $services->getTitleParser());}, 'Translate:ParsingPlaceholderFactory'=> static function():ParsingPlaceholderFactory { return new ParsingPlaceholderFactory();}, 'Translate:PersistentCache'=> static function(MediaWikiServices $services):PersistentCache { return new PersistentDatabaseCache($services->getDBLoadBalancer(), $services->getJsonCodec());}, 'Translate:ProgressStatsTableFactory'=> static function(MediaWikiServices $services):ProgressStatsTableFactory { return new ProgressStatsTableFactory($services->getLinkRenderer(), $services->get( 'Translate:ConfigHelper'));}, 'Translate:RevTagStore'=> static function(MediaWikiServices $services):RevTagStore { return new RevTagStore($services->getDBLoadBalancerFactory());}, 'Translate:SubpageListBuilder'=> static function(MediaWikiServices $services):SubpageListBuilder { return new SubpageListBuilder($services->get( 'Translate:TranslatableBundleFactory'), $services->getLinkBatchFactory());}, 'Translate:TranslatableBundleExporter'=> static function(MediaWikiServices $services):TranslatableBundleExporter { return new TranslatableBundleExporter($services->get( 'Translate:SubpageListBuilder'), $services->getWikiExporterFactory(), $services->getDBLoadBalancer());}, 'Translate:TranslatableBundleFactory'=> static function(MediaWikiServices $services):TranslatableBundleFactory { return new TranslatableBundleFactory($services->get( 'Translate:TranslatablePageStore'), $services->get( 'Translate:MessageBundleStore'));}, 'Translate:TranslatableBundleImporter'=> static function(MediaWikiServices $services):TranslatableBundleImporter { return new TranslatableBundleImporter($services->getWikiImporterFactory(), $services->get( 'Translate:TranslatablePageParser'), $services->getRevisionLookup());}, 'Translate:TranslatableBundleMover'=> static function(MediaWikiServices $services):TranslatableBundleMover { return new TranslatableBundleMover($services->getMovePageFactory(), $services->getJobQueueGroup(), $services->getLinkBatchFactory(), $services->get( 'Translate:TranslatableBundleFactory'), $services->get( 'Translate:SubpageListBuilder'), $services->getMainConfig() ->get( 'TranslatePageMoveLimit'));}, 'Translate:TranslatableBundleStatusStore'=> static function(MediaWikiServices $services):TranslatableBundleStatusStore { return new TranslatableBundleStatusStore($services->getDBLoadBalancer() ->getConnection(DB_PRIMARY), $services->getCollationFactory() ->makeCollation( 'uca-default-u-kn'), $services->getDBLoadBalancer() ->getMaintenanceConnectionRef(DB_PRIMARY));}, 'Translate:TranslatablePageParser'=> static function(MediaWikiServices $services):TranslatablePageParser { return new TranslatablePageParser($services->get( 'Translate:ParsingPlaceholderFactory'));}, 'Translate:TranslatablePageStore'=> static function(MediaWikiServices $services):TranslatablePageStore { return new TranslatablePageStore($services->get( 'Translate:MessageIndex'), $services->getJobQueueGroup(), $services->get( 'Translate:RevTagStore'), $services->getDBLoadBalancer(), $services->get( 'Translate:TranslatableBundleStatusStore'), $services->get( 'Translate:TranslatablePageParser'),);}, 'Translate:TranslationStashReader'=> static function(MediaWikiServices $services):TranslationStashReader { $db=$services->getDBLoadBalancer() ->getConnection(DB_REPLICA);return new TranslationStashStorage( $db);}, 'Translate:TranslationStatsDataProvider'=> static function(MediaWikiServices $services):TranslationStatsDataProvider { return new TranslationStatsDataProvider(new ServiceOptions(TranslationStatsDataProvider::CONSTRUCTOR_OPTIONS, $services->getMainConfig()), $services->getObjectFactory(), $services->getDBLoadBalancer());}, 'Translate:TranslationUnitStoreFactory'=> static function(MediaWikiServices $services):TranslationUnitStoreFactory { return new TranslationUnitStoreFactory( $services->getDBLoadBalancer());}, 'Translate:TranslatorActivity'=> static function(MediaWikiServices $services):TranslatorActivity { $query=new TranslatorActivityQuery($services->getMainConfig(), $services->getDBLoadBalancer());return new TranslatorActivity($services->getMainObjectStash(), $query, $services->getJobQueueGroup());}, 'Translate:TtmServerFactory'=> static function(MediaWikiServices $services):TtmServerFactory { $config=$services->getMainConfig();$default=$config->get( 'TranslateTranslationDefaultService');if( $default===false) { $default=null;} return new TtmServerFactory( $config->get( 'TranslateTranslationServices'), $default);}]
@phpcs-require-sorted-array
Represents a single result from the persistent cache.
removeMessages(string $groupId, string ... $messageKeys)
Remove messages from the cache.
isGroupBeingProcessed(string $groupId)
Check if the group is in synchronization.
addMessages(string $groupId, MessageUpdateParameter ... $messageParams)
Add messages for a group to the cache.
isMessageBeingProcessed(string $groupId, string $messageKey)
Check if a message is being processed.
getGroupErrorInfo(string $groupId)
Fetch information about a particular group that has errors including messages that failed.
markMessageAsResolved(string $groupId, string $messagePageName)
Marks errors for a message as resolved.
markGroupForSync(string $groupId)
Start synchronization process for a group and starts the expiry time.
syncGroupErrors(string $groupId)
Checks if group has unresolved error messages.
markGroupAsResolved(string $groupId)
Marks all messages in a group and the group itself as resolved.
getSynchronizationStatus(string $groupId)
Get the current synchronization status of the group.
Class encapsulating the response returned by the GroupSynchronizationCache when requested for an upda...
Defines what method should be provided by a class implementing a persistent cache.
Finds external changes for file based message groups.