Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
66.23% covered (warning)
66.23%
406 / 613
39.02% covered (danger)
39.02%
16 / 41
CRAP
0.00% covered (danger)
0.00%
0 / 1
MessageCache
66.34% covered (warning)
66.34%
406 / 612
39.02% covered (danger)
39.02%
16 / 41
1357.33
0.00% covered (danger)
0.00%
0 / 1
 normalizeKey
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 __construct
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
1
 setLogger
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getParserOptions
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 getLocalCache
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 saveToLocalCache
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 load
33.33% covered (danger)
33.33%
4 / 12
0.00% covered (danger)
0.00%
0 / 1
16.67
 loadUnguarded
45.07% covered (danger)
45.07%
32 / 71
0.00% covered (danger)
0.00%
0 / 1
86.29
 loadFromDBWithMainLock
57.89% covered (warning)
57.89%
11 / 19
0.00% covered (danger)
0.00%
0 / 1
6.87
 loadFromDBWithLocalLock
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
6
 loadFromDB
78.82% covered (warning)
78.82%
67 / 85
0.00% covered (danger)
0.00%
0 / 1
21.08
 isLanguageLoaded
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isMainCacheable
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 separateCacheableRows
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
4.02
 replace
83.33% covered (warning)
83.33%
10 / 12
0.00% covered (danger)
0.00%
0 / 1
5.12
 refreshAndReplaceInternal
65.00% covered (warning)
65.00%
26 / 40
0.00% covered (danger)
0.00%
0 / 1
12.47
 isCacheExpired
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 saveToCaches
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 getValidationHash
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
3
 setValidationHash
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 getReentrantScopedLock
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 get
82.22% covered (warning)
82.22%
37 / 45
0.00% covered (danger)
0.00%
0 / 1
12.81
 getLanguageObject
16.67% covered (danger)
16.67%
2 / 12
0.00% covered (danger)
0.00%
0 / 1
35.36
 getMessageFromFallbackChain
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 getMessageForLang
97.30% covered (success)
97.30%
36 / 37
0.00% covered (danger)
0.00%
0 / 1
14
 getMessagePageName
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getMsgFromNamespace
73.53% covered (warning)
73.53%
25 / 34
0.00% covered (danger)
0.00%
0 / 1
13.24
 loadCachedMessagePageEntry
79.41% covered (warning)
79.41%
27 / 34
0.00% covered (danger)
0.00%
0 / 1
4.14
 transform
16.67% covered (danger)
16.67%
2 / 12
0.00% covered (danger)
0.00%
0 / 1
8.21
 getParser
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 parse
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
30
 disable
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 enable
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isDisabled
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 clear
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 figureMessage
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
3.01
 getAllMessageKeys
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 updateMessageOverride
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 getCheckKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getMessageTextFromContent
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
4.18
 bigMessageCacheKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
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
21use MediaWiki\Config\ServiceOptions;
22use MediaWiki\Context\RequestContext;
23use MediaWiki\Deferred\DeferredUpdates;
24use MediaWiki\Deferred\MessageCacheUpdate;
25use MediaWiki\HookContainer\HookContainer;
26use MediaWiki\HookContainer\HookRunner;
27use MediaWiki\Language\ILanguageConverter;
28use MediaWiki\Languages\LanguageConverterFactory;
29use MediaWiki\Languages\LanguageFactory;
30use MediaWiki\Languages\LanguageFallback;
31use MediaWiki\Languages\LanguageNameUtils;
32use MediaWiki\Linker\LinkTarget;
33use MediaWiki\Logger\LoggerFactory;
34use MediaWiki\MainConfigNames;
35use MediaWiki\MediaWikiServices;
36use MediaWiki\Page\PageReference;
37use MediaWiki\Page\PageReferenceValue;
38use MediaWiki\Parser\Parser;
39use MediaWiki\Parser\ParserOutput;
40use MediaWiki\Revision\SlotRecord;
41use MediaWiki\StubObject\StubObject;
42use MediaWiki\StubObject\StubUserLang;
43use MediaWiki\Title\Title;
44use Psr\Log\LoggerAwareInterface;
45use Psr\Log\LoggerInterface;
46use Wikimedia\LightweightObjectStore\ExpirationAwareness;
47use Wikimedia\ObjectCache\BagOStuff;
48use Wikimedia\ObjectCache\EmptyBagOStuff;
49use Wikimedia\Rdbms\Database;
50use Wikimedia\Rdbms\IExpression;
51use Wikimedia\Rdbms\IResultWrapper;
52use Wikimedia\Rdbms\LikeValue;
53use Wikimedia\RequestTimeout\TimeoutException;
54use Wikimedia\ScopedCallback;
55
56/**
57 * MediaWiki message cache structure version.
58 * Bump this whenever the message cache format has changed.
59 */
60define( 'MSG_CACHE_VERSION', 2 );
61
62/**
63 * Cache messages that are defined by MediaWiki-namespace pages or by hooks.
64 *
65 * @ingroup Language
66 */
67class 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                    '&#32;',
1121                    // Fix for NBSP, converted to space by firefox
1122                    '&nbsp;',
1123                    '&#160;',
1124                    '&shy;'
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