Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.31% covered (success)
92.31%
60 / 65
91.67% covered (success)
91.67%
11 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
MessageBlobStore
92.31% covered (success)
92.31%
60 / 65
91.67% covered (success)
91.67%
11 / 12
22.22
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 setLogger
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getBlob
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getBlobs
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
6
 makeGlobalPurgeKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 makeModulePurgeKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 makeBlobCacheKey
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 recacheMessageBlob
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 updateMessage
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 clearGlobalCacheEntry
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 fetchMessage
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 generateMessageBlob
61.54% covered (warning)
61.54%
8 / 13
0.00% covered (danger)
0.00%
0 / 1
4.91
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 * @author Roan Kattouw
6 * @author Trevor Parscal
7 */
8
9namespace MediaWiki\ResourceLoader;
10
11use MediaWiki\Json\FormatJson;
12use MediaWiki\MediaWikiServices;
13use Psr\Log\LoggerAwareInterface;
14use Psr\Log\LoggerInterface;
15use Psr\Log\NullLogger;
16use Wikimedia\ObjectCache\WANObjectCache;
17
18/**
19 * This class generates message blobs for use by ResourceLoader.
20 *
21 * A message blob is a JSON object containing the interface messages for a
22 * certain module in a certain language.
23 *
24 * @ingroup ResourceLoader
25 * @since 1.17
26 */
27class MessageBlobStore implements LoggerAwareInterface {
28    /** @var ResourceLoader */
29    private $resourceloader;
30
31    /** @var LoggerInterface */
32    protected $logger;
33
34    /** @var WANObjectCache */
35    protected $wanCache;
36
37    /**
38     * @param ResourceLoader $rl
39     * @param LoggerInterface|null $logger
40     * @param WANObjectCache|null $wanObjectCache
41     */
42    public function __construct(
43        ResourceLoader $rl,
44        ?LoggerInterface $logger,
45        ?WANObjectCache $wanObjectCache
46    ) {
47        $this->resourceloader = $rl;
48        $this->logger = $logger ?? new NullLogger();
49
50        // NOTE: when changing this assignment, make sure the code in the instantiator for
51        // LocalisationCache which calls MessageBlobStore::clearGlobalCacheEntry() uses the
52        // same cache object.
53        $this->wanCache = $wanObjectCache ?? MediaWikiServices::getInstance()
54            ->getMainWANObjectCache();
55    }
56
57    /**
58     * @since 1.27
59     * @param LoggerInterface $logger
60     */
61    public function setLogger( LoggerInterface $logger ): void {
62        $this->logger = $logger;
63    }
64
65    /**
66     * Get the message blob for a module
67     *
68     * @since 1.27
69     * @param Module $module
70     * @param string $lang Language code
71     * @return string JSON
72     */
73    public function getBlob( Module $module, $lang ) {
74        $blobs = $this->getBlobs( [ $module->getName() => $module ], $lang );
75        return $blobs[$module->getName()];
76    }
77
78    /**
79     * Get the message blobs for a set of modules
80     *
81     * @since 1.27
82     * @param Module[] $modules Array of module objects keyed by name
83     * @param string $lang Language code
84     * @return string[] An array mapping module names to message blobs
85     */
86    public function getBlobs( array $modules, $lang ) {
87        // Each cache key for a message blob by module name and language code also has a generic
88        // check key without language code. This is used to invalidate any and all language subkeys
89        // that exist for a module from the updateMessage() method.
90        $checkKeys = [
91            self::makeGlobalPurgeKey( $this->wanCache )
92        ];
93        $cacheKeys = [];
94        foreach ( $modules as $name => $module ) {
95            $cacheKey = $this->makeBlobCacheKey( $name, $lang, $module );
96            $cacheKeys[$name] = $cacheKey;
97            $checkKeys[$cacheKey][] = $this->makeModulePurgeKey( $name );
98        }
99        $curTTLs = [];
100        $result = $this->wanCache->getMulti( array_values( $cacheKeys ), $curTTLs, $checkKeys );
101
102        $blobs = [];
103        foreach ( $modules as $name => $module ) {
104            $key = $cacheKeys[$name];
105            if ( !isset( $result[$key] ) || $curTTLs[$key] === null || $curTTLs[$key] < 0 ) {
106                $blobs[$name] = $this->recacheMessageBlob( $key, $module, $lang );
107            } else {
108                // Use unexpired cache
109                $blobs[$name] = $result[$key];
110            }
111        }
112        return $blobs;
113    }
114
115    /**
116     * @param WANObjectCache $cache
117     * @return string Cache key
118     */
119    private static function makeGlobalPurgeKey( WANObjectCache $cache ) {
120        return $cache->makeGlobalKey( 'resourceloader-messageblob' );
121    }
122
123    /**
124     * Per-module check key, for ::updateMessage()
125     *
126     * @param string $name
127     * @return string Cache key
128     */
129    private function makeModulePurgeKey( $name ) {
130        return $this->wanCache->makeKey( 'resourceloader-messageblob', $name );
131    }
132
133    /**
134     * @param string $name
135     * @param string $lang
136     * @param Module $module
137     * @return string Cache key
138     */
139    private function makeBlobCacheKey( $name, $lang, Module $module ) {
140        $messages = array_values( array_unique( $module->getMessages() ) );
141        sort( $messages );
142        return $this->wanCache->makeKey( 'resourceloader-messageblob',
143            $name,
144            $lang,
145            md5( json_encode( $messages ) )
146        );
147    }
148
149    /**
150     * @since 1.27
151     * @param string $cacheKey
152     * @param Module $module
153     * @param string $lang
154     * @return string JSON blob
155     */
156    protected function recacheMessageBlob( $cacheKey, Module $module, $lang ) {
157        $blob = $this->generateMessageBlob( $module, $lang );
158        $cache = $this->wanCache;
159        $cache->set( $cacheKey, $blob,
160            // Add part of a day to TTL to avoid all modules expiring at once
161            $cache::TTL_WEEK + mt_rand( 0, $cache::TTL_DAY )
162        );
163        return $blob;
164    }
165
166    /**
167     * Invalidate cache keys for modules using this message key.
168     * Called by MessageCache when a message has changed.
169     *
170     * @param string $key Message key
171     */
172    public function updateMessage( $key ): void {
173        $moduleNames = $this->resourceloader->getModulesByMessage( $key );
174        foreach ( $moduleNames as $moduleName ) {
175            // Use the default holdoff TTL to account for database replica DB lag
176            // which can affect MessageCache.
177            $this->wanCache->touchCheckKey( $this->makeModulePurgeKey( $moduleName ) );
178        }
179    }
180
181    /**
182     * Invalidate cache keys for all known modules.
183     *
184     * Used by LocalisationCache, DatabaseUpdater and purgeMessageBlobStore.php script
185     * after regenerating l10n cache.
186     */
187    public static function clearGlobalCacheEntry( WANObjectCache $cache ) {
188        // Disable holdoff TTL because:
189        // - LocalisationCache is populated by messages on-disk and don't have DB lag,
190        //   thus there is no need for hold off. We only clear it after new localisation
191        //   updates are known to be deployed to all servers.
192        // - This global check key invalidates message blobs for all modules for all wikis
193        //   in cache contexts (e.g. languages, skins). Setting a hold-off on this key could
194        //   cause a cache stampede since no values would be stored for several seconds.
195        $cache->touchCheckKey( self::makeGlobalPurgeKey( $cache ), $cache::HOLDOFF_TTL_NONE );
196    }
197
198    /**
199     * @since 1.27
200     * @param string $key Message key
201     * @param string $lang Language code
202     * @return string|null
203     */
204    protected function fetchMessage( $key, $lang ) {
205        $message = wfMessage( $key )->inLanguage( $lang );
206        if ( !$message->exists() ) {
207            $this->logger->warning( 'Failed to find {messageKey} ({lang})', [
208                'messageKey' => $key,
209                'lang' => $lang,
210            ] );
211            $value = null;
212        } else {
213            $value = $message->plain();
214        }
215        return $value;
216    }
217
218    /**
219     * Generate the message blob for a given module in a given language.
220     *
221     * @param Module $module
222     * @param string $lang Language code
223     * @return string JSON blob
224     */
225    private function generateMessageBlob( Module $module, $lang ) {
226        $messages = [];
227        foreach ( $module->getMessages() as $key ) {
228            $value = $this->fetchMessage( $key, $lang );
229            // If the message does not exist, omit it from the blob so that
230            // client-side mw.message may do its own existence handling.
231            if ( $value !== null ) {
232                $messages[$key] = $value;
233            }
234        }
235
236        $json = FormatJson::encode( (object)$messages, false, FormatJson::UTF8_OK );
237        // @codeCoverageIgnoreStart
238        if ( $json === false ) {
239            $this->logger->warning( 'Failed to encode message blob for {module} ({lang})', [
240                'module' => $module->getName(),
241                'lang' => $lang,
242            ] );
243            $json = '{}';
244        }
245        // codeCoverageIgnoreEnd
246        return $json;
247    }
248}