<?php
/**
- * Resource message blobs storage used by ResourceLoader.
+ * Message blobs storage used by ResourceLoader.
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* @file
* @author Roan Kattouw
* @author Trevor Parscal
+ * @author Timo Tijhof
*/
+use Psr\Log\LoggerAwareInterface;
+use Psr\Log\LoggerInterface;
+use Psr\Log\NullLogger;
+
/**
- * This class provides access to the resource message blobs storage used
- * by ResourceLoader.
+ * This class generates message blobs for use by ResourceLoader modules.
*
- * A message blob is a JSON object containing the interface messages for a
- * certain resource in a certain language. These message blobs are cached
- * in the msg_resource table and automatically invalidated when one of their
- * constituent messages or the resource itself is changed.
+ * A message blob is a JSON object containing the interface messages for a certain module in
+ * a certain language.
*/
-class MessageBlobStore {
+class MessageBlobStore implements LoggerAwareInterface {
+
+ /* @var ResourceLoader|null */
+ private $resourceloader;
+
/**
- * In-process cache for message blobs.
- *
- * Keyed by language code, then module name.
- *
- * @var array
+ * @var LoggerInterface
*/
- protected $blobCache = array();
+ protected $logger;
- /* @var ResourceLoader */
- protected $resourceloader;
+ /**
+ * @var WANObjectCache
+ */
+ protected $wanCache;
/**
- * @param ResourceLoader $resourceloader
+ * @param ResourceLoader $rl
+ * @param LoggerInterface $logger
*/
- public function __construct( ResourceLoader $resourceloader = null ) {
- $this->resourceloader = $resourceloader;
+ public function __construct( ResourceLoader $rl = null, LoggerInterface $logger = null ) {
+ $this->resourceloader = $rl;
+ $this->logger = $logger ?: new NullLogger();
+ $this->wanCache = ObjectCache::getMainWANInstance();
}
/**
- * Get the singleton instance
+ * @since 1.27
+ * @param LoggerInterface $logger
+ */
+ public function setLogger( LoggerInterface $logger ) {
+ $this->logger = $logger;
+ }
+
+ /**
+ * Get the message blob for a module
*
- * @since 1.24
- * @deprecated since 1.25
- * @return MessageBlobStore
+ * @since 1.27
+ * @param ResourceLoaderModule $module
+ * @param string $lang Language code
+ * @return string JSON
*/
- public static function getInstance() {
- wfDeprecated( __METHOD__, '1.25' );
- return new self;
+ public function getBlob( ResourceLoaderModule $module, $lang ) {
+ $blobs = $this->getBlobs( array( $module->getName() => $module ), $lang );
+ return $blobs[$module->getName()];
}
/**
* Get the message blobs for a set of modules
*
- * @param ResourceLoader $resourceLoader
- * @param array $modules Array of module objects keyed by module name
+ * @since 1.27
+ * @param ResourceLoaderModule[] $modules Array of module objects keyed by name
* @param string $lang Language code
* @return array An array mapping module names to message blobs
*/
- public function get( ResourceLoader $resourceLoader, $modules, $lang ) {
- if ( !count( $modules ) ) {
- return array();
+ public function getBlobs( array $modules, $lang ) {
+ // Each cache key for a message blob by module name and language code also has a generic
+ // check key without language code. This is used to invalidate any and all language subkeys
+ // that exist for a module from the updateMessage() method.
+ $cache = $this->wanCache;
+ $checkKeys = array(
+ // Global check key, see clear()
+ $cache->makeKey( __CLASS__ )
+ );
+ $cacheKeys = array();
+ foreach ( $modules as $name => $module ) {
+ $cacheKey = $this->makeCacheKey( $module, $lang );
+ $cacheKeys[$name] = $cacheKey;
+ // Per-module check key, see updateMessage()
+ $checkKeys[$cacheKey][] = $cache->makeKey( __CLASS__, $name );
}
+ $curTTLs = array();
+ $result = $cache->getMulti( array_values( $cacheKeys ), $curTTLs, $checkKeys );
$blobs = array();
-
- // Try in-process cache
- $missingFromCache = array();
foreach ( $modules as $name => $module ) {
- if ( isset( $this->blobCache[$lang][$name] ) ) {
- $blobs[$name] = $this->blobCache[$lang][$name];
+ $key = $cacheKeys[$name];
+ if ( !isset( $result[$key] ) || $curTTLs[$key] === null || $curTTLs[$key] < 0 ) {
+ $this->logger->info( 'Message blob cache-miss for {module}',
+ array( 'module' => $name, 'cacheKey' => $key )
+ );
+ $blobs[$name] = $this->recacheMessageBlob( $key, $module, $lang );
} else {
- $missingFromCache[] = $name;
- }
- }
-
- // Try DB cache
- if ( $missingFromCache ) {
- $blobs += $this->getFromDB( $resourceLoader, $missingFromCache, $lang );
- }
-
- // Generate new blobs for any remaining modules and store in DB
- $missingFromDb = array_diff( array_keys( $modules ), array_keys( $blobs ) );
- foreach ( $missingFromDb as $name ) {
- $blob = $this->insertMessageBlob( $name, $modules[$name], $lang );
- if ( $blob ) {
- $blobs[$name] = $blob;
+ // Use unexpired cache
+ $blobs[$name] = $result[$key];
}
}
-
- // Update in-process cache
- if ( isset( $this->blobCache[$lang] ) ) {
- $this->blobCache[$lang] += $blobs;
- } else {
- $this->blobCache[$lang] = $blobs;
- }
-
return $blobs;
}
/**
- * Generate and insert a new message blob. If the blob was already
- * present, it is not regenerated; instead, the preexisting blob
- * is fetched and returned.
- *
- * @param string $name Module name
- * @param ResourceLoaderModule $module
- * @param string $lang Language code
- * @return mixed Message blob or false if the module has no messages
+ * @deprecated since 1.27 Use getBlobs() instead
+ * @return array
*/
- public function insertMessageBlob( $name, ResourceLoaderModule $module, $lang ) {
- $blob = $this->generateMessageBlob( $module, $lang );
-
- if ( !$blob ) {
- return false;
- }
-
- try {
- $dbw = wfGetDB( DB_MASTER );
- $success = $dbw->insert( 'msg_resource', array(
- 'mr_lang' => $lang,
- 'mr_resource' => $name,
- 'mr_blob' => $blob,
- 'mr_timestamp' => $dbw->timestamp()
- ),
- __METHOD__,
- array( 'IGNORE' )
- );
+ public function get( ResourceLoader $resourceLoader, $modules, $lang ) {
+ return $this->getBlobs( $modules, $lang );
+ }
- if ( $success && $dbw->affectedRows() == 0 ) {
- // Blob was already present, fetch it
- $blob = $dbw->selectField( 'msg_resource', 'mr_blob', array(
- 'mr_resource' => $name,
- 'mr_lang' => $lang,
- ),
- __METHOD__
- );
- }
- } catch ( DBError $e ) {
- wfDebug( __METHOD__ . " failed to update DB: $e\n" );
- }
- return $blob;
+ /**
+ * @deprecated since 1.27 Obsolete. Used to populate a cache table in the database.
+ * @return bool
+ */
+ public function insertMessageBlob( $name, ResourceLoaderModule $module, $lang ) {
+ return false;
}
/**
- * Update the message blob for a given module in a given language
- *
- * @param string $name Module name
+ * @since 1.27
* @param ResourceLoaderModule $module
- * @param string $lang Language code
- * @return string|null Regenerated message blob, or null if there was no blob for
- * the given module/language pair.
+ * @param string $lang
+ * @return string Cache key
*/
- public function updateModule( $name, ResourceLoaderModule $module, $lang ) {
- $dbw = wfGetDB( DB_MASTER );
- $row = $dbw->selectRow( 'msg_resource', 'mr_blob',
- array( 'mr_resource' => $name, 'mr_lang' => $lang ),
- __METHOD__
+ private function makeCacheKey( ResourceLoaderModule $module, $lang ) {
+ $messages = array_values( array_unique( $module->getMessages() ) );
+ sort( $messages );
+ return $this->wanCache->makeKey( __CLASS__, $module->getName(), $lang,
+ md5( json_encode( $messages ) )
);
- if ( !$row ) {
- return null;
- }
-
- $newBlob = $this->generateMessageBlob( $module, $lang );
-
- try {
- $newRow = array(
- 'mr_resource' => $name,
- 'mr_lang' => $lang,
- 'mr_blob' => $newBlob,
- 'mr_timestamp' => $dbw->timestamp()
- );
+ }
- $dbw->replace( 'msg_resource',
- array( array( 'mr_resource', 'mr_lang' ) ),
- $newRow, __METHOD__
- );
- } catch ( Exception $e ) {
- wfDebug( __METHOD__ . " failed to update DB: $e\n" );
- }
- return $newBlob;
+ /**
+ * @since 1.27
+ * @param string $cacheKey
+ * @param ResourceLoaderModule $module
+ * @param string $lang
+ * @return string JSON blob
+ */
+ protected function recacheMessageBlob( $cacheKey, ResourceLoaderModule $module, $lang ) {
+ $blob = $this->generateMessageBlob( $module, $lang );
+ $cache = $this->wanCache;
+ $cache->set( $cacheKey, $blob,
+ // Add part of a day to TTL to avoid all modules expiring at once
+ $cache::TTL_WEEK + mt_rand( 0, $cache::TTL_DAY ),
+ Database::getCacheSetOptions( wfGetDB( DB_SLAVE ) )
+ );
+ return $blob;
}
/**
- * Update a single message in all message blobs it occurs in.
+ * Invalidate cache keys for modules using this message key.
+ * Called by MessageCache when a message has changed.
*
* @param string $key Message key
*/
public function updateMessage( $key ) {
- try {
- $dbw = wfGetDB( DB_MASTER );
-
- // Keep running until the updates queue is empty.
- // Due to update conflicts, the queue might not be emptied
- // in one iteration.
- $updates = null;
- do {
- $updates = $this->getUpdatesForMessage( $key, $updates );
-
- foreach ( $updates as $k => $update ) {
- // Update the row on the condition that it
- // didn't change since we fetched it by putting
- // the timestamp in the WHERE clause.
- $success = $dbw->update( 'msg_resource',
- array(
- 'mr_blob' => $update['newBlob'],
- 'mr_timestamp' => $dbw->timestamp() ),
- array(
- 'mr_resource' => $update['resource'],
- 'mr_lang' => $update['lang'],
- 'mr_timestamp' => $update['timestamp'] ),
- __METHOD__
- );
-
- // Only requeue conflicted updates.
- // If update() returned false, don't retry, for
- // fear of getting into an infinite loop
- if ( !( $success && $dbw->affectedRows() == 0 ) ) {
- // Not conflicted
- unset( $updates[$k] );
- }
- }
- } while ( count( $updates ) );
-
- } catch ( Exception $e ) {
- wfDebug( __METHOD__ . " failed to update DB: $e\n" );
+ $moduleNames = $this->getResourceLoader()->getModulesByMessage( $key );
+ foreach ( $moduleNames as $moduleName ) {
+ // Uses a holdoff to account for database slave lag (for MessageCache)
+ $this->wanCache->touchCheckKey( $this->wanCache->makeKey( __CLASS__, $moduleName ) );
}
}
+ /**
+ * Invalidate cache keys for all known modules.
+ * Called by LocalisationCache after cache is regenerated.
+ */
public function clear() {
- try {
- // Not using TRUNCATE, because that needs extra permissions,
- // which maybe not granted to the database user.
- $dbw = wfGetDB( DB_MASTER );
- $dbw->delete( 'msg_resource', '*', __METHOD__ );
- } catch ( Exception $e ) {
- wfDebug( __METHOD__ . " failed to update DB: $e\n" );
- }
+ $cache = $this->wanCache;
+ // Disable holdoff because this invalidates all modules and also not needed since
+ // LocalisationCache is stored outside the database and doesn't have lag.
+ $cache->touchCheckKey( $cache->makeKey( __CLASS__ ), $cache::HOLDOFF_NONE );
}
/**
+ * @since 1.27
* @return ResourceLoader
*/
protected function getResourceLoader() {
- // For back-compat this class supports instantiation without passing ResourceLoader
+ // Back-compat: This class supports instantiation without a ResourceLoader object.
// Lazy-initialise this property because most callers don't need it.
if ( $this->resourceloader === null ) {
- wfDebug( __CLASS__ . ' created without a ResourceLoader instance' );
+ $this->logger->warning( __CLASS__ . ' created without a ResourceLoader instance' );
$this->resourceloader = new ResourceLoader();
}
-
return $this->resourceloader;
}
/**
- * Create an update queue for updateMessage()
- *
- * @param string $key Message key
- * @param array $prevUpdates Updates queue to refresh or null to build a fresh update queue
- * @return array Updates queue
- */
- private function getUpdatesForMessage( $key, $prevUpdates = null ) {
- $dbw = wfGetDB( DB_MASTER );
-
- if ( is_null( $prevUpdates ) ) {
- $rl = $this->getResourceLoader();
- $moduleNames = $rl->getModulesByMessage( $key );
- // Fetch all blobs referencing $key
- $res = $dbw->select(
- array( 'msg_resource' ),
- array( 'mr_resource', 'mr_lang', 'mr_blob', 'mr_timestamp' ),
- array(
- 'mr_resource' => $moduleNames,
- ),
- __METHOD__
- );
- } else {
- // Refetch the blobs referenced by $prevUpdates
-
- // Reorganize the (resource, lang) pairs in the format
- // expected by makeWhereFrom2d()
- $twoD = array();
-
- foreach ( $prevUpdates as $update ) {
- $twoD[$update['resource']][$update['lang']] = true;
- }
-
- $res = $dbw->select( 'msg_resource',
- array( 'mr_resource', 'mr_lang', 'mr_blob', 'mr_timestamp' ),
- $dbw->makeWhereFrom2d( $twoD, 'mr_resource', 'mr_lang' ),
- __METHOD__
- );
- }
-
- // Build the new updates queue
- $updates = array();
-
- foreach ( $res as $row ) {
- $updates[] = array(
- 'resource' => $row->mr_resource,
- 'lang' => $row->mr_lang,
- 'timestamp' => $row->mr_timestamp,
- 'newBlob' => $this->reencodeBlob( $row->mr_blob, $key, $row->mr_lang )
- );
- }
-
- return $updates;
- }
-
- /**
- * Reencode a message blob with the updated value for a message
- *
- * @param string $blob Message blob (JSON object)
+ * @since 1.27
* @param string $key Message key
* @param string $lang Language code
- * @return string Message blob with $key replaced with its new value
+ * @return string
*/
- private function reencodeBlob( $blob, $key, $lang ) {
- $decoded = FormatJson::decode( $blob, true );
- $decoded[$key] = wfMessage( $key )->inLanguage( $lang )->plain();
-
- return FormatJson::encode( (object)$decoded );
- }
-
- /**
- * Get the message blobs for a set of modules from the database.
- * Modules whose blobs are not in the database are silently dropped.
- *
- * @param ResourceLoader $resourceLoader
- * @param array $modules Array of module names
- * @param string $lang Language code
- * @throws MWException
- * @return array Array mapping module names to blobs
- */
- private function getFromDB( ResourceLoader $resourceLoader, $modules, $lang ) {
- if ( !count( $modules ) ) {
- return array();
- }
-
- $retval = array();
- $dbr = wfGetDB( DB_SLAVE );
- $res = $dbr->select( 'msg_resource',
- array( 'mr_blob', 'mr_resource', 'mr_timestamp' ),
- array( 'mr_resource' => $modules, 'mr_lang' => $lang ),
- __METHOD__
- );
-
- foreach ( $res as $row ) {
- $module = $resourceLoader->getModule( $row->mr_resource );
- if ( !$module ) {
- // This shouldn't be possible
- throw new MWException( __METHOD__ . ' passed an invalid module name' );
- }
-
- // Update the module's blob if the list of messages changed
- $blobKeys = array_keys( FormatJson::decode( $row->mr_blob, true ) );
- $moduleMsgs = array_values( array_unique( $module->getMessages() ) );
- if ( $blobKeys !== $moduleMsgs ) {
- $retval[$row->mr_resource] = $this->updateModule( $row->mr_resource, $module, $lang );
- } else {
- $retval[$row->mr_resource] = $row->mr_blob;
- }
- }
-
- return $retval;
+ protected function fetchMessage( $key, $lang ) {
+ $message = wfMessage( $key )->inLanguage( $lang );
+ $value = $message->plain();
+ if ( !$message->exists() ) {
+ $this->logger->warning( 'Failed to find {messageKey} ({lang})', array(
+ 'messageKey' => $key,
+ 'lang' => $lang,
+ ) );
+ }
+ return $value;
}
/**
*
* @param ResourceLoaderModule $module
* @param string $lang Language code
- * @return string JSON object
+ * @return string JSON blob
*/
private function generateMessageBlob( ResourceLoaderModule $module, $lang ) {
$messages = array();
-
foreach ( $module->getMessages() as $key ) {
- $messages[$key] = wfMessage( $key )->inLanguage( $lang )->plain();
+ $messages[$key] = $this->fetchMessage( $key, $lang );
}
- return FormatJson::encode( (object)$messages );
+ $json = FormatJson::encode( (object)$messages );
+ if ( $json === false ) {
+ $this->logger->warning( 'Failed to encode message blob for {module} ({lang})', array(
+ 'module' => $module->getName(),
+ 'lang' => $lang,
+ ) );
+ $json = '{}';
+ }
+ return $json;
}
}