Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
66.23% |
406 / 613 |
|
39.02% |
16 / 41 |
CRAP | |
0.00% |
0 / 1 |
MessageCache | |
66.34% |
406 / 612 |
|
39.02% |
16 / 41 |
1357.33 | |
0.00% |
0 / 1 |
normalizeKey | |
85.71% |
6 / 7 |
|
0.00% |
0 / 1 |
3.03 | |||
__construct | |
100.00% |
20 / 20 |
|
100.00% |
1 / 1 |
1 | |||
setLogger | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getParserOptions | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
12 | |||
getLocalCache | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
saveToLocalCache | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
load | |
33.33% |
4 / 12 |
|
0.00% |
0 / 1 |
16.67 | |||
loadUnguarded | |
45.07% |
32 / 71 |
|
0.00% |
0 / 1 |
86.29 | |||
loadFromDBWithMainLock | |
57.89% |
11 / 19 |
|
0.00% |
0 / 1 |
6.87 | |||
loadFromDBWithLocalLock | |
0.00% |
0 / 14 |
|
0.00% |
0 / 1 |
6 | |||
loadFromDB | |
78.82% |
67 / 85 |
|
0.00% |
0 / 1 |
21.08 | |||
isLanguageLoaded | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
isMainCacheable | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
4 | |||
separateCacheableRows | |
88.89% |
8 / 9 |
|
0.00% |
0 / 1 |
4.02 | |||
replace | |
83.33% |
10 / 12 |
|
0.00% |
0 / 1 |
5.12 | |||
refreshAndReplaceInternal | |
65.00% |
26 / 40 |
|
0.00% |
0 / 1 |
12.47 | |||
isCacheExpired | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
4 | |||
saveToCaches | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
2 | |||
getValidationHash | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
3 | |||
setValidationHash | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
1 | |||
getReentrantScopedLock | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
2 | |||
get | |
82.22% |
37 / 45 |
|
0.00% |
0 / 1 |
12.81 | |||
getLanguageObject | |
16.67% |
2 / 12 |
|
0.00% |
0 / 1 |
35.36 | |||
getMessageFromFallbackChain | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
2 | |||
getMessageForLang | |
97.30% |
36 / 37 |
|
0.00% |
0 / 1 |
14 | |||
getMessagePageName | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
getMsgFromNamespace | |
73.53% |
25 / 34 |
|
0.00% |
0 / 1 |
13.24 | |||
loadCachedMessagePageEntry | |
79.41% |
27 / 34 |
|
0.00% |
0 / 1 |
4.14 | |||
transform | |
16.67% |
2 / 12 |
|
0.00% |
0 / 1 |
8.21 | |||
getParser | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
parse | |
0.00% |
0 / 24 |
|
0.00% |
0 / 1 |
30 | |||
disable | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
enable | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
isDisabled | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
clear | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
figureMessage | |
91.67% |
11 / 12 |
|
0.00% |
0 / 1 |
3.01 | |||
getAllMessageKeys | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
6 | |||
updateMessageOverride | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
2.06 | |||
getCheckKey | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getMessageTextFromContent | |
77.78% |
7 / 9 |
|
0.00% |
0 / 1 |
4.18 | |||
bigMessageCacheKey | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 |
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 | */ |
20 | |
21 | use MediaWiki\Config\ServiceOptions; |
22 | use MediaWiki\Context\RequestContext; |
23 | use MediaWiki\Deferred\DeferredUpdates; |
24 | use MediaWiki\Deferred\MessageCacheUpdate; |
25 | use MediaWiki\HookContainer\HookContainer; |
26 | use MediaWiki\HookContainer\HookRunner; |
27 | use MediaWiki\Language\ILanguageConverter; |
28 | use MediaWiki\Languages\LanguageConverterFactory; |
29 | use MediaWiki\Languages\LanguageFactory; |
30 | use MediaWiki\Languages\LanguageFallback; |
31 | use MediaWiki\Languages\LanguageNameUtils; |
32 | use MediaWiki\Linker\LinkTarget; |
33 | use MediaWiki\Logger\LoggerFactory; |
34 | use MediaWiki\MainConfigNames; |
35 | use MediaWiki\MediaWikiServices; |
36 | use MediaWiki\Page\PageReference; |
37 | use MediaWiki\Page\PageReferenceValue; |
38 | use MediaWiki\Parser\Parser; |
39 | use MediaWiki\Parser\ParserOutput; |
40 | use MediaWiki\Revision\SlotRecord; |
41 | use MediaWiki\StubObject\StubObject; |
42 | use MediaWiki\StubObject\StubUserLang; |
43 | use MediaWiki\Title\Title; |
44 | use Psr\Log\LoggerAwareInterface; |
45 | use Psr\Log\LoggerInterface; |
46 | use Wikimedia\LightweightObjectStore\ExpirationAwareness; |
47 | use Wikimedia\ObjectCache\BagOStuff; |
48 | use Wikimedia\ObjectCache\EmptyBagOStuff; |
49 | use Wikimedia\Rdbms\Database; |
50 | use Wikimedia\Rdbms\IExpression; |
51 | use Wikimedia\Rdbms\IResultWrapper; |
52 | use Wikimedia\Rdbms\LikeValue; |
53 | use Wikimedia\RequestTimeout\TimeoutException; |
54 | use Wikimedia\ScopedCallback; |
55 | |
56 | /** |
57 | * MediaWiki message cache structure version. |
58 | * Bump this whenever the message cache format has changed. |
59 | */ |
60 | define( 'MSG_CACHE_VERSION', 2 ); |
61 | |
62 | /** |
63 | * Cache messages that are defined by MediaWiki-namespace pages or by hooks. |
64 | * |
65 | * @ingroup Language |
66 | */ |
67 | class MessageCache implements LoggerAwareInterface { |
68 | /** |
69 | * Options to be included in the ServiceOptions |
70 | */ |
71 | public const CONSTRUCTOR_OPTIONS = [ |
72 | MainConfigNames::UseDatabaseMessages, |
73 | MainConfigNames::MaxMsgCacheEntrySize, |
74 | MainConfigNames::AdaptiveMessageCache, |
75 | MainConfigNames::UseXssLanguage, |
76 | MainConfigNames::RawHtmlMessages, |
77 | ]; |
78 | |
79 | /** |
80 | * The size of the MapCacheLRU which stores message data. The maximum |
81 | * number of languages which can be efficiently loaded in a given request. |
82 | */ |
83 | public const MAX_REQUEST_LANGUAGES = 10; |
84 | |
85 | private const FOR_UPDATE = 1; // force message reload |
86 | |
87 | /** How long to wait for memcached locks */ |
88 | private const WAIT_SEC = 15; |
89 | /** How long memcached locks last */ |
90 | private const LOCK_TTL = 30; |
91 | |
92 | /** |
93 | * Lifetime for cache, for keys stored in $wanCache, in seconds. |
94 | * @var int |
95 | */ |
96 | private const WAN_TTL = ExpirationAwareness::TTL_DAY; |
97 | |
98 | /** @var LoggerInterface */ |
99 | private $logger; |
100 | |
101 | /** |
102 | * Process cache of loaded messages that are defined in MediaWiki namespace |
103 | * |
104 | * @var MapCacheLRU Map of (language code => key => " <MESSAGE>" or "!TOO BIG" or "!ERROR") |
105 | */ |
106 | private $cache; |
107 | |
108 | /** |
109 | * Map of (lowercase message key => unused) for all software-defined messages |
110 | * |
111 | * @var array |
112 | */ |
113 | private $systemMessageNames; |
114 | |
115 | /** |
116 | * @var bool[] Map of (language code => boolean) |
117 | */ |
118 | private $cacheVolatile = []; |
119 | |
120 | /** |
121 | * Should mean that database cannot be used, but check |
122 | * @var bool |
123 | */ |
124 | private $disable; |
125 | |
126 | /** @var int Maximum entry size in bytes */ |
127 | private $maxEntrySize; |
128 | |
129 | /** @var bool */ |
130 | private $adaptive; |
131 | |
132 | /** @var bool */ |
133 | private $useXssLanguage; |
134 | |
135 | /** @var string[] */ |
136 | private $rawHtmlMessages; |
137 | |
138 | /** |
139 | * Message cache has its own parser which it uses to transform messages |
140 | * @var ParserOptions |
141 | */ |
142 | private $parserOptions; |
143 | |
144 | /** @var ?Parser Lazy-created via self::getParser() */ |
145 | private $parser = null; |
146 | |
147 | /** |
148 | * @var bool |
149 | */ |
150 | private $inParser = false; |
151 | |
152 | /** @var WANObjectCache */ |
153 | private $wanCache; |
154 | /** @var BagOStuff */ |
155 | private $clusterCache; |
156 | /** @var BagOStuff */ |
157 | private $srvCache; |
158 | /** @var Language */ |
159 | private $contLang; |
160 | /** @var string */ |
161 | private $contLangCode; |
162 | /** @var ILanguageConverter */ |
163 | private $contLangConverter; |
164 | /** @var LanguageFactory */ |
165 | private $langFactory; |
166 | /** @var LocalisationCache */ |
167 | private $localisationCache; |
168 | /** @var LanguageNameUtils */ |
169 | private $languageNameUtils; |
170 | /** @var LanguageFallback */ |
171 | private $languageFallback; |
172 | /** @var HookRunner */ |
173 | private $hookRunner; |
174 | /** @var ParserFactory */ |
175 | private $parserFactory; |
176 | |
177 | /** @var (string|callable)[]|null */ |
178 | private $messageKeyOverrides; |
179 | |
180 | /** |
181 | * Normalize message key input |
182 | * |
183 | * @param string $key Input message key to be normalized |
184 | * @return string Normalized message key |
185 | */ |
186 | public static function normalizeKey( $key ) { |
187 | $lckey = strtr( $key, ' ', '_' ); |
188 | if ( $lckey === '' ) { |
189 | // T300792 |
190 | return $lckey; |
191 | } |
192 | |
193 | if ( ord( $lckey ) < 128 ) { |
194 | $lckey[0] = strtolower( $lckey[0] ); |
195 | } else { |
196 | $lckey = MediaWikiServices::getInstance()->getContentLanguage()->lcfirst( $lckey ); |
197 | } |
198 | |
199 | return $lckey; |
200 | } |
201 | |
202 | /** |
203 | * @internal For use by ServiceWiring |
204 | * @param WANObjectCache $wanCache |
205 | * @param BagOStuff $clusterCache |
206 | * @param BagOStuff $serverCache |
207 | * @param Language $contLang Content language of site |
208 | * @param LanguageConverterFactory $langConverterFactory |
209 | * @param LoggerInterface $logger |
210 | * @param ServiceOptions $options |
211 | * @param LanguageFactory $langFactory |
212 | * @param LocalisationCache $localisationCache |
213 | * @param LanguageNameUtils $languageNameUtils |
214 | * @param LanguageFallback $languageFallback |
215 | * @param HookContainer $hookContainer |
216 | * @param ParserFactory $parserFactory |
217 | */ |
218 | public function __construct( |
219 | WANObjectCache $wanCache, |
220 | BagOStuff $clusterCache, |
221 | BagOStuff $serverCache, |
222 | Language $contLang, |
223 | LanguageConverterFactory $langConverterFactory, |
224 | LoggerInterface $logger, |
225 | ServiceOptions $options, |
226 | LanguageFactory $langFactory, |
227 | LocalisationCache $localisationCache, |
228 | LanguageNameUtils $languageNameUtils, |
229 | LanguageFallback $languageFallback, |
230 | HookContainer $hookContainer, |
231 | ParserFactory $parserFactory |
232 | ) { |
233 | $this->wanCache = $wanCache; |
234 | $this->clusterCache = $clusterCache; |
235 | $this->srvCache = $serverCache; |
236 | $this->contLang = $contLang; |
237 | $this->contLangConverter = $langConverterFactory->getLanguageConverter( $contLang ); |
238 | $this->contLangCode = $contLang->getCode(); |
239 | $this->logger = $logger; |
240 | $this->langFactory = $langFactory; |
241 | $this->localisationCache = $localisationCache; |
242 | $this->languageNameUtils = $languageNameUtils; |
243 | $this->languageFallback = $languageFallback; |
244 | $this->hookRunner = new HookRunner( $hookContainer ); |
245 | $this->parserFactory = $parserFactory; |
246 | |
247 | // limit size |
248 | $this->cache = new MapCacheLRU( self::MAX_REQUEST_LANGUAGES ); |
249 | |
250 | $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS ); |
251 | $this->disable = !$options->get( MainConfigNames::UseDatabaseMessages ); |
252 | $this->maxEntrySize = $options->get( MainConfigNames::MaxMsgCacheEntrySize ); |
253 | $this->adaptive = $options->get( MainConfigNames::AdaptiveMessageCache ); |
254 | $this->useXssLanguage = $options->get( MainConfigNames::UseXssLanguage ); |
255 | $this->rawHtmlMessages = $options->get( MainConfigNames::RawHtmlMessages ); |
256 | } |
257 | |
258 | public function setLogger( LoggerInterface $logger ) { |
259 | $this->logger = $logger; |
260 | } |
261 | |
262 | /** |
263 | * ParserOptions is lazily initialised. |
264 | * |
265 | * @return ParserOptions |
266 | */ |
267 | private function getParserOptions() { |
268 | if ( !$this->parserOptions ) { |
269 | $context = RequestContext::getMain(); |
270 | $user = $context->getUser(); |
271 | if ( !$user->isSafeToLoad() ) { |
272 | // It isn't safe to use the context user yet, so don't try to get a |
273 | // ParserOptions for it. And don't cache this ParserOptions |
274 | // either. |
275 | $po = ParserOptions::newFromAnon(); |
276 | $po->setAllowUnsafeRawHtml( false ); |
277 | return $po; |
278 | } |
279 | |
280 | $this->parserOptions = ParserOptions::newFromContext( $context ); |
281 | // Messages may take parameters that could come |
282 | // from malicious sources. As a precaution, disable |
283 | // the <html> parser tag when parsing messages. |
284 | $this->parserOptions->setAllowUnsafeRawHtml( false ); |
285 | } |
286 | |
287 | return $this->parserOptions; |
288 | } |
289 | |
290 | /** |
291 | * Try to load the cache from APC. |
292 | * |
293 | * @param string $code Optional language code, see documentation of load(). |
294 | * @return array|false The cache array, or false if not in cache. |
295 | */ |
296 | private function getLocalCache( $code ) { |
297 | $cacheKey = $this->srvCache->makeKey( __CLASS__, $code ); |
298 | |
299 | return $this->srvCache->get( $cacheKey ); |
300 | } |
301 | |
302 | /** |
303 | * Save the cache to APC. |
304 | * |
305 | * @param string $code |
306 | * @param array $cache The cache array |
307 | */ |
308 | private function saveToLocalCache( $code, $cache ) { |
309 | $cacheKey = $this->srvCache->makeKey( __CLASS__, $code ); |
310 | $this->srvCache->set( $cacheKey, $cache ); |
311 | } |
312 | |
313 | /** |
314 | * Loads messages from caches or from database in this order: |
315 | * (1) local message cache (if $wgUseLocalMessageCache is enabled) |
316 | * (2) memcached |
317 | * (3) from the database. |
318 | * |
319 | * When successfully loading from (2) or (3), all higher level caches are |
320 | * updated for the newest version. |
321 | * |
322 | * Nothing is loaded if member variable mDisable is true, either manually |
323 | * set by calling code or if message loading fails (is this possible?). |
324 | * |
325 | * Returns true if cache is already populated, or it was successfully populated, |
326 | * or false if populating empty cache fails. Also returns true if MessageCache |
327 | * is disabled. |
328 | * |
329 | * @param string $code Which language to load messages for |
330 | * @param int|null $mode Use MessageCache::FOR_UPDATE to skip process cache [optional] |
331 | * @return bool |
332 | */ |
333 | private function load( string $code, $mode = null ) { |
334 | // Don't do double loading... |
335 | if ( $this->isLanguageLoaded( $code ) && $mode !== self::FOR_UPDATE ) { |
336 | return true; |
337 | } |
338 | |
339 | // Show a log message (once) if loading is disabled |
340 | if ( $this->disable ) { |
341 | static $shownDisabled = false; |
342 | if ( !$shownDisabled ) { |
343 | $this->logger->debug( __METHOD__ . ': disabled' ); |
344 | $shownDisabled = true; |
345 | } |
346 | |
347 | return true; |
348 | } |
349 | |
350 | try { |
351 | return $this->loadUnguarded( $code, $mode ); |
352 | } catch ( Throwable $e ) { |
353 | // Don't try to load again during the exception handler |
354 | $this->disable = true; |
355 | throw $e; |
356 | } |
357 | } |
358 | |
359 | /** |
360 | * Load messages from the cache or database, without exception guarding. |
361 | * |
362 | * @param string $code Which language to load messages for |
363 | * @param int|null $mode Use MessageCache::FOR_UPDATE to skip process cache [optional] |
364 | * @return bool |
365 | */ |
366 | private function loadUnguarded( $code, $mode ) { |
367 | $success = false; // Keep track of success |
368 | $staleCache = false; // a cache array with expired data, or false if none has been loaded |
369 | $where = []; // Debug info, delayed to avoid spamming debug log too much |
370 | |
371 | // A hash of the expected content is stored in a WAN cache key, providing a way |
372 | // to invalid the local cache on every server whenever a message page changes. |
373 | [ $hash, $hashVolatile ] = $this->getValidationHash( $code ); |
374 | $this->cacheVolatile[$code] = $hashVolatile; |
375 | $volatilityOnlyStaleness = false; |
376 | |
377 | // Try the local cache and check against the cluster hash key... |
378 | $cache = $this->getLocalCache( $code ); |
379 | if ( !$cache ) { |
380 | $where[] = 'local cache is empty'; |
381 | } elseif ( !isset( $cache['HASH'] ) || $cache['HASH'] !== $hash ) { |
382 | $where[] = 'local cache has the wrong hash'; |
383 | $staleCache = $cache; |
384 | } elseif ( $this->isCacheExpired( $cache ) ) { |
385 | $where[] = 'local cache is expired'; |
386 | $staleCache = $cache; |
387 | } elseif ( $hashVolatile ) { |
388 | // Some recent message page changes might not show due to DB lag |
389 | $where[] = 'local cache validation key is expired/volatile'; |
390 | $staleCache = $cache; |
391 | $volatilityOnlyStaleness = true; |
392 | } else { |
393 | $where[] = 'got from local cache'; |
394 | $this->cache->set( $code, $cache ); |
395 | $success = true; |
396 | } |
397 | |
398 | if ( !$success ) { |
399 | // Try the cluster cache, using a lock for regeneration... |
400 | $cacheKey = $this->clusterCache->makeKey( 'messages', $code ); |
401 | for ( $failedAttempts = 0; $failedAttempts <= 1; $failedAttempts++ ) { |
402 | if ( $volatilityOnlyStaleness && $staleCache ) { |
403 | // While the cluster cache *might* be more up-to-date, we do not want |
404 | // the I/O strain of every application server fetching the key here during |
405 | // the volatility period. Either this thread wins the lock and regenerates |
406 | // the cache or the stale local cache value gets reused. |
407 | $where[] = 'global cache is presumed expired'; |
408 | } else { |
409 | $cache = $this->clusterCache->get( $cacheKey ); |
410 | if ( !$cache ) { |
411 | $where[] = 'global cache is empty'; |
412 | } elseif ( $this->isCacheExpired( $cache ) ) { |
413 | $where[] = 'global cache is expired'; |
414 | $staleCache = $cache; |
415 | } elseif ( $hashVolatile ) { |
416 | // Some recent message page changes might not show due to DB lag |
417 | $where[] = 'global cache is expired/volatile'; |
418 | $staleCache = $cache; |
419 | } else { |
420 | $where[] = 'got from global cache'; |
421 | $this->cache->set( $code, $cache ); |
422 | $this->saveToCaches( $cache, 'local-only', $code ); |
423 | $success = true; |
424 | break; |
425 | } |
426 | } |
427 | |
428 | // We need to call loadFromDB(). Limit the concurrency to one thread. |
429 | // This prevents the site from going down when the cache expires. |
430 | // Note that the DB slam protection lock here is non-blocking. |
431 | $loadStatus = $this->loadFromDBWithMainLock( $code, $where, $mode ); |
432 | if ( $loadStatus === true ) { |
433 | $success = true; |
434 | break; |
435 | } elseif ( $staleCache ) { |
436 | // Use the stale cache while some other thread constructs the new one |
437 | $where[] = 'using stale cache'; |
438 | $this->cache->set( $code, $staleCache ); |
439 | $success = true; |
440 | break; |
441 | } elseif ( $failedAttempts > 0 ) { |
442 | $where[] = 'failed to find cache after waiting'; |
443 | // Already blocked once, so avoid another lock/unlock cycle. |
444 | // This case will typically be hit if memcached is down, or if |
445 | // loadFromDB() takes longer than LOCK_WAIT. |
446 | break; |
447 | } elseif ( $loadStatus === 'cantacquire' ) { |
448 | // Wait for the other thread to finish, then retry. Normally, |
449 | // the memcached get() will then yield the other thread's result. |
450 | $where[] = 'waiting for other thread to complete'; |
451 | [ , $ioError ] = $this->getReentrantScopedLock( $code ); |
452 | if ( $ioError ) { |
453 | $where[] = 'failed waiting'; |
454 | // Call loadFromDB() with concurrency limited to one thread per server. |
455 | // It should be rare for all servers to lack even a stale local cache. |
456 | $success = $this->loadFromDBWithLocalLock( $code, $where, $mode ); |
457 | break; |
458 | } |
459 | } else { |
460 | // Disable cache; $loadStatus is 'disabled' |
461 | break; |
462 | } |
463 | } |
464 | } |
465 | |
466 | if ( !$success ) { |
467 | $where[] = 'loading FAILED - cache is disabled'; |
468 | $this->disable = true; |
469 | $this->cache->set( $code, [] ); |
470 | $this->logger->error( __METHOD__ . ": Failed to load $code" ); |
471 | // This used to throw an exception, but that led to nasty side effects like |
472 | // the whole wiki being instantly down if the memcached server died |
473 | } |
474 | |
475 | if ( !$this->isLanguageLoaded( $code ) ) { |
476 | throw new LogicException( "Process cache for '$code' should be set by now." ); |
477 | } |
478 | |
479 | $info = implode( ', ', $where ); |
480 | $this->logger->debug( __METHOD__ . ": Loading $code... $info" ); |
481 | |
482 | return $success; |
483 | } |
484 | |
485 | /** |
486 | * @param string $code |
487 | * @param string[] &$where List of debug comments |
488 | * @param int|null $mode Use MessageCache::FOR_UPDATE to use DB_PRIMARY |
489 | * @return true|string One (true, "cantacquire", "disabled") |
490 | */ |
491 | private function loadFromDBWithMainLock( $code, array &$where, $mode = null ) { |
492 | // If cache updates on all levels fail, give up on message overrides. |
493 | // This is to avoid easy site outages; see $saveSuccess comments below. |
494 | $statusKey = $this->clusterCache->makeKey( 'messages', $code, 'status' ); |
495 | $status = $this->clusterCache->get( $statusKey ); |
496 | if ( $status === 'error' ) { |
497 | $where[] = "could not load; method is still globally disabled"; |
498 | return 'disabled'; |
499 | } |
500 | |
501 | // Now let's regenerate |
502 | $where[] = 'loading from DB'; |
503 | |
504 | // Lock the cache to prevent conflicting writes. |
505 | // This lock is non-blocking so stale cache can quickly be used. |
506 | // Note that load() will call a blocking getReentrantScopedLock() |
507 | // after this if it really needs to wait for any current thread. |
508 | [ $scopedLock ] = $this->getReentrantScopedLock( $code, 0 ); |
509 | if ( !$scopedLock ) { |
510 | $where[] = 'could not acquire main lock'; |
511 | return 'cantacquire'; |
512 | } |
513 | |
514 | $cache = $this->loadFromDB( $code, $mode ); |
515 | $this->cache->set( $code, $cache ); |
516 | $saveSuccess = $this->saveToCaches( $cache, 'all', $code ); |
517 | |
518 | if ( !$saveSuccess ) { |
519 | /** |
520 | * Cache save has failed. |
521 | * |
522 | * There are two main scenarios where this could be a problem: |
523 | * - The cache is more than the maximum size (typically 1MB compressed). |
524 | * - Memcached has no space remaining in the relevant slab class. This is |
525 | * unlikely with recent versions of memcached. |
526 | * |
527 | * Either way, if there is a local cache, nothing bad will happen. If there |
528 | * is no local cache, disabling the message cache for all requests avoids |
529 | * incurring a loadFromDB() overhead on every request, and thus saves the |
530 | * wiki from complete downtime under moderate traffic conditions. |
531 | */ |
532 | if ( $this->srvCache instanceof EmptyBagOStuff ) { |
533 | $this->clusterCache->set( $statusKey, 'error', 60 * 5 ); |
534 | $where[] = 'could not save cache, disabled globally for 5 minutes'; |
535 | } else { |
536 | $where[] = "could not save global cache"; |
537 | } |
538 | } |
539 | |
540 | return true; |
541 | } |
542 | |
543 | /** |
544 | * @param string $code |
545 | * @param string[] &$where List of debug comments |
546 | * @param int|null $mode Use MessageCache::FOR_UPDATE to use DB_PRIMARY |
547 | * @return bool Success |
548 | */ |
549 | private function loadFromDBWithLocalLock( $code, array &$where, $mode = null ) { |
550 | $success = false; |
551 | $where[] = 'loading from DB using local lock'; |
552 | |
553 | $scopedLock = $this->srvCache->getScopedLock( |
554 | $this->srvCache->makeKey( 'messages', $code ), |
555 | self::WAIT_SEC, |
556 | self::LOCK_TTL, |
557 | __METHOD__ |
558 | ); |
559 | if ( $scopedLock ) { |
560 | $cache = $this->loadFromDB( $code, $mode ); |
561 | $this->cache->set( $code, $cache ); |
562 | $this->saveToCaches( $cache, 'local-only', $code ); |
563 | $success = true; |
564 | } |
565 | |
566 | return $success; |
567 | } |
568 | |
569 | /** |
570 | * Loads cacheable messages from the database. Messages bigger than |
571 | * $wgMaxMsgCacheEntrySize are assigned a special value, and are loaded |
572 | * on-demand from the database later. |
573 | * |
574 | * @param string $code Language code |
575 | * @param int|null $mode Use MessageCache::FOR_UPDATE to skip process cache |
576 | * @return array Loaded messages for storing in caches |
577 | */ |
578 | private function loadFromDB( $code, $mode = null ) { |
579 | $icp = MediaWikiServices::getInstance()->getConnectionProvider(); |
580 | |
581 | $dbr = ( $mode === self::FOR_UPDATE ) ? $icp->getPrimaryDatabase() : $icp->getReplicaDatabase(); |
582 | |
583 | $cache = []; |
584 | |
585 | $mostused = []; // list of "<cased message key>/<code>" |
586 | if ( $this->adaptive && $code !== $this->contLangCode ) { |
587 | if ( !$this->cache->has( $this->contLangCode ) ) { |
588 | $this->load( $this->contLangCode ); |
589 | } |
590 | $mostused = array_keys( $this->cache->get( $this->contLangCode ) ); |
591 | foreach ( $mostused as $key => $value ) { |
592 | $mostused[$key] = "$value/$code"; |
593 | } |
594 | } |
595 | |
596 | // Common conditions |
597 | $conds = [ |
598 | 'page_is_redirect' => 0, |
599 | 'page_namespace' => NS_MEDIAWIKI, |
600 | ]; |
601 | if ( count( $mostused ) ) { |
602 | $conds['page_title'] = $mostused; |
603 | } elseif ( $code !== $this->contLangCode ) { |
604 | $conds[] = $dbr->expr( |
605 | 'page_title', |
606 | IExpression::LIKE, |
607 | new LikeValue( $dbr->anyString(), '/', $code ) |
608 | ); |
609 | } else { |
610 | // Effectively disallows use of '/' character in NS_MEDIAWIKI for uses |
611 | // other than language code. |
612 | $conds[] = $dbr->expr( |
613 | 'page_title', |
614 | IExpression::NOT_LIKE, |
615 | new LikeValue( $dbr->anyString(), '/', $dbr->anyString() ) |
616 | ); |
617 | } |
618 | |
619 | // Set the stubs for oversized software-defined messages in the main cache map |
620 | $res = $dbr->newSelectQueryBuilder() |
621 | ->select( [ 'page_title', 'page_latest' ] ) |
622 | ->from( 'page' ) |
623 | ->where( $conds ) |
624 | ->andWhere( $dbr->expr( 'page_len', '>', intval( $this->maxEntrySize ) ) ) |
625 | ->caller( __METHOD__ . "($code)-big" )->fetchResultSet(); |
626 | foreach ( $res as $row ) { |
627 | // Include entries/stubs for all keys in $mostused in adaptive mode |
628 | if ( $this->adaptive || $this->isMainCacheable( $row->page_title ) ) { |
629 | $cache[$row->page_title] = '!TOO BIG'; |
630 | } |
631 | // At least include revision ID so page changes are reflected in the hash |
632 | $cache['EXCESSIVE'][$row->page_title] = $row->page_latest; |
633 | } |
634 | |
635 | // RevisionStore cannot be injected as it would break the installer since |
636 | // it instantiates MessageCache before the DB. |
637 | $revisionStore = MediaWikiServices::getInstance()->getRevisionStore(); |
638 | // Set the text for small software-defined messages in the main cache map |
639 | $revQuery = $revisionStore->getQueryInfo( [ 'page' ] ); |
640 | |
641 | // T231196: MySQL/MariaDB (10.1.37) can sometimes irrationally decide that querying `actor` then |
642 | // `revision` then `page` is somehow better than starting with `page`. Tell it not to reorder the |
643 | // query (and also reorder it ourselves because as generated by RevisionStore it'll have |
644 | // `revision` first rather than `page`). |
645 | $revQuery['joins']['revision'] = $revQuery['joins']['page']; |
646 | unset( $revQuery['joins']['page'] ); |
647 | // It isn't actually necessary to reorder $revQuery['tables'] as Database does the right thing |
648 | // when join conditions are given for all joins, but Gergő is wary of relying on that so pull |
649 | // `page` to the start. |
650 | $revQuery['tables'] = array_merge( |
651 | [ 'page' ], |
652 | array_diff( $revQuery['tables'], [ 'page' ] ) |
653 | ); |
654 | |
655 | $res = $dbr->newSelectQueryBuilder() |
656 | ->queryInfo( $revQuery ) |
657 | ->where( $conds ) |
658 | ->andWhere( [ |
659 | $dbr->expr( 'page_len', '<=', intval( $this->maxEntrySize ) ), |
660 | 'page_latest = rev_id' // get the latest revision only |
661 | ] ) |
662 | ->caller( __METHOD__ . "($code)-small" ) |
663 | ->straightJoinOption() |
664 | ->fetchResultSet(); |
665 | |
666 | // Don't load content from uncacheable rows (T313004) |
667 | [ $cacheableRows, $uncacheableRows ] = $this->separateCacheableRows( $res ); |
668 | $result = $revisionStore->newRevisionsFromBatch( $cacheableRows, [ |
669 | 'slots' => [ SlotRecord::MAIN ], |
670 | 'content' => true |
671 | ] ); |
672 | $revisions = $result->isOK() ? $result->getValue() : []; |
673 | |
674 | foreach ( $cacheableRows as $row ) { |
675 | try { |
676 | $rev = $revisions[$row->rev_id] ?? null; |
677 | $content = $rev ? $rev->getContent( SlotRecord::MAIN ) : null; |
678 | $text = $this->getMessageTextFromContent( $content ); |
679 | } catch ( TimeoutException $e ) { |
680 | throw $e; |
681 | } catch ( Exception $ex ) { |
682 | $text = false; |
683 | } |
684 | |
685 | if ( !is_string( $text ) ) { |
686 | $entry = '!ERROR'; |
687 | $this->logger->error( |
688 | __METHOD__ |
689 | . ": failed to load message page text for {$row->page_title} ($code)" |
690 | ); |
691 | } else { |
692 | $entry = ' ' . $text; |
693 | } |
694 | $cache[$row->page_title] = $entry; |
695 | } |
696 | |
697 | foreach ( $uncacheableRows as $row ) { |
698 | // T193271: The cache object gets too big and slow to generate. |
699 | // At least include revision ID, so that page changes are reflected in the hash. |
700 | $cache['EXCESSIVE'][$row->page_title] = $row->page_latest; |
701 | } |
702 | |
703 | $cache['VERSION'] = MSG_CACHE_VERSION; |
704 | ksort( $cache ); |
705 | |
706 | // Hash for validating local cache (APC). No need to take into account |
707 | // messages larger than $wgMaxMsgCacheEntrySize, since those are only |
708 | // stored and fetched from memcache. |
709 | $cache['HASH'] = md5( serialize( $cache ) ); |
710 | $cache['EXPIRY'] = wfTimestamp( TS_MW, time() + self::WAN_TTL ); |
711 | unset( $cache['EXCESSIVE'] ); // only needed for hash |
712 | |
713 | return $cache; |
714 | } |
715 | |
716 | /** |
717 | * Whether the language was loaded and its data is still in the process cache. |
718 | * |
719 | * @param string $lang |
720 | * @return bool |
721 | */ |
722 | private function isLanguageLoaded( $lang ) { |
723 | // It is important that this only returns true if the cache was fully |
724 | // populated by load(), so that callers can assume all cache keys exist. |
725 | // It is possible for $this->cache to be only partially populated through |
726 | // methods like MessageCache::replace(), which must not make this method |
727 | // return true (T208897). And this method must cease to return true |
728 | // if the language was evicted by MapCacheLRU (T230690). |
729 | return $this->cache->hasField( $lang, 'VERSION' ); |
730 | } |
731 | |
732 | /** |
733 | * Can the given DB key be added to the main cache blob? To reduce the |
734 | * abuse impact of the MediaWiki namespace by {{int:}} and CentralNotice, |
735 | * this is only true if the page overrides a predefined message. |
736 | * |
737 | * @param string $name Message name (possibly with /code suffix) |
738 | * @param string|null $code The language code. If this is null, message |
739 | * presence will be bulk loaded for the content language. Otherwise, |
740 | * presence will be detected by loading the specified message. |
741 | * @return bool |
742 | */ |
743 | private function isMainCacheable( $name, $code = null ) { |
744 | // Convert the first letter to lowercase, and strip /code suffix |
745 | $name = $this->contLang->lcfirst( $name ); |
746 | // Include common conversion table pages. This also avoids problems with |
747 | // Installer::parse() bailing out due to disallowed DB queries (T207979). |
748 | if ( strpos( $name, 'conversiontable/' ) === 0 ) { |
749 | return true; |
750 | } |
751 | $msg = preg_replace( '/\/[a-z0-9-]{2,}$/', '', $name ); |
752 | |
753 | if ( $code === null ) { |
754 | // Bulk load |
755 | if ( $this->systemMessageNames === null ) { |
756 | $this->systemMessageNames = array_fill_keys( |
757 | $this->localisationCache->getSubitemList( $this->contLangCode, 'messages' ), |
758 | true ); |
759 | } |
760 | return isset( $this->systemMessageNames[$msg] ); |
761 | } else { |
762 | // Use individual subitem |
763 | return $this->localisationCache->getSubitem( $code, 'messages', $msg ) !== null; |
764 | } |
765 | } |
766 | |
767 | /** |
768 | * Separate cacheable from uncacheable rows in a page/revsion query result. |
769 | * |
770 | * @param IResultWrapper $res |
771 | * @return array{0:IResultWrapper|stdClass[],1:stdClass[]} An array with the cacheable |
772 | * rows in the first element and the uncacheable rows in the second. |
773 | */ |
774 | private function separateCacheableRows( $res ) { |
775 | if ( $this->adaptive ) { |
776 | // Include entries/stubs for all keys in $mostused in adaptive mode |
777 | return [ $res, [] ]; |
778 | } |
779 | $cacheableRows = []; |
780 | $uncacheableRows = []; |
781 | foreach ( $res as $row ) { |
782 | if ( $this->isMainCacheable( $row->page_title ) ) { |
783 | $cacheableRows[] = $row; |
784 | } else { |
785 | $uncacheableRows[] = $row; |
786 | } |
787 | } |
788 | return [ $cacheableRows, $uncacheableRows ]; |
789 | } |
790 | |
791 | /** |
792 | * Updates cache as necessary when message page is changed |
793 | * |
794 | * @param string $title Message cache key with the initial uppercase letter |
795 | * @param string|false $text New contents of the page (false if deleted) |
796 | */ |
797 | public function replace( $title, $text ) { |
798 | if ( $this->disable ) { |
799 | return; |
800 | } |
801 | |
802 | [ $msg, $code ] = $this->figureMessage( $title ); |
803 | if ( strpos( $title, '/' ) !== false && $code === $this->contLangCode ) { |
804 | // Content language overrides do not use the /<code> suffix |
805 | return; |
806 | } |
807 | |
808 | // (a) Update the process cache with the new message text |
809 | if ( $text === false ) { |
810 | // Page deleted |
811 | $this->cache->setField( $code, $title, '!NONEXISTENT' ); |
812 | } else { |
813 | // Ignore $wgMaxMsgCacheEntrySize so the process cache is up-to-date |
814 | $this->cache->setField( $code, $title, ' ' . $text ); |
815 | } |
816 | |
817 | // (b) Update the shared caches in a deferred update with a fresh DB snapshot |
818 | DeferredUpdates::addUpdate( |
819 | new MessageCacheUpdate( $code, $title, $msg ), |
820 | DeferredUpdates::PRESEND |
821 | ); |
822 | } |
823 | |
824 | /** |
825 | * @param string $code |
826 | * @param array[] $replacements List of (title, message key) pairs |
827 | */ |
828 | public function refreshAndReplaceInternal( string $code, array $replacements ) { |
829 | // Allow one caller at a time to avoid race conditions |
830 | [ $scopedLock ] = $this->getReentrantScopedLock( $code ); |
831 | if ( !$scopedLock ) { |
832 | foreach ( $replacements as [ $title ] ) { |
833 | $this->logger->error( |
834 | __METHOD__ . ': could not acquire lock to update {title} ({code})', |
835 | [ 'title' => $title, 'code' => $code ] ); |
836 | } |
837 | |
838 | return; |
839 | } |
840 | |
841 | // Load the existing cache to update it in the local DC cache. |
842 | // The other DCs will see a hash mismatch. |
843 | if ( $this->load( $code, self::FOR_UPDATE ) ) { |
844 | $cache = $this->cache->get( $code ); |
845 | } else { |
846 | // Err? Fall back to loading from the database. |
847 | $cache = $this->loadFromDB( $code, self::FOR_UPDATE ); |
848 | } |
849 | // Check if individual cache keys should exist and update cache accordingly |
850 | $newTextByTitle = []; // map of (title => content) |
851 | $newBigTitles = []; // map of (title => latest revision ID), like EXCESSIVE in loadFromDB() |
852 | // Can not inject the WikiPageFactory as it would break the installer since |
853 | // it instantiates MessageCache before the DB. |
854 | $wikiPageFactory = MediaWikiServices::getInstance()->getWikiPageFactory(); |
855 | foreach ( $replacements as [ $title ] ) { |
856 | $page = $wikiPageFactory->newFromTitle( Title::makeTitle( NS_MEDIAWIKI, $title ) ); |
857 | $page->loadPageData( IDBAccessObject::READ_LATEST ); |
858 | $text = $this->getMessageTextFromContent( $page->getContent() ); |
859 | // Remember the text for the blob store update later on |
860 | $newTextByTitle[$title] = $text ?? ''; |
861 | // Note that if $text is false, then $cache should have a !NONEXISTANT entry |
862 | if ( !is_string( $text ) ) { |
863 | $cache[$title] = '!NONEXISTENT'; |
864 | } elseif ( strlen( $text ) > $this->maxEntrySize ) { |
865 | $cache[$title] = '!TOO BIG'; |
866 | $newBigTitles[$title] = $page->getLatest(); |
867 | } else { |
868 | $cache[$title] = ' ' . $text; |
869 | } |
870 | } |
871 | // Update HASH for the new key. Incorporates various administrative keys, |
872 | // including the old HASH (and thereby the EXCESSIVE value from loadFromDB() |
873 | // and previous replace() calls), but that doesn't really matter since we |
874 | // only ever compare it for equality with a copy saved by saveToCaches(). |
875 | $cache['HASH'] = md5( serialize( $cache + [ 'EXCESSIVE' => $newBigTitles ] ) ); |
876 | // Update the too-big WAN cache entries now that we have the new HASH |
877 | foreach ( $newBigTitles as $title => $id ) { |
878 | // Match logic of loadCachedMessagePageEntry() |
879 | $this->wanCache->set( |
880 | $this->bigMessageCacheKey( $cache['HASH'], $title ), |
881 | ' ' . $newTextByTitle[$title], |
882 | self::WAN_TTL |
883 | ); |
884 | } |
885 | // Mark this cache as definitely being "latest" (non-volatile) so |
886 | // load() calls do not try to refresh the cache with replica DB data |
887 | $cache['LATEST'] = time(); |
888 | // Update the process cache |
889 | $this->cache->set( $code, $cache ); |
890 | // Pre-emptively update the local datacenter cache so things like edit filter and |
891 | // prevented changes are reflected immediately; these often use MediaWiki: pages. |
892 | // The datacenter handling replace() calls should be the same one handling edits |
893 | // as they require HTTP POST. |
894 | $this->saveToCaches( $cache, 'all', $code ); |
895 | // Release the lock now that the cache is saved |
896 | ScopedCallback::consume( $scopedLock ); |
897 | |
898 | // Relay the purge. Touching this check key expires cache contents |
899 | // and local cache (APC) validation hash across all datacenters. |
900 | $this->wanCache->touchCheckKey( $this->getCheckKey( $code ) ); |
901 | |
902 | // Purge the messages in the message blob store and fire any hook handlers |
903 | $blobStore = MediaWikiServices::getInstance()->getResourceLoader()->getMessageBlobStore(); |
904 | foreach ( $replacements as [ $title, $msg ] ) { |
905 | $blobStore->updateMessage( $this->contLang->lcfirst( $msg ) ); |
906 | $this->hookRunner->onMessageCacheReplace( $title, $newTextByTitle[$title] ); |
907 | } |
908 | } |
909 | |
910 | /** |
911 | * Is the given cache array expired due-to-time passing or a version change? |
912 | * |
913 | * @param array $cache |
914 | * @return bool |
915 | */ |
916 | private function isCacheExpired( $cache ) { |
917 | return !isset( $cache['VERSION'] ) || |
918 | !isset( $cache['EXPIRY'] ) || |
919 | $cache['VERSION'] !== MSG_CACHE_VERSION || |
920 | $cache['EXPIRY'] <= wfTimestampNow(); |
921 | } |
922 | |
923 | /** |
924 | * Shortcut to update caches. |
925 | * |
926 | * @param array $cache Cached messages with a version. |
927 | * @param string $dest Either "local-only" to save to local caches only |
928 | * or "all" to save to all caches. |
929 | * @param string|false $code Language code (default: false) |
930 | * @return bool |
931 | */ |
932 | private function saveToCaches( array $cache, $dest, $code = false ) { |
933 | if ( $dest === 'all' ) { |
934 | $cacheKey = $this->clusterCache->makeKey( 'messages', $code ); |
935 | $success = $this->clusterCache->set( $cacheKey, $cache ); |
936 | $this->setValidationHash( $code, $cache ); |
937 | } else { |
938 | $success = true; |
939 | } |
940 | |
941 | $this->saveToLocalCache( $code, $cache ); |
942 | |
943 | return $success; |
944 | } |
945 | |
946 | /** |
947 | * Get the md5 used to validate the local server cache |
948 | * |
949 | * @param string $code |
950 | * @return array (hash or false, bool expiry/volatility status) |
951 | */ |
952 | private function getValidationHash( $code ) { |
953 | $curTTL = null; |
954 | $value = $this->wanCache->get( |
955 | $this->wanCache->makeKey( 'messages', $code, 'hash', 'v1' ), |
956 | $curTTL, |
957 | [ $this->getCheckKey( $code ) ] |
958 | ); |
959 | |
960 | if ( $value ) { |
961 | $hash = $value['hash']; |
962 | if ( ( time() - $value['latest'] ) < WANObjectCache::TTL_MINUTE ) { |
963 | // Cache was recently updated via replace() and should be up-to-date. |
964 | // That method is only called in the primary datacenter and uses FOR_UPDATE. |
965 | $expired = false; |
966 | } else { |
967 | // See if the "check" key was bumped after the hash was generated |
968 | $expired = ( $curTTL < 0 ); |
969 | } |
970 | } else { |
971 | // No hash found at all; cache must regenerate to be safe |
972 | $hash = false; |
973 | $expired = true; |
974 | } |
975 | |
976 | return [ $hash, $expired ]; |
977 | } |
978 | |
979 | /** |
980 | * Set the md5 used to validate the local server cache |
981 | * |
982 | * If $cache has a 'LATEST' UNIX timestamp key, then the hash will not |
983 | * be treated as "volatile" by getValidationHash() for the next few seconds. |
984 | * This is triggered when $cache is generated using FOR_UPDATE mode. |
985 | * |
986 | * @param string $code |
987 | * @param array $cache Cached messages with a version |
988 | */ |
989 | private function setValidationHash( $code, array $cache ) { |
990 | $this->wanCache->set( |
991 | $this->wanCache->makeKey( 'messages', $code, 'hash', 'v1' ), |
992 | [ |
993 | 'hash' => $cache['HASH'], |
994 | 'latest' => $cache['LATEST'] ?? 0 |
995 | ], |
996 | WANObjectCache::TTL_INDEFINITE |
997 | ); |
998 | } |
999 | |
1000 | /** |
1001 | * @param string $code Which language to load messages for |
1002 | * @param int $timeout Wait timeout in seconds |
1003 | * @return array (ScopedCallback or null, whether locking failed due to an I/O error) |
1004 | * @phan-return array{0:ScopedCallback|null,1:bool} |
1005 | */ |
1006 | private function getReentrantScopedLock( $code, $timeout = self::WAIT_SEC ) { |
1007 | $key = $this->clusterCache->makeKey( 'messages', $code ); |
1008 | |
1009 | $watchPoint = $this->clusterCache->watchErrors(); |
1010 | $scopedLock = $this->clusterCache->getScopedLock( |
1011 | $key, |
1012 | $timeout, |
1013 | self::LOCK_TTL, |
1014 | __METHOD__ |
1015 | ); |
1016 | $error = ( !$scopedLock && $this->clusterCache->getLastError( $watchPoint ) ); |
1017 | |
1018 | return [ $scopedLock, $error ]; |
1019 | } |
1020 | |
1021 | /** |
1022 | * Get a message from either the content language or the user language. |
1023 | * |
1024 | * First, assemble a list of languages to attempt getting the message from. This |
1025 | * chain begins with the requested language and its fallbacks and then continues with |
1026 | * the content language and its fallbacks. For each language in the chain, the following |
1027 | * process will occur (in this order): |
1028 | * 1. If a language-specific override, i.e., [[MW:msg/lang]], is available, use that. |
1029 | * Note: for the content language, there is no /lang subpage. |
1030 | * 2. Fetch from the static CDB cache. |
1031 | * 3. If available, check the database for fallback language overrides. |
1032 | * |
1033 | * This process provides a number of guarantees. When changing this code, make sure all |
1034 | * of these guarantees are preserved. |
1035 | * * If the requested language is *not* the content language, then the CDB cache for that |
1036 | * specific language will take precedence over the root database page ([[MW:msg]]). |
1037 | * * Fallbacks will be just that: fallbacks. A fallback language will never be reached if |
1038 | * the message is available *anywhere* in the language for which it is a fallback. |
1039 | * |
1040 | * @param string $key The message key |
1041 | * @param bool $useDB If true, look for the message in the DB, false |
1042 | * to use only the compiled l10n cache. |
1043 | * @param bool|string|Language|null $language Code of the language to get the message for. |
1044 | * - If string and a valid code, will create a standard language object |
1045 | * - If string but not a valid code, will create a basic language object |
1046 | * - If false, create object from the current users language |
1047 | * - If true or null, create object from the wikis content language |
1048 | * - If language object, use it as given |
1049 | * - If this parameter omitted the object from the wikis content language is used |
1050 | * - Other values than a Language object or null are deprecated. |
1051 | * @param string &$usedKey @phan-output-reference If given, will be set to the message key |
1052 | * that the message was fetched from (the requested key may be overridden by hooks). |
1053 | * |
1054 | * @return string|false False if the message doesn't exist, otherwise the |
1055 | * message (which can be empty) |
1056 | */ |
1057 | public function get( $key, $useDB = true, $language = null, &$usedKey = '' ) { |
1058 | if ( is_int( $key ) ) { |
1059 | // Fix numerical strings that somehow become ints on their way here |
1060 | $key = (string)$key; |
1061 | } elseif ( !is_string( $key ) ) { |
1062 | throw new TypeError( 'Message key must be a string' ); |
1063 | } elseif ( $key === '' ) { |
1064 | // Shortcut: the empty key is always missing |
1065 | return false; |
1066 | } |
1067 | |
1068 | $language ??= $this->contLang; |
1069 | $language = $this->getLanguageObject( $language ); |
1070 | |
1071 | // Normalise title-case input (with some inlining) |
1072 | $lckey = self::normalizeKey( $key ); |
1073 | |
1074 | // Initialize the overrides here to prevent calling the hook too early. |
1075 | if ( $this->messageKeyOverrides === null ) { |
1076 | $this->messageKeyOverrides = []; |
1077 | $this->hookRunner->onMessageCacheFetchOverrides( $this->messageKeyOverrides ); |
1078 | } |
1079 | |
1080 | if ( isset( $this->messageKeyOverrides[$lckey] ) ) { |
1081 | $override = $this->messageKeyOverrides[$lckey]; |
1082 | |
1083 | // Strings are deliberately interpreted as message keys, |
1084 | // to prevent ambiguity between message keys and functions. |
1085 | if ( is_string( $override ) ) { |
1086 | $lckey = $override; |
1087 | } else { |
1088 | $lckey = $override( $lckey, $this, $language, $useDB ); |
1089 | } |
1090 | } |
1091 | |
1092 | $this->hookRunner->onMessageCache__get( $lckey ); |
1093 | |
1094 | $usedKey = $lckey; |
1095 | |
1096 | // Loop through each language in the fallback list until we find something useful |
1097 | $message = $this->getMessageFromFallbackChain( |
1098 | $language, |
1099 | $lckey, |
1100 | !$this->disable && $useDB |
1101 | ); |
1102 | |
1103 | // If we still have no message, maybe the key was in fact a full key so try that |
1104 | if ( $message === false ) { |
1105 | $parts = explode( '/', $lckey ); |
1106 | // We may get calls for things that are http-urls from sidebar |
1107 | // Let's not load nonexistent languages for those |
1108 | // They usually have more than one slash. |
1109 | if ( count( $parts ) === 2 && $parts[1] !== '' ) { |
1110 | $message = $this->localisationCache->getSubitem( $parts[1], 'messages', $parts[0] ) ?? false; |
1111 | } |
1112 | } |
1113 | |
1114 | // Post-processing if the message exists |
1115 | if ( $message !== false ) { |
1116 | // Fix whitespace |
1117 | $message = str_replace( |
1118 | [ |
1119 | // Fix for trailing whitespace, removed by textarea |
1120 | ' ', |
1121 | // Fix for NBSP, converted to space by firefox |
1122 | ' ', |
1123 | ' ', |
1124 | '­' |
1125 | ], |
1126 | [ |
1127 | ' ', |
1128 | "\u{00A0}", |
1129 | "\u{00A0}", |
1130 | "\u{00AD}" |
1131 | ], |
1132 | $message |
1133 | ); |
1134 | } |
1135 | |
1136 | return $message; |
1137 | } |
1138 | |
1139 | /** |
1140 | * Return a Language object from $langcode |
1141 | * |
1142 | * @param Language|string|bool $langcode Either: |
1143 | * - a Language object |
1144 | * - code of the language to get the message for, if it is |
1145 | * a valid code create a language for that language, if |
1146 | * it is a string but not a valid code then make a basic |
1147 | * language object |
1148 | * - a boolean: if it's false then use the global object for |
1149 | * the current user's language (as a fallback for the old parameter |
1150 | * functionality), or if it is true then use global object |
1151 | * for the wiki's content language. |
1152 | * @return Language|StubUserLang |
1153 | */ |
1154 | private function getLanguageObject( $langcode ) { |
1155 | # Identify which language to get or create a language object for. |
1156 | # Using is_object here due to Stub objects. |
1157 | if ( is_object( $langcode ) ) { |
1158 | # Great, we already have the object (hopefully)! |
1159 | return $langcode; |
1160 | } |
1161 | |
1162 | wfDeprecated( __METHOD__ . ' with not a Language object in $langcode', '1.43' ); |
1163 | if ( $langcode === true || $langcode === $this->contLangCode ) { |
1164 | # $langcode is the language code of the wikis content language object. |
1165 | # or it is a boolean and value is true |
1166 | return $this->contLang; |
1167 | } |
1168 | |
1169 | global $wgLang; |
1170 | if ( $langcode === false || $langcode === $wgLang->getCode() ) { |
1171 | # $langcode is the language code of user language object. |
1172 | # or it was a boolean and value is false |
1173 | return $wgLang; |
1174 | } |
1175 | |
1176 | $validCodes = array_keys( $this->languageNameUtils->getLanguageNames() ); |
1177 | if ( in_array( $langcode, $validCodes ) ) { |
1178 | # $langcode corresponds to a valid language. |
1179 | return $this->langFactory->getLanguage( $langcode ); |
1180 | } |
1181 | |
1182 | # $langcode is a string, but not a valid language code; use content language. |
1183 | $this->logger->debug( 'Invalid language code passed to' . __METHOD__ . ', falling back to content language.' ); |
1184 | return $this->contLang; |
1185 | } |
1186 | |
1187 | /** |
1188 | * Given a language, try and fetch messages from that language. |
1189 | * |
1190 | * Will also consider fallbacks of that language, the site language, and fallbacks for |
1191 | * the site language. |
1192 | * |
1193 | * @see MessageCache::get |
1194 | * @param Language|StubObject $lang Preferred language |
1195 | * @param string $lckey Lowercase key for the message (as for localisation cache) |
1196 | * @param bool $useDB Whether to include messages from the wiki database |
1197 | * @return string|false The message, or false if not found |
1198 | */ |
1199 | private function getMessageFromFallbackChain( $lang, $lckey, $useDB ) { |
1200 | $alreadyTried = []; |
1201 | |
1202 | // First try the requested language. |
1203 | $message = $this->getMessageForLang( $lang, $lckey, $useDB, $alreadyTried ); |
1204 | if ( $message !== false ) { |
1205 | return $message; |
1206 | } |
1207 | |
1208 | // Now try checking the site language. |
1209 | $message = $this->getMessageForLang( $this->contLang, $lckey, $useDB, $alreadyTried ); |
1210 | return $message; |
1211 | } |
1212 | |
1213 | /** |
1214 | * Given a language, try and fetch messages from that language and its fallbacks. |
1215 | * |
1216 | * @see MessageCache::get |
1217 | * @param Language|StubObject $lang Preferred language |
1218 | * @param string $lckey Lowercase key for the message (as for localisation cache) |
1219 | * @param bool $useDB Whether to include messages from the wiki database |
1220 | * @param bool[] &$alreadyTried Contains true for each language that has been tried already |
1221 | * @return string|false The message, or false if not found |
1222 | */ |
1223 | private function getMessageForLang( $lang, $lckey, $useDB, &$alreadyTried ) { |
1224 | $langcode = $lang->getCode(); |
1225 | |
1226 | // Try checking the database for the requested language |
1227 | if ( $useDB ) { |
1228 | $uckey = $this->contLang->ucfirst( $lckey ); |
1229 | |
1230 | if ( !isset( $alreadyTried[$langcode] ) ) { |
1231 | $message = $this->getMsgFromNamespace( |
1232 | $this->getMessagePageName( $langcode, $uckey ), |
1233 | $langcode |
1234 | ); |
1235 | if ( $message !== false ) { |
1236 | return $message; |
1237 | } |
1238 | $alreadyTried[$langcode] = true; |
1239 | } |
1240 | } else { |
1241 | $uckey = null; |
1242 | } |
1243 | |
1244 | // Return a special value handled in Message::format() to display the message key |
1245 | // (and fallback keys) and the parameters passed to the message. |
1246 | // TODO: Move to a better place. |
1247 | if ( $langcode === 'qqx' ) { |
1248 | return '($*)'; |
1249 | } elseif ( |
1250 | $langcode === 'x-xss' && |
1251 | $this->useXssLanguage && |
1252 | !in_array |