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