src/Synchronization/GroupSynchronizationCache.php
<?php
declare( strict_types = 1 );
namespace MediaWiki\Extension\Translate\Synchronization;
use DateTime;
use InvalidArgumentException;
use LogicException;
use MediaWiki\Extension\Translate\Cache\PersistentCache;
use MediaWiki\Extension\Translate\Cache\PersistentCacheEntry;
use MediaWiki\Logger\LoggerFactory;
use Psr\Log\LoggerInterface;
use RuntimeException;
/**
* Message group synchronization cache. Handles storage of data in the cache
* to track which groups are currently being synchronized.
* Stores:
*
* 1. Groups in sync:
* - Key: {hash($groupId)}_$groupId
* - Value: $groupId
* - Tag: See GroupSynchronizationCache::getGroupsTag()
* - Exptime: Set when startSyncTimer is called
*
* 2. Message under each group being modified:
* - Key: {hash($groupId_$messageKey)}_$messageKey
* - Value: MessageUpdateParameter
* - Tag: gsc_$groupId
* - Exptime: none
*
* @author Abijeet Patro
* @license GPL-2.0-or-later
* @since 2020.06
*/
class GroupSynchronizationCache {
private PersistentCache $cache;
private int $initialTimeoutSeconds;
private int $incrementalTimeoutSeconds;
/** @var string Cache tag used for groups */
private const GROUP_LIST_TAG = 'gsc_%group_in_sync%';
/** @var string Cache tag used for tracking groups that have errors */
private const GROUP_ERROR_TAG = 'gsc_%group_with_error%';
/** @var string Cache tag used for tracking groups that are in review */
private const GROUP_IN_REVIEW_TAG = 'gsc_%group_in_review%';
private LoggerInterface $logger;
public function __construct(
PersistentCache $cache,
int $initialTimeoutSeconds = 2400,
int $incrementalTimeoutSeconds = 600
) {
$this->cache = $cache;
// The timeout is set to 40 minutes initially, and then incremented by 10 minutes
// each time a message is marked as processed if group is about to expire.
$this->initialTimeoutSeconds = $initialTimeoutSeconds;
$this->incrementalTimeoutSeconds = $incrementalTimeoutSeconds;
$this->logger = LoggerFactory::getInstance( 'Translate.GroupSynchronization' );
}
/**
* Get the groups currently in sync
* @return string[]
*/
public function getGroupsInSync(): array {
$groupsInSyncEntries = $this->cache->getByTag( self::GROUP_LIST_TAG );
$groups = [];
foreach ( $groupsInSyncEntries as $entry ) {
$groups[] = $entry->value();
}
return $groups;
}
/** Start synchronization process for a group and starts the expiry time */
public function markGroupForSync( string $groupId ): void {
$expTime = $this->getExpireTime( $this->initialTimeoutSeconds );
$this->cache->set(
new PersistentCacheEntry(
$this->getGroupKey( $groupId ),
$groupId,
$expTime,
self::GROUP_LIST_TAG
)
);
$this->logger->debug( 'Started sync for group {groupId}', [ 'groupId' => $groupId ] );
}
public function getSyncEndTime( string $groupId ): ?int {
$cacheEntry = $this->cache->get( $this->getGroupKey( $groupId ) );
return $cacheEntry ? $cacheEntry[0]->exptime() : null;
}
/** End synchronization for a group. Deletes the group key */
public function endSync( string $groupId ): void {
if ( $this->cache->hasEntryWithTag( $this->getGroupTag( $groupId ) ) ) {
throw new InvalidArgumentException(
'Cannot end synchronization for a group that still has messages to be processed.'
);
}
$groupKey = $this->getGroupKey( $groupId );
$this->cache->delete( $groupKey );
$this->logger->debug( 'Ended sync for group {groupId}', [ 'groupId' => $groupId ] );
}
/** End synchronization for a group. Deletes the group key and messages */
public function forceEndSync( string $groupId ): void {
$this->cache->deleteEntriesWithTag( $this->getGroupTag( $groupId ) );
$this->endSync( $groupId );
}
/** Add messages for a group to the cache */
public function addMessages( string $groupId, MessageUpdateParameter ...$messageParams ): void {
$messagesToAdd = [];
$groupTag = $this->getGroupTag( $groupId );
foreach ( $messageParams as $messageParam ) {
$titleKey = $this->getMessageKeys( $groupId, $messageParam->getPageName() )[0];
$messagesToAdd[] = new PersistentCacheEntry(
$titleKey,
$messageParam,
null,
$groupTag
);
}
$this->cache->set( ...$messagesToAdd );
}
/** Check if the group is in synchronization */
public function isGroupBeingProcessed( string $groupId ): bool {
$groupEntry = $this->cache->get( $this->getGroupKey( $groupId ) );
return $groupEntry !== [];
}
/**
* Return all messages in a group
* @param string $groupId
* @return MessageUpdateParameter[] Returns a key value pair, with the key being the
* messageKey and value being MessageUpdateParameter
*/
public function getGroupMessages( string $groupId ): array {
$messageEntries = $this->cache->getByTag( $this->getGroupTag( $groupId ) );
$allMessageParams = [];
foreach ( $messageEntries as $entry ) {
$message = $entry->value();
if ( $message instanceof MessageUpdateParameter ) {
$allMessageParams[$message->getPageName()] = $message;
} else {
// Should not happen, but handle primarily to keep phan happy.
throw $this->invalidArgument( $message, MessageUpdateParameter::class );
}
}
return $allMessageParams;
}
/** Check if a message is being processed */
public function isMessageBeingProcessed( string $groupId, string $messageKey ): bool {
$messageCacheKey = $this->getMessageKeys( $groupId, $messageKey );
return $this->cache->has( $messageCacheKey[0] );
}
/** Get the current synchronization status of the group. Does not perform any updates. */
public function getSynchronizationStatus( string $groupId ): GroupSynchronizationResponse {
if ( !$this->isGroupBeingProcessed( $groupId ) ) {
// Group is currently not being processed.
throw new LogicException(
'Sync requested for a group currently not being processed. Check if ' .
'group is being processed by calling isGroupBeingProcessed() first'
);
}
$remainingMessages = $this->getGroupMessages( $groupId );
// No messages are present
if ( !$remainingMessages ) {
return new GroupSynchronizationResponse( $groupId, [], false );
}
$syncExpTime = $this->getSyncEndTime( $groupId );
if ( $syncExpTime === null ) {
// This should not happen
throw new RuntimeException(
"Unexpected condition. Group: $groupId; Messages present, but group key not found."
);
}
$hasTimedOut = $this->hasGroupTimedOut( $syncExpTime );
return new GroupSynchronizationResponse(
$groupId,
$remainingMessages,
$hasTimedOut
);
}
/** Remove messages from the cache. */
public function removeMessages( string $groupId, string ...$messageKeys ): void {
$messageCacheKeys = $this->getMessageKeys( $groupId, ...$messageKeys );
$this->cache->delete( ...$messageCacheKeys );
}
public function addGroupErrors( GroupSynchronizationResponse $response ): void {
$groupId = $response->getGroupId();
$remainingMessages = $response->getRemainingMessages();
if ( !$remainingMessages ) {
throw new LogicException( 'Cannot add a group without any remaining messages to the errors list' );
}
$groupMessageErrorTag = $this->getGroupMessageErrorTag( $groupId );
$entriesToSave = [];
foreach ( $remainingMessages as $messageParam ) {
$titleErrorKey = $this->getMessageErrorKey( $groupId, $messageParam->getPageName() )[0];
$entriesToSave[] = new PersistentCacheEntry(
$titleErrorKey,
$messageParam,
null,
$groupMessageErrorTag
);
}
$this->cache->set( ...$entriesToSave );
$groupErrorKey = $this->getGroupErrorKey( $groupId );
// Check if the group already has errors
$groupInfo = $this->cache->get( $groupErrorKey );
if ( $groupInfo ) {
return;
}
// Group did not have an error previously, add it now. When adding,
// remove the remaining messages from the GroupSynchronizationResponse to
// avoid the value in the cache becoming too big. The remaining messages
// are stored as separate items in the cache.
$trimmedGroupSyncResponse = new GroupSynchronizationResponse(
$groupId,
[],
$response->hasTimedOut()
);
$entriesToSave[] = new PersistentCacheEntry(
$groupErrorKey,
$trimmedGroupSyncResponse,
null,
self::GROUP_ERROR_TAG
);
$this->cache->set( ...$entriesToSave );
}
/**
* Return the groups that have errors
* @return string[]
*/
public function getGroupsWithErrors(): array {
$groupsInSyncEntries = $this->cache->getByTag( self::GROUP_ERROR_TAG );
$groupIds = [];
foreach ( $groupsInSyncEntries as $entry ) {
$groupResponse = $entry->value();
if ( $groupResponse instanceof GroupSynchronizationResponse ) {
$groupIds[] = $groupResponse->getGroupId();
} else {
// Should not happen, but handle primarily to keep phan happy.
throw $this->invalidArgument( $groupResponse, GroupSynchronizationResponse::class );
}
}
return $groupIds;
}
/** Fetch information about a particular group that has errors including messages that failed */
public function getGroupErrorInfo( string $groupId ): GroupSynchronizationResponse {
$groupMessageErrorTag = $this->getGroupMessageErrorTag( $groupId );
$groupMessageEntries = $this->cache->getByTag( $groupMessageErrorTag );
$groupErrorKey = $this->getGroupErrorKey( $groupId );
$groupResponseEntry = $this->cache->get( $groupErrorKey );
$groupResponse = $groupResponseEntry[0] ? $groupResponseEntry[0]->value() : null;
if ( $groupResponse ) {
if ( !$groupResponse instanceof GroupSynchronizationResponse ) {
// Should not happen, but handle primarily to keep phan happy.
throw $this->invalidArgument( $groupResponse, GroupSynchronizationResponse::class );
}
} else {
throw new LogicException( 'Requested to fetch errors for a group that has no errors.' );
}
$messageParams = [];
foreach ( $groupMessageEntries as $messageEntries ) {
$messageParam = $messageEntries->value();
if ( $messageParam instanceof MessageUpdateParameter ) {
$messageParams[] = $messageParam;
} else {
// Should not happen, but handle primarily to keep phan happy.
throw $this->invalidArgument( $messageParam, MessageUpdateParameter::class );
}
}
return new GroupSynchronizationResponse(
$groupId,
$messageParams,
$groupResponse->hasTimedOut()
);
}
/** Marks all messages in a group and the group itself as resolved */
public function markGroupAsResolved( string $groupId ): GroupSynchronizationResponse {
$groupSyncResponse = $this->getGroupErrorInfo( $groupId );
$errorMessages = $groupSyncResponse->getRemainingMessages();
$errorMessageKeys = [];
foreach ( $errorMessages as $message ) {
$errorMessageKeys[] = $this->getMessageErrorKey( $groupId, $message->getPageName() )[0];
}
$this->cache->delete( ...$errorMessageKeys );
return $this->syncGroupErrors( $groupId );
}
/** Marks errors for a message as resolved */
public function markMessageAsResolved( string $groupId, string $messagePageName ): void {
$messageErrorKey = $this->getMessageErrorKey( $groupId, $messagePageName )[0];
$messageInCache = $this->cache->get( $messageErrorKey );
if ( !$messageInCache ) {
throw new InvalidArgumentException(
'Message does not appear to have synchronization errors'
);
}
$this->cache->delete( $messageErrorKey );
}
/** Checks if the group has errors */
public function groupHasErrors( string $groupId ): bool {
$groupErrorKey = $this->getGroupErrorKey( $groupId );
return $this->cache->has( $groupErrorKey );
}
/** Checks if group has unresolved error messages. If not clears the group from error list */
public function syncGroupErrors( string $groupId ): GroupSynchronizationResponse {
$groupSyncResponse = $this->getGroupErrorInfo( $groupId );
if ( $groupSyncResponse->getRemainingMessages() ) {
return $groupSyncResponse;
}
// No remaining messages left, remove group from errors list.
$groupErrorKey = $this->getGroupErrorKey( $groupId );
$this->cache->delete( $groupErrorKey );
return $groupSyncResponse;
}
/**
* Return groups that are in review
* @return string[]
*/
public function getGroupsInReview(): array {
$groupsInReviewEntries = $this->cache->getByTag( self::GROUP_IN_REVIEW_TAG );
$groups = [];
foreach ( $groupsInReviewEntries as $entry ) {
$groups[] = $entry->value();
}
return $groups;
}
public function markGroupAsInReview( string $groupId ): void {
$groupReviewKey = $this->getGroupReviewKey( $groupId );
$this->cache->set(
new PersistentCacheEntry(
$groupReviewKey,
$groupId,
null,
self::GROUP_IN_REVIEW_TAG
)
);
$this->logger->debug( 'Group {groupId} marked for review', [ 'groupId' => $groupId ] );
}
public function markGroupAsReviewed( string $groupId ): void {
$groupReviewKey = $this->getGroupReviewKey( $groupId );
$this->cache->delete( $groupReviewKey );
$this->logger->debug( 'Group {groupId} removed from review', [ 'groupId' => $groupId ] );
}
public function isGroupInReview( string $groupId ): bool {
return $this->cache->has( $this->getGroupReviewKey( $groupId ) );
}
public function extendGroupExpiryTime( string $groupId ): void {
$groupKey = $this->getGroupKey( $groupId );
$groupEntry = $this->cache->get( $groupKey );
if ( $groupEntry === [] ) {
// Group is currently not being processed.
throw new LogicException(
'Requested extension of expiry time for a group that is not being processed. ' .
'Check if group is being processed by calling isGroupBeingProcessed() first'
);
}
if ( $groupEntry[0]->hasExpired() ) {
throw new InvalidArgumentException(
'Cannot extend expiry time for a group that has already expired.'
);
}
$newExpiryTime = $this->getExpireTime( $this->incrementalTimeoutSeconds );
// We start with the initial timeout minutes, we only change the timeout if the group
// is actually about to expire.
if ( $newExpiryTime < $groupEntry[0]->exptime() ) {
return;
}
$this->cache->setExpiry( $groupKey, $newExpiryTime );
}
/** @internal - Internal; For testing use only */
public function getGroupExpiryTime( string $groupId ): int {
$groupKey = $this->getGroupKey( $groupId );
$groupEntry = $this->cache->get( $groupKey );
if ( $groupEntry === [] ) {
throw new InvalidArgumentException( "$groupId currently not in processing!" );
}
return $groupEntry[0]->exptime();
}
private function hasGroupTimedOut( int $syncExpTime ): bool {
return ( new DateTime() )->getTimestamp() > $syncExpTime;
}
private function getExpireTime( int $timeoutSeconds ): int {
$currentTime = ( new DateTime() )->getTimestamp();
return ( new DateTime() )
->setTimestamp( $currentTime + $timeoutSeconds )
->getTimestamp();
}
private function invalidArgument( $value, string $expectedType ): RuntimeException {
$valueType = get_debug_type( $value );
return new RuntimeException( "Expected $expectedType, got $valueType" );
}
// Cache keys / tag related functions start here.
private function getGroupTag( string $groupId ): string {
return 'gsc_' . $groupId;
}
private function getGroupKey( string $groupId ): string {
$hash = substr( hash( 'sha256', $groupId ), 0, 40 );
return substr( "{$hash}_$groupId", 0, 255 );
}
/** @return string[] */
private function getMessageKeys( string $groupId, string ...$messages ): array {
$messageKeys = [];
foreach ( $messages as $message ) {
$key = $groupId . '_' . $message;
$hash = substr( hash( 'sha256', $key ), 0, 40 );
$finalKey = substr( $hash . '_' . $key, 0, 255 );
$messageKeys[] = $finalKey;
}
return $messageKeys;
}
private function getGroupErrorKey( string $groupId ): string {
$hash = substr( hash( 'sha256', $groupId ), 0, 40 );
return substr( "{$hash}_gsc_error_$groupId", 0, 255 );
}
/** @return string[] */
private function getMessageErrorKey( string $groupId, string ...$messages ): array {
$messageKeys = [];
foreach ( $messages as $message ) {
$key = $groupId . '_' . $message;
$hash = substr( hash( 'sha256', $key ), 0, 40 );
$finalKey = substr( $hash . '_gsc_error_' . $key, 0, 255 );
$messageKeys[] = $finalKey;
}
return $messageKeys;
}
private function getGroupMessageErrorTag( string $groupId ): string {
return "gsc_%error%_$groupId";
}
private function getGroupReviewKey( string $groupId ): string {
$hash = substr( hash( 'sha256', $groupId ), 0, 40 );
return substr( "{$hash}_gsc_%review%_$groupId", 0, 255 );
}
}