Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
70.58% covered (warning)
70.58%
415 / 588
43.59% covered (danger)
43.59%
17 / 39
CRAP
0.00% covered (danger)
0.00%
0 / 1
MessageCache
70.70% covered (warning)
70.70%
415 / 587
43.59% covered (danger)
43.59%
17 / 39
955.29
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
2
 setLogger
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 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
57.14% covered (warning)
57.14%
4 / 7
0.00% covered (danger)
0.00%
0 / 1
6.97
 loadUnguarded
45.07% covered (danger)
45.07%
32 / 71
0.00% covered (danger)
0.00%
0 / 1
78.83
 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
 normalizeKey
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 get
73.21% covered (warning)
73.21%
41 / 56
0.00% covered (danger)
0.00%
0 / 1
25.94
 getMessageFromFallbackChain
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 getMessageForLang
100.00% covered (success)
100.00%
47 / 47
100.00% covered (success)
100.00%
1 / 1
20
 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
77.42% covered (warning)
77.42%
24 / 31
0.00% covered (danger)
0.00%
0 / 1
4.18
 transform
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 parseWithPostprocessing
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 parse
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
12
 disable
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 enable
100.00% covered (success)
100.00%
2 / 2
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
45.45% covered (danger)
45.45%
5 / 11
0.00% covered (danger)
0.00%
0 / 1
11.84
 bigMessageCacheKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 */
6
7namespace MediaWiki\Language;
8
9use Exception;
10use InvalidArgumentException;
11use LogicException;
12use MediaWiki\Config\ServiceOptions;
13use MediaWiki\Content\Content;
14use MediaWiki\Deferred\DeferredUpdates;
15use MediaWiki\HookContainer\HookContainer;
16use MediaWiki\HookContainer\HookRunner;
17use MediaWiki\Logger\LoggerFactory;
18use MediaWiki\MainConfigNames;
19use MediaWiki\MediaWikiServices;
20use MediaWiki\Page\PageIdentity;
21use MediaWiki\Page\PageReference;
22use MediaWiki\Page\PageReferenceValue;
23use MediaWiki\Parser\ParserOutput;
24use MediaWiki\Revision\SlotRecord;
25use MediaWiki\StubObject\StubUserLang;
26use MediaWiki\Title\Title;
27use Psr\Log\LoggerAwareInterface;
28use Psr\Log\LoggerInterface;
29use RuntimeException;
30use Throwable;
31use Wikimedia\ObjectCache\BagOStuff;
32use Wikimedia\ObjectCache\EmptyBagOStuff;
33use Wikimedia\ObjectCache\MapCacheLRU;
34use Wikimedia\ObjectCache\WANObjectCache;
35use Wikimedia\Rdbms\IDBAccessObject;
36use Wikimedia\Rdbms\IExpression;
37use Wikimedia\Rdbms\IResultWrapper;
38use Wikimedia\Rdbms\LikeValue;
39use Wikimedia\RequestTimeout\TimeoutException;
40use Wikimedia\ScopedCallback;
41use Wikimedia\Timestamp\TimestampFormat as TS;
42
43/**
44 * Cache messages that are defined by MediaWiki-namespace pages or by hooks.
45 *
46 * @ingroup Language
47 */
48class MessageCache implements LoggerAwareInterface {
49    /**
50     * Options to be included in the ServiceOptions
51     */
52    public const CONSTRUCTOR_OPTIONS = [
53        MainConfigNames::UseDatabaseMessages,
54        MainConfigNames::MaxMsgCacheEntrySize,
55        MainConfigNames::AdaptiveMessageCache,
56        MainConfigNames::UseXssLanguage,
57        MainConfigNames::RawHtmlMessages,
58    ];
59
60    /**
61     * Bump this whenever the cache format changes.
62     */
63    private const CACHE_VERSION = 2;
64
65    /**
66     * The size of the MapCacheLRU which stores message data. The maximum
67     * number of languages which can be efficiently loaded in a given request.
68     */
69    public const MAX_REQUEST_LANGUAGES = 10;
70
71    /** Force message reload */
72    private const FOR_UPDATE = 1;
73
74    /** How long to wait for locks */
75    private const LOCK_WAIT_TIME = 15;
76    /** How long locks last */
77    private const LOCK_TTL = 30;
78
79    /**
80     * Lifetime for cache, for keys stored in $wanCache, in seconds.
81     */
82    private const WAN_TTL = BagOStuff::TTL_DAY;
83
84    /** @var LoggerInterface */
85    private $logger;
86
87    /**
88     * Process cache of loaded messages that are defined in MediaWiki namespace
89     *
90     * @var MapCacheLRU Map of (language code => key => " <MESSAGE>" or "!TOO BIG" or "!ERROR")
91     */
92    private $cache;
93
94    /**
95     * Map of (lowercase message key => unused) for all software-defined messages
96     *
97     * @var array
98     */
99    private $systemMessageNames;
100
101    /**
102     * Map of (language code => boolean). Whether a message was updated in the
103     * last minute in a manner which risks a stampede.
104     * @var bool[]
105     */
106    private $isCacheVolatile = [];
107
108    /**
109     * If this is true, disable fetching from the MediaWiki namespace, including
110     * via the cache. Fall back to loading from the LocalisationCache only.
111     * @var bool
112     */
113    private $disabled;
114
115    /** @var int Maximum entry size in bytes */
116    private $maxEntrySize;
117    /** @var bool */
118    private $adaptive;
119    /** @var bool */
120    private $useXssLanguage;
121    /** @var string[] */
122    private $rawHtmlMessages;
123
124    /** @var WANObjectCache */
125    private $wanCache;
126    /** @var BagOStuff */
127    private $mainCache;
128    /** @var BagOStuff */
129    private $srvCache;
130    /** @var Language */
131    private $contLang;
132    /** @var string */
133    private $contLangCode;
134    /** @var ILanguageConverter */
135    private $contLangConverter;
136    /** @var LocalisationCache */
137    private $localisationCache;
138    /** @var LanguageNameUtils */
139    private $languageNameUtils;
140    /** @var LanguageFallback */
141    private $languageFallback;
142    /** @var HookRunner */
143    private $hookRunner;
144    /** @var MessageParser */
145    private $messageParser;
146
147    /** @var (string|callable)[]|null */
148    private $messageKeyOverrides;
149
150    /**
151     * @internal For use by ServiceWiring
152     * @param WANObjectCache $wanCache
153     * @param BagOStuff $mainCache
154     * @param BagOStuff $serverCache
155     * @param Language $contLang Content language of site
156     * @param LanguageConverterFactory $langConverterFactory
157     * @param LoggerInterface $logger
158     * @param ServiceOptions $options
159     * @param LocalisationCache $localisationCache
160     * @param LanguageNameUtils $languageNameUtils
161     * @param LanguageFallback $languageFallback
162     * @param HookContainer $hookContainer
163     * @param MessageParser $messageParser
164     */
165    public function __construct(
166        WANObjectCache $wanCache,
167        BagOStuff $mainCache,
168        BagOStuff $serverCache,
169        Language $contLang,
170        LanguageConverterFactory $langConverterFactory,
171        LoggerInterface $logger,
172        ServiceOptions $options,
173        LocalisationCache $localisationCache,
174        LanguageNameUtils $languageNameUtils,
175        LanguageFallback $languageFallback,
176        HookContainer $hookContainer,
177        MessageParser $messageParser
178    ) {
179        $this->wanCache = $wanCache;
180        $this->mainCache = $mainCache;
181        $this->srvCache = $serverCache;
182        $this->contLang = $contLang;
183        $this->contLangConverter = $langConverterFactory->getLanguageConverter( $contLang );
184        $this->contLangCode = $contLang->getCode();
185        $this->logger = $logger;
186        $this->localisationCache = $localisationCache;
187        $this->languageNameUtils = $languageNameUtils;
188        $this->languageFallback = $languageFallback;
189        $this->hookRunner = new HookRunner( $hookContainer );
190        $this->messageParser = $messageParser;
191
192        $this->cache = new MapCacheLRU( self::MAX_REQUEST_LANGUAGES );
193
194        $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
195        if ( !$options->get( MainConfigNames::UseDatabaseMessages ) ) {
196            $this->disable( 'config' );
197        }
198        $this->maxEntrySize = $options->get( MainConfigNames::MaxMsgCacheEntrySize );
199        $this->adaptive = $options->get( MainConfigNames::AdaptiveMessageCache );
200        $this->useXssLanguage = $options->get( MainConfigNames::UseXssLanguage );
201        $this->rawHtmlMessages = $options->get( MainConfigNames::RawHtmlMessages );
202    }
203
204    public function setLogger( LoggerInterface $logger ): void {
205        $this->logger = $logger;
206    }
207
208    /**
209     * Try to load the cache from APC.
210     *
211     * @param string $code
212     * @return array|false The cache array, or false if not in cache.
213     */
214    private function getLocalCache( $code ) {
215        $cacheKey = $this->srvCache->makeKey( __CLASS__, $code );
216
217        return $this->srvCache->get( $cacheKey );
218    }
219
220    /**
221     * Save the cache to APC.
222     *
223     * @param string $code
224     * @param array $cache The cache array
225     */
226    private function saveToLocalCache( $code, $cache ) {
227        $cacheKey = $this->srvCache->makeKey( __CLASS__, $code );
228        $this->srvCache->set( $cacheKey, $cache );
229    }
230
231    /**
232     * Loads messages from caches or from database in this order:
233     * (1) local message cache (if $wgUseLocalMessageCache is enabled)
234     * (2) the main cache
235     * (3) the database.
236     *
237     * When successfully loading from (2) or (3), all higher level caches are
238     * updated for the newest version.
239     *
240     * Nothing is loaded if member variable mDisable is true, either manually
241     * set by calling code or if message loading fails (is this possible?).
242     *
243     * Returns true if cache is already populated, or it was successfully populated,
244     * or false if populating empty cache fails. Also returns true if MessageCache
245     * is disabled.
246     *
247     * @param string $code Which language to load messages for
248     * @param int|null $mode Use MessageCache::FOR_UPDATE to skip process cache [optional]
249     * @return bool
250     */
251    private function load( string $code, $mode = null ) {
252        // Check if loading is done already
253        if ( $this->disabled ||
254            ( $mode !== self::FOR_UPDATE && $this->isLanguageLoaded( $code ) )
255        ) {
256            return true;
257        }
258
259        try {
260            return $this->loadUnguarded( $code, $mode );
261        } catch ( Throwable $e ) {
262            // Don't try to load again during the exception handler
263            $this->disable();
264            throw $e;
265        }
266    }
267
268    /**
269     * Load messages from the cache or database, without exception guarding.
270     *
271     * @param string $code Which language to load messages for
272     * @param int|null $mode Use MessageCache::FOR_UPDATE to skip process cache [optional]
273     * @return bool
274     */
275    private function loadUnguarded( $code, $mode ) {
276        $success = false; // Keep track of success
277        $staleCache = false; // a cache array with expired data, or false if none has been loaded
278        $where = []; // Debug info, delayed to avoid spamming debug log too much
279
280        // A hash of the expected content is stored in a WAN cache key, providing a way
281        // to invalidate the local cache on every server whenever a message page changes.
282        [ $hash, $isCacheVolatile ] = $this->getValidationHash( $code );
283        $this->isCacheVolatile[$code] = $isCacheVolatile;
284        $isStaleDueToVolatility = false;
285
286        // Try the local cache and check against the main cache hash key...
287        $cache = $this->getLocalCache( $code );
288        if ( !$cache ) {
289            $where[] = 'local cache is empty';
290        } elseif ( !isset( $cache['HASH'] ) || $cache['HASH'] !== $hash ) {
291            $where[] = 'local cache has the wrong hash';
292            $staleCache = $cache;
293        } elseif ( $this->isCacheExpired( $cache ) ) {
294            $where[] = 'local cache is expired';
295            $staleCache = $cache;
296        } elseif ( $isCacheVolatile ) {
297            // Some recent message page changes might not show due to DB lag
298            $where[] = 'local cache validation key is expired/volatile';
299            $staleCache = $cache;
300            $isStaleDueToVolatility = true;
301        } else {
302            $where[] = 'got from local cache';
303            $this->cache->set( $code, $cache );
304            $success = true;
305        }
306
307        if ( !$success ) {
308            // Try the main cache, using a lock for regeneration...
309            $cacheKey = $this->mainCache->makeKey( 'messages', $code );
310            for ( $failedAttempts = 0; $failedAttempts <= 1; $failedAttempts++ ) {
311                if ( $isStaleDueToVolatility ) {
312                    // While the main cache *might* be more up-to-date, we do not want
313                    // the I/O strain of every application server fetching the key here during
314                    // the volatility period. Either this thread wins the lock and regenerates
315                    // the cache or the stale local cache value gets reused.
316                    $where[] = 'global cache is presumed expired';
317                } else {
318                    $cache = $this->mainCache->get( $cacheKey );
319                    if ( !$cache ) {
320                        $where[] = 'global cache is empty';
321                    } elseif ( $this->isCacheExpired( $cache ) ) {
322                        $where[] = 'global cache is expired';
323                        $staleCache = $cache;
324                    } elseif ( $isCacheVolatile ) {
325                        // Some recent message page changes might not show due to DB lag
326                        $where[] = 'global cache is expired/volatile';
327                        $staleCache = $cache;
328                    } else {
329                        $where[] = 'got from global cache';
330                        $this->cache->set( $code, $cache );
331                        $this->saveToCaches( $cache, 'local-only', $code );
332                        $success = true;
333                        break;
334                    }
335                }
336
337                // We need to call loadFromDB(). Limit the concurrency to one thread.
338                // This prevents the site from going down when the cache expires.
339                // Note that the DB slam protection lock here is non-blocking.
340                $loadStatus = $this->loadFromDBWithMainLock( $code, $where, $mode );
341                if ( $loadStatus === true ) {
342                    $success = true;
343                    break;
344                } elseif ( $staleCache ) {
345                    // Use the stale cache while some other thread constructs the new one
346                    $where[] = 'using stale cache';
347                    $this->cache->set( $code, $staleCache );
348                    $success = true;
349                    break;
350                } elseif ( $failedAttempts > 0 ) {
351                    $where[] = 'failed to find cache after waiting';
352                    // Already blocked once, so avoid another lock/unlock cycle.
353                    // This case will typically be hit if memcached is down, or if
354                    // loadFromDB() takes longer than LOCK_WAIT.
355                    break;
356                } elseif ( $loadStatus === 'cant-acquire' ) {
357                    // Wait for the other thread to finish, then retry. Normally,
358                    // the memcached get() will then yield the other thread's result.
359                    $where[] = 'waiting for other thread to complete';
360                    [ , $ioError ] = $this->getReentrantScopedLock( $code );
361                    if ( $ioError ) {
362                        $where[] = 'failed waiting';
363                        // Call loadFromDB() with concurrency limited to one thread per server.
364                        // It should be rare for all servers to lack even a stale local cache.
365                        $success = $this->loadFromDBWithLocalLock( $code, $where, $mode );
366                        break;
367                    }
368                } else {
369                    // Disable cache; $loadStatus is 'disabled'
370                    break;
371                }
372            }
373        }
374
375        if ( !$success ) {
376            $where[] = 'loading FAILED - cache is disabled';
377            $this->disable();
378            $this->cache->set( $code, [] );
379            $this->logger->error( __METHOD__ . ": Failed to load $code" );
380            // This used to throw an exception, but that led to nasty side effects like
381            // the whole wiki being instantly down if the memcached server died
382        }
383
384        if ( !$this->isLanguageLoaded( $code ) ) {
385            throw new LogicException( "Process cache for '$code' should be set by now." );
386        }
387
388        $info = implode( ', ', $where );
389        $this->logger->debug( __METHOD__ . ": Loading $code... $info" );
390
391        return $success;
392    }
393
394    /**
395     * @param string $code
396     * @param string[] &$where List of debug comments
397     * @param int|null $mode Use MessageCache::FOR_UPDATE to use DB_PRIMARY
398     * @return true|string One of: true, "cant-acquire" or "disabled".
399     */
400    private function loadFromDBWithMainLock( $code, array &$where, $mode = null ) {
401        // If cache updates on all levels fail, give up on message overrides.
402        // This is to avoid easy site outages; see $saveSuccess comments below.
403        $statusKey = $this->mainCache->makeKey( 'messages', $code, 'status' );
404        $status = $this->mainCache->get( $statusKey );
405        if ( $status === 'error' ) {
406            $where[] = "could not load; method is still globally disabled";
407            return 'disabled';
408        }
409
410        // Now let's regenerate
411        $where[] = 'loading from DB';
412
413        // Lock the cache to prevent conflicting writes.
414        // This lock is non-blocking so stale cache can quickly be used.
415        // Note that load() will call a blocking getReentrantScopedLock()
416        // after this if it really needs to wait for any current thread.
417        [ $scopedLock ] = $this->getReentrantScopedLock( $code, 0 );
418        if ( !$scopedLock ) {
419            $where[] = 'could not acquire main lock';
420            return 'cant-acquire';
421        }
422
423        $cache = $this->loadFromDB( $code, $mode );
424        $this->cache->set( $code, $cache );
425        $saveSuccess = $this->saveToCaches( $cache, 'all', $code );
426
427        if ( !$saveSuccess ) {
428            /**
429             * Cache save has failed. Most likely this is because the cache is
430             * more than the maximum size (typically 1MB compressed).
431             *
432             * If there is a local cache, nothing bad will happen. If there is no local
433             * cache, disabling the message cache for all requests avoids incurring a
434             * loadFromDB() overhead on every request, and thus saves the wiki from
435             * complete downtime under moderate traffic conditions.
436             */
437            if ( $this->srvCache instanceof EmptyBagOStuff ) {
438                $this->mainCache->set( $statusKey, 'error', 60 * 5 );
439                $where[] = 'could not save cache, disabled globally for 5 minutes';
440            } else {
441                $where[] = "could not save global cache";
442            }
443        }
444
445        return true;
446    }
447
448    /**
449     * @param string $code
450     * @param string[] &$where List of debug comments
451     * @param int|null $mode Use MessageCache::FOR_UPDATE to use DB_PRIMARY
452     * @return bool Success
453     */
454    private function loadFromDBWithLocalLock( $code, array &$where, $mode = null ) {
455        $success = false;
456        $where[] = 'loading from DB using local lock';
457
458        $scopedLock = $this->srvCache->getScopedLock(
459            $this->srvCache->makeKey( 'messages', $code ),
460            self::LOCK_WAIT_TIME,
461            self::LOCK_TTL,
462            __METHOD__
463        );
464        if ( $scopedLock ) {
465            $cache = $this->loadFromDB( $code, $mode );
466            $this->cache->set( $code, $cache );
467            $this->saveToCaches( $cache, 'local-only', $code );
468            $success = true;
469        }
470
471        return $success;
472    }
473
474    /**
475     * Loads cacheable messages from the database. Messages bigger than
476     * $wgMaxMsgCacheEntrySize are assigned a special value, and are loaded
477     * on-demand from the database later.
478     *
479     * @param string $code Language code
480     * @param int|null $mode Use MessageCache::FOR_UPDATE to skip process cache
481     * @return array Loaded messages for storing in caches
482     */
483    private function loadFromDB( $code, $mode = null ) {
484        $icp = MediaWikiServices::getInstance()->getConnectionProvider();
485
486        $dbr = ( $mode === self::FOR_UPDATE ) ? $icp->getPrimaryDatabase() : $icp->getReplicaDatabase();
487
488        $cache = [];
489
490        $mostUsed = []; // list of "<cased message key>/<code>"
491        if ( $this->adaptive && $code !== $this->contLangCode ) {
492            if ( !$this->cache->has( $this->contLangCode ) ) {
493                $this->load( $this->contLangCode );
494            }
495            $mostUsed = array_keys( $this->cache->get( $this->contLangCode ) );
496            foreach ( $mostUsed as $key => $value ) {
497                $mostUsed[$key] = "$value/$code";
498            }
499        }
500
501        // Common conditions
502        $conds = [
503            // Treat redirects as not existing (T376398)
504            'page_is_redirect' => 0,
505            'page_namespace' => NS_MEDIAWIKI,
506        ];
507        if ( count( $mostUsed ) ) {
508            $conds['page_title'] = $mostUsed;
509        } elseif ( $code !== $this->contLangCode ) {
510            $conds[] = $dbr->expr(
511                'page_title',
512                IExpression::LIKE,
513                new LikeValue( $dbr->anyString(), '/', $code )
514            );
515        } else {
516            // Effectively disallows use of '/' character in NS_MEDIAWIKI for uses
517            // other than language code.
518            $conds[] = $dbr->expr(
519                'page_title',
520                IExpression::NOT_LIKE,
521                new LikeValue( $dbr->anyString(), '/', $dbr->anyString() )
522            );
523        }
524
525        // Set the stubs for oversized software-defined messages in the main cache map
526        $res = $dbr->newSelectQueryBuilder()
527            ->select( [ 'page_title', 'page_latest' ] )
528            ->from( 'page' )
529            ->where( $conds )
530            ->andWhere( $dbr->expr( 'page_len', '>', intval( $this->maxEntrySize ) ) )
531            ->caller( __METHOD__ . "($code)-big" )->fetchResultSet();
532        foreach ( $res as $row ) {
533            // Include entries/stubs for all keys in $mostUsed in adaptive mode
534            if ( $this->adaptive || $this->isMainCacheable( $row->page_title ) ) {
535                $cache[$row->page_title] = '!TOO BIG';
536            }
537            // At least include revision ID so page changes are reflected in the hash
538            $cache['EXCESSIVE'][$row->page_title] = $row->page_latest;
539        }
540
541        // RevisionStore cannot be injected as it would break the installer since
542        // it instantiates MessageCache before the DB.
543        $revisionStore = MediaWikiServices::getInstance()->getRevisionStore();
544        // Set the text for small software-defined messages in the main cache map
545        $revQuery = $revisionStore->getQueryInfo( [ 'page' ] );
546
547        // T231196: MySQL/MariaDB (10.1.37) can sometimes irrationally decide that querying `actor` then
548        // `revision` then `page` is somehow better than starting with `page`. Tell it not to reorder the
549        // query (and also reorder it ourselves because as generated by RevisionStore it'll have
550        // `revision` first rather than `page`).
551        $revQuery['joins']['revision'] = $revQuery['joins']['page'];
552        unset( $revQuery['joins']['page'] );
553        // It isn't actually necessary to reorder $revQuery['tables'] as Database does the right thing
554        // when join conditions are given for all joins, but GergÅ‘ is wary of relying on that so pull
555        // `page` to the start.
556        $revQuery['tables'] = array_merge(
557            [ 'page' ],
558            array_diff( $revQuery['tables'], [ 'page' ] )
559        );
560
561        $res = $dbr->newSelectQueryBuilder()
562            ->queryInfo( $revQuery )
563            ->where( $conds )
564            ->andWhere( [
565                $dbr->expr( 'page_len', '<=', intval( $this->maxEntrySize ) ),
566                'page_latest = rev_id' // get the latest revision only
567            ] )
568            ->caller( __METHOD__ . "($code)-small" )
569            ->straightJoinOption()
570            ->fetchResultSet();
571
572        // Don't load content from uncacheable rows (T313004)
573        [ $cacheableRows, $uncacheableRows ] = $this->separateCacheableRows( $res );
574        $result = $revisionStore->newRevisionsFromBatch( $cacheableRows, [
575            'slots' => [ SlotRecord::MAIN ],
576            'content' => true
577        ] );
578        $revisions = $result->isOK() ? $result->getValue() : [];
579
580        foreach ( $cacheableRows as $row ) {
581            try {
582                $rev = $revisions[$row->rev_id] ?? null;
583                $content = $rev ? $rev->getContent( SlotRecord::MAIN ) : null;
584                $text = $this->getMessageTextFromContent( $content );
585            } catch ( TimeoutException $e ) {
586                throw $e;
587            } catch ( Exception ) {
588                $text = false;
589            }
590
591            if ( !is_string( $text ) ) {
592                $entry = '!ERROR';
593                $this->logger->error(
594                    __METHOD__
595                    . ": failed to load message page text for {$row->page_title} ($code)"
596                );
597            } else {
598                $entry = ' ' . $text;
599            }
600            $cache[$row->page_title] = $entry;
601        }
602
603        foreach ( $uncacheableRows as $row ) {
604            // T193271: The cache object gets too big and slow to generate.
605            // At least include revision ID, so that page changes are reflected in the hash.
606            $cache['EXCESSIVE'][$row->page_title] = $row->page_latest;
607        }
608
609        $cache['VERSION'] = self::CACHE_VERSION;
610        ksort( $cache );
611
612        // Hash for validating local cache (APC). No need to take into account
613        // messages larger than $wgMaxMsgCacheEntrySize, since those are only
614        // stored and fetched from memcache.
615        $cache['HASH'] = md5( serialize( $cache ) );
616        $cache['EXPIRY'] = wfTimestamp( TS::MW, time() + self::WAN_TTL );
617        unset( $cache['EXCESSIVE'] ); // only needed for hash
618
619        return $cache;
620    }
621
622    /**
623     * Whether the language was loaded and its data is still in the process cache.
624     *
625     * @param string $lang
626     * @return bool
627     */
628    private function isLanguageLoaded( $lang ) {
629        // It is important that this only returns true if the cache was fully
630        // populated by load(), so that callers can assume all cache keys exist.
631        // It is possible for $this->cache to be only partially populated through
632        // methods like MessageCache::replace(), which must not make this method
633        // return true (T208897). And this method must cease to return true
634        // if the language was evicted by MapCacheLRU (T230690).
635        return $this->cache->hasField( $lang, 'VERSION' );
636    }
637
638    /**
639     * Can the given DB key be added to the main cache blob? To reduce the
640     * abuse impact of the MediaWiki namespace by {{int:}} and CentralNotice,
641     * this is only true if the page overrides a predefined message.
642     *
643     * @param string $name Message name (possibly with /code suffix)
644     * @param string|null $code The language code. If this is null, message
645     *   presence will be bulk loaded for the content language. Otherwise,
646     *   presence will be detected by loading the specified message.
647     * @return bool
648     */
649    private function isMainCacheable( $name, $code = null ) {
650        // Convert the first letter to lowercase, and strip /code suffix
651        $name = $this->contLang->lcfirst( $name );
652        // Include common conversion table pages. This also avoids problems with
653        // Installer::parse() bailing out due to disallowed DB queries (T207979).
654        if ( str_starts_with( $name, 'conversiontable/' ) ) {
655            return true;
656        }
657        $msg = preg_replace( '/\/[a-z0-9-]{2,}$/', '', $name );
658
659        if ( $code === null ) {
660            // Bulk load
661            if ( $this->systemMessageNames === null ) {
662                $this->systemMessageNames = array_fill_keys(
663                    $this->localisationCache->getSubitemList( $this->contLangCode, 'messages' ),
664                    true );
665            }
666            return isset( $this->systemMessageNames[$msg] );
667        } else {
668            // Use individual subitem
669            return $this->localisationCache->getSubitem( $code, 'messages', $msg ) !== null;
670        }
671    }
672
673    /**
674     * Separate cacheable from uncacheable rows in a page/revision query result.
675     *
676     * @param IResultWrapper $res
677     * @return array{0:IResultWrapper|\stdClass[],1:\stdClass[]} An array with the cacheable
678     *    rows in the first element and the uncacheable rows in the second.
679     */
680    private function separateCacheableRows( $res ) {
681        if ( $this->adaptive ) {
682            // Include entries/stubs for all keys in $mostUsed in adaptive mode
683            return [ $res, [] ];
684        }
685        $cacheableRows = [];
686        $uncacheableRows = [];
687        foreach ( $res as $row ) {
688            if ( $this->isMainCacheable( $row->page_title ) ) {
689                $cacheableRows[] = $row;
690            } else {
691                $uncacheableRows[] = $row;
692            }
693        }
694        return [ $cacheableRows, $uncacheableRows ];
695    }
696
697    /**
698     * Update the cache as necessary when a message page is changed
699     *
700     * @param string $title Message cache key with the initial uppercase letter
701     * @param string|false $text New contents of the page (false if deleted)
702     */
703    public function replace( $title, $text ) {
704        if ( $this->disabled ) {
705            return;
706        }
707
708        [ $msg, $code ] = $this->figureMessage( $title );
709        if ( str_contains( $title, '/' ) && $code === $this->contLangCode ) {
710            // Content language overrides do not use the /<code> suffix
711            return;
712        }
713
714        // (a) Update the process cache with the new message text
715        if ( $text === false ) {
716            // Page deleted
717            $this->cache->setField( $code, $title, '!NONEXISTENT' );
718        } else {
719            // Ignore $wgMaxMsgCacheEntrySize so the process cache is up-to-date
720            $this->cache->setField( $code, $title, ' ' . $text );
721        }
722
723        // (b) Update the shared caches in a deferred update with a fresh DB snapshot
724        DeferredUpdates::addUpdate(
725            new MessageCacheUpdate( $code, $title, $msg ),
726            DeferredUpdates::PRESEND
727        );
728    }
729
730    /**
731     * @internal Entry point for MessageCacheUpdate
732     * @param string $code
733     * @param array[] $replacements List of (title, message key) pairs
734     */
735    public function refreshAndReplaceInternal( string $code, array $replacements ) {
736        // Allow one caller at a time to avoid race conditions
737        [ $scopedLock ] = $this->getReentrantScopedLock( $code );
738        if ( !$scopedLock ) {
739            foreach ( $replacements as [ $title ] ) {
740                $this->logger->error(
741                    __METHOD__ . ': could not acquire lock to update {title} ({code})',
742                    [ 'title' => $title, 'code' => $code ] );
743            }
744
745            return;
746        }
747
748        // Load the existing cache to update it in the local DC cache.
749        // The other DCs will see a hash mismatch.
750        if ( $this->load( $code, self::FOR_UPDATE ) ) {
751            $cache = $this->cache->get( $code );
752        } else {
753            // Err? Fall back to loading from the database.
754            $cache = $this->loadFromDB( $code, self::FOR_UPDATE );
755        }
756        // Check if individual cache keys should exist and update cache accordingly
757        $newTextByTitle = []; // map of (title => content)
758        $newBigTitles = []; // map of (title => latest revision ID), like EXCESSIVE in loadFromDB()
759        // Can not inject the WikiPageFactory as it would break the installer since
760        // it instantiates MessageCache before the DB.
761        $wikiPageFactory = MediaWikiServices::getInstance()->getWikiPageFactory();
762        foreach ( $replacements as [ $title ] ) {
763            $page = $wikiPageFactory->newFromTitle( Title::makeTitle( NS_MEDIAWIKI, $title ) );
764            $page->loadPageData( IDBAccessObject::READ_LATEST );
765            $text = $this->getMessageTextFromContent( $page->getContent() );
766            // Remember the text for the blob store update later on
767            $newTextByTitle[$title] = $text ?? '';
768            // Note that if $text is false, then $cache should have a !NONEXISTENT entry
769            if ( !is_string( $text ) ) {
770                $cache[$title] = '!NONEXISTENT';
771            } elseif ( strlen( $text ) > $this->maxEntrySize ) {
772                $cache[$title] = '!TOO BIG';
773                $newBigTitles[$title] = $page->getLatest();
774            } else {
775                $cache[$title] = ' ' . $text;
776            }
777        }
778        // Update HASH for the new key. Incorporates various administrative keys,
779        // including the old HASH (and thereby the EXCESSIVE value from loadFromDB()
780        // and previous replace() calls), but that doesn't really matter since we
781        // only ever compare it for equality with a copy saved by saveToCaches().
782        $cache['HASH'] = md5( serialize( $cache + [ 'EXCESSIVE' => $newBigTitles ] ) );
783        // Update the too-big WAN cache entries now that we have the new HASH
784        foreach ( $newBigTitles as $title => $id ) {
785            // Match logic of loadCachedMessagePageEntry()
786            $this->wanCache->set(
787                $this->bigMessageCacheKey( $cache['HASH'], $title ),
788                ' ' . $newTextByTitle[$title],
789                self::WAN_TTL
790            );
791        }
792        // Mark this cache as definitely being "latest" (non-volatile) so
793        // load() calls do not try to refresh the cache with replica DB data
794        $cache['LATEST'] = time();
795        // Update the process cache
796        $this->cache->set( $code, $cache );
797        // Pre-emptively update the local datacenter cache so things like edit filter and
798        // prevented changes are reflected immediately; these often use MediaWiki: pages.
799        // The datacenter handling replace() calls should be the same one handling edits
800        // as they require HTTP POST.
801        $this->saveToCaches( $cache, 'all', $code );
802        // Release the lock now that the cache is saved
803        ScopedCallback::consume( $scopedLock );
804
805        // Relay the purge. Touching this check key expires cache contents
806        // and local cache (APC) validation hash across all datacenters.
807        $this->wanCache->touchCheckKey( $this->getCheckKey( $code ) );
808
809        // Purge the messages in the message blob store and fire any hook handlers
810        $blobStore = MediaWikiServices::getInstance()->getResourceLoader()->getMessageBlobStore();
811        foreach ( $replacements as [ $title, $msg ] ) {
812            $blobStore->updateMessage( $this->contLang->lcfirst( $msg ) );
813            $this->hookRunner->onMessageCacheReplace( $title, $newTextByTitle[$title] );
814        }
815    }
816
817    /**
818     * Is the given cache array expired due-to-time passing or a version change?
819     *
820     * @param array $cache
821     * @return bool
822     */
823    private function isCacheExpired( $cache ) {
824        return !isset( $cache['VERSION'] ) ||
825            !isset( $cache['EXPIRY'] ) ||
826            $cache['VERSION'] !== self::CACHE_VERSION ||
827            $cache['EXPIRY'] <= wfTimestampNow();
828    }
829
830    /**
831     * Store data in the local and main caches, and update the validation hash in
832     * the WAN cache.
833     *
834     * @param array $cache Cached messages with a version.
835     * @param string $dest Either "local-only" to save to local caches only
836     *   or "all" to save to all caches.
837     * @param string|false $code Language code (default: false)
838     * @return bool
839     */
840    private function saveToCaches( array $cache, $dest, $code = false ) {
841        if ( $dest === 'all' ) {
842            $cacheKey = $this->mainCache->makeKey( 'messages', $code );
843            $success = $this->mainCache->set( $cacheKey, $cache );
844            $this->setValidationHash( $code, $cache );
845        } else {
846            $success = true;
847        }
848
849        $this->saveToLocalCache( $code, $cache );
850
851        return $success;
852    }
853
854    /**
855     * Get the MD5 hash used to validate the local server cache
856     *
857     * @param string $code
858     * @return array (hash or false, bool expiry/volatility status)
859     */
860    private function getValidationHash( $code ) {
861        $curTTL = null;
862        $value = $this->wanCache->get(
863            $this->wanCache->makeKey( 'messages', $code, 'hash', 'v1' ),
864            $curTTL,
865            [ $this->getCheckKey( $code ) ]
866        );
867
868        if ( $value ) {
869            $hash = $value['hash'];
870            if ( ( time() - $value['latest'] ) < WANObjectCache::TTL_MINUTE ) {
871                // Cache was recently updated via replace() and should be up-to-date.
872                // That method is only called in the primary datacenter and uses FOR_UPDATE.
873                $isCacheVolatile = false;
874            } else {
875                // See if the "check" key was bumped after the hash was generated
876                $isCacheVolatile = ( $curTTL < 0 );
877            }
878        } else {
879            // No hash found at all; cache must regenerate to be safe
880            $hash = false;
881            $isCacheVolatile = true;
882        }
883
884        return [ $hash, $isCacheVolatile ];
885    }
886
887    /**
888     * Set the MD5 hash used to validate the local server cache
889     *
890     * If $cache has a 'LATEST' UNIX timestamp key, then the hash will not
891     * be treated as "volatile" by getValidationHash() for the next few seconds.
892     * This is triggered when $cache is generated using FOR_UPDATE mode.
893     *
894     * @param string $code
895     * @param array $cache Cached messages with a version
896     */
897    private function setValidationHash( $code, array $cache ) {
898        $this->wanCache->set(
899            $this->wanCache->makeKey( 'messages', $code, 'hash', 'v1' ),
900            [
901                'hash' => $cache['HASH'],
902                'latest' => $cache['LATEST'] ?? 0
903            ],
904            WANObjectCache::TTL_INDEFINITE
905        );
906    }
907
908    /**
909     * @param string $code The language code being loaded
910     * @param int $timeout Wait timeout in seconds
911     * @return array (ScopedCallback or null, whether locking failed due to an I/O error)
912     * @phan-return array{0:ScopedCallback|null,1:bool}
913     */
914    private function getReentrantScopedLock( $code, $timeout = self::LOCK_WAIT_TIME ) {
915        $key = $this->mainCache->makeKey( 'messages', $code );
916
917        $watchPoint = $this->mainCache->watchErrors();
918        $scopedLock = $this->mainCache->getScopedLock(
919            $key,
920            $timeout,
921            self::LOCK_TTL,
922            __METHOD__
923        );
924        $error = ( !$scopedLock && $this->mainCache->getLastError( $watchPoint ) );
925
926        return [ $scopedLock, $error ];
927    }
928
929    /**
930     * Normalize message key input
931     *
932     * @param string $key Input message key to be normalized
933     * @return string Normalized message key
934     */
935    public function normalizeKey( string $key ): string {
936        if ( $key === '' ) {
937            return '';
938        }
939        $lckey = strtr( $key, ' ', '_' );
940        if ( ord( $lckey[0] ) < 128 ) {
941            $lckey[0] = strtolower( $lckey[0] );
942        } else {
943            $lckey = $this->contLang->lcfirst( $lckey );
944        }
945
946        return $lckey;
947    }
948
949    /**
950     * Get a message from either the content language or the user language.
951     *
952     * First, assemble a list of languages to attempt getting the message from. This
953     * chain begins with the requested language and its fallbacks and then continues with
954     * the content language and its fallbacks. For each language in the chain, the following
955     * process will occur (in this order):
956     *  1. If a language-specific override, i.e., [[MW:msg/lang]], is available, use that.
957     *     Note: for the content language, there is no /lang subpage.
958     *  2. Fetch from LocalisationCache (the i18n JSON file store).
959     *  3. If available, check the database for fallback language overrides.
960     *
961     * This process provides a number of guarantees. When changing this code, make sure all
962     * of these guarantees are preserved.
963     *  * If the requested language is *not* the content language, then the CDB cache for that
964     *    specific language will take precedence over the root database page ([[MW:msg]]).
965     *  * Fallbacks will be just that: fallbacks. A fallback language will never be reached if
966     *    the message is available *anywhere* in the language for which it is a fallback.
967     *
968     * @param string $key The message key
969     * @param bool $useDB If true, look for the message in the DB, false
970     *   to use only the LocalisationCache.
971     * @param string|null $langCode Code of the language to get the message for.
972     *   If null is given, the content language code will be used.
973     * @param MessageInfo|null $info If a default-constructed MessageInfo is passed, it will be
974     *   populated with information about the retrieved message.
975     *
976     * @return string|false False if the message doesn't exist, otherwise the
977     *   message (which can be empty)
978     */
979    public function get( $key, $useDB = true, ?string $langCode = null, $info = null ) {
980        if ( is_int( $key ) ) {
981            // Fix numerical strings that somehow become ints on their way here
982            $key = (string)$key;
983        } elseif ( !is_string( $key ) ) {
984            throw new InvalidArgumentException( 'Message key must be a string' );
985        } elseif ( $key === '' ) {
986            // Shortcut: the empty key is always missing
987            return false;
988        }
989
990        // Ignore legacy $usedKey parameter
991        if ( $info && !( $info instanceof MessageInfo ) ) {
992            $info = null;
993        }
994
995        if ( $langCode === null ) {
996            $langCode = $this->contLangCode;
997        } elseif ( !$this->languageNameUtils->isValidCode( $langCode ) ) {
998            // $langCode is not a valid language code; use content language code.
999            $this->logger->debug( 'Invalid language code passed to ' . __METHOD__ .
1000                ', falling back to content language.' );
1001            $langCode = $this->contLangCode;
1002        }
1003
1004        // Normalise title-case input (with some inlining)
1005        $lckey = $this->normalizeKey( $key );
1006
1007        // Initialize the overrides here to prevent calling the hook too early.
1008        if ( $this->messageKeyOverrides === null ) {
1009            $this->messageKeyOverrides = [];
1010            $this->hookRunner->onMessageCacheFetchOverrides( $this->messageKeyOverrides );
1011        }
1012
1013        if ( isset( $this->messageKeyOverrides[$lckey] ) ) {
1014            $override = $this->messageKeyOverrides[$lckey];
1015
1016            // Strings are deliberately interpreted as message keys,
1017            // to prevent ambiguity between message keys and functions.
1018            if ( is_string( $override ) ) {
1019                $lckey = $override;
1020            } else {
1021                $lckey = $override( $lckey, $this );
1022            }
1023        }
1024
1025        $this->hookRunner->onMessageCache__get( $lckey );
1026
1027        if ( $info ) {
1028            $info->usedKey = $lckey;
1029        }
1030
1031        // Loop through each language in the fallback list until we find something useful
1032        $message = $this->getMessageFromFallbackChain(
1033            $langCode,
1034            $lckey,
1035            !$this->disabled && $useDB,
1036            $info
1037        );
1038
1039        // If we still have no message, maybe the key was in fact a full key, so try that
1040        if ( $message === false ) {
1041            $parts = explode( '/', $lckey );
1042            // We may get calls for things that are HTTP URLs from the sidebar
1043            // Let's not load nonexistent languages for those
1044            // They usually have more than one slash.
1045            if ( count( $parts ) === 2 && $parts[1] !== '' ) {
1046                $message = $this->localisationCache->getSubitem( $parts[1], 'messages', $parts[0] ) ?? false;
1047                if ( $message !== false && $info ) {
1048                    $info->usedKey = $parts[0];
1049                    $info->langCode = $parts[1];
1050                }
1051            }
1052        }
1053
1054        // Post-processing if the message exists
1055        if ( $message !== false ) {
1056            // Fix whitespace
1057            $message = str_replace(
1058                [
1059                    // Fix for trailing whitespace, removed by textarea
1060                    '&#32;',
1061                    // Fix for NBSP, converted to space by firefox
1062                    '&nbsp;',
1063                    '&#160;',
1064                    '&shy;'
1065                ],
1066                [
1067                    ' ',
1068                    "\u{00A0}",
1069                    "\u{00A0}",
1070                    "\u{00AD}"
1071                ],
1072                $message
1073            );
1074        }
1075
1076        return $message;
1077    }
1078
1079    /**
1080     * Given a language, try to fetch messages for that language, fallbacks of
1081     * that language, the site language, or fallbacks of the site language.
1082     *
1083     * @see MessageCache::get
1084     * @param string $code Preferred language
1085     * @param string $lckey Lowercase key for the message (as for localisation cache)
1086     * @param bool $useDB Whether to include messages from the wiki database
1087     * @param MessageInfo|null $info
1088     * @return string|false The message, or false if not found
1089     */
1090    private function getMessageFromFallbackChain( $code, $lckey, $useDB, $info ) {
1091        $alreadyTried = [];
1092
1093        // First try the requested language.
1094        $message = $this->getMessageForLang( $code, $lckey, $useDB, $alreadyTried, $info );
1095        if ( $message !== false ) {
1096            return $message;
1097        }
1098
1099        // Now try checking the site language.
1100        $message = $this->getMessageForLang( $this->contLangCode, $lckey, $useDB, $alreadyTried, $info );
1101        return $message;
1102    }
1103
1104    /**
1105     * Given a language, try to fetch messages for that language and its fallbacks.
1106     *
1107     * @see MessageCache::get
1108     * @param string $langCode Preferred language
1109     * @param string $lckey Lowercase key for the message (as for localisation cache)
1110     * @param bool $useDB Whether to include messages from the wiki database
1111     * @param bool[] &$alreadyTried Contains true for each language that has been tried already
1112     * @param MessageInfo|null $info
1113     * @return string|false The message, or false if not found
1114     */
1115    private function getMessageForLang( $langCode, $lckey, $useDB, &$alreadyTried, $info ) {
1116        // Try checking the database for the requested language
1117        if ( $useDB ) {
1118            $uckey = $this->contLang->ucfirst( $lckey );
1119
1120            if ( !isset( $alreadyTried[$langCode] ) ) {
1121                $message = $this->getMsgFromNamespace(
1122                    $this->getMessagePageName( $langCode, $uckey ),
1123                    $langCode
1124                );
1125                if ( $message !== false ) {
1126                    if ( $info ) {
1127                        $info->langCode = $langCode;
1128                    }
1129                    return $message;
1130                }
1131                $alreadyTried[$langCode] = true;
1132            }
1133        } else {
1134            $uckey = null;
1135        }
1136
1137        // Return a special value handled in Message::format() to display the message key
1138        // (and fallback keys) and the parameters passed to the message.
1139        // TODO: Move to a better place.
1140        if ( $langCode === 'qqx' ) {
1141            return '($*)';
1142        } elseif (
1143            $langCode === 'x-xss' &&
1144            $this->useXssLanguage &&
1145            !in_array( $lckey, $this->rawHtmlMessages, true )
1146        ) {
1147            $lcKeyForAlert = str_replace( [ '"', "'" ], '_', $lckey );
1148            $xssViaInnerHtml = "<img src=\"\" onerror='alert(\"$lcKeyForAlert\")'/>";
1149            $xssViaAttribute = '">' . $xssViaInnerHtml . '<x y="';
1150            return $xssViaInnerHtml . $xssViaAttribute . '($*)';
1151        }
1152
1153        // Check the localisation cache
1154        [ $defaultMessage, $messageSource ] =
1155            $this->localisationCache->getSubitemWithSource( $langCode, 'messages', $lckey );
1156        if ( $messageSource === $langCode ) {
1157            if ( $info ) {
1158                $info->langCode = $langCode;
1159            }
1160            return $defaultMessage;
1161        }
1162
1163        // Try checking the database for all of the fallback languages
1164        if ( $useDB ) {
1165            $fallbackChain = $this->languageFallback->getAll( $langCode );
1166
1167            foreach ( $fallbackChain as $code ) {
1168                if ( isset( $alreadyTried[$code] ) ) {
1169                    continue;
1170                }
1171
1172                $message = $this->getMsgFromNamespace(
1173                    // @phan-suppress-next-line PhanTypeMismatchArgumentNullable uckey is set when used
1174                    $this->getMessagePageName( $code, $uckey ), $code );
1175
1176                if ( $message !== false ) {
1177                    if ( $info ) {
1178                        $info->langCode = $code;
1179                    }
1180                    return $message;
1181                }
1182                $alreadyTried[$code] = true;
1183
1184                // Reached the source language of the default message. Don't look for DB overrides
1185                // further back in the fallback chain. (T229992)
1186                if ( $code === $messageSource ) {
1187                    if ( $info ) {
1188                        $info->langCode = $code;
1189                    }
1190                    return $defaultMessage;
1191                }
1192            }
1193        }
1194
1195        if ( $defaultMessage !== null && $info ) {
1196            $info->langCode = $messageSource;
1197        }
1198        return $defaultMessage ?? false;
1199    }
1200
1201    /**
1202     * Get the message page name for a given language
1203     *
1204     * @param string $langCode
1205     * @param string $uckey Uppercase key for the message
1206     * @return string The page name
1207     */
1208    private function getMessagePageName( $langCode, $uckey ) {
1209        if ( $langCode === $this->contLangCode ) {
1210            // Messages created in the content language will not have the /lang extension
1211            return $uckey;
1212        } else {
1213            return "$uckey/$langCode";
1214        }
1215    }
1216
1217    /**
1218     * Get a message from the MediaWiki namespace, with caching. The key must
1219     * first be converted to two-part lang/msg form if necessary.
1220     *
1221     * Unlike self::get(), this function doesn't resolve fallback chains, and
1222     * some callers require this behavior. LanguageConverter::parseCachedTable()
1223     * and self::get() are some examples in core.
1224     *
1225     * @param string $title Message cache key with the initial uppercase letter
1226     * @param string $code Code denoting the language to try
1227     * @return string|false The message, or false if it does not exist or on error
1228     */
1229    public function getMsgFromNamespace( $title, $code ) {
1230        // Load all MediaWiki page definitions into cache. Note that individual keys
1231        // already loaded into the cache during this request remain in the cache, which
1232        // includes the value of hook-defined messages.
1233        $this->load( $code );
1234
1235        $entry = $this->cache->getField( $code, $title );
1236
1237        if ( $entry !== null ) {
1238            // Message page exists as an override of a software messages
1239            if ( str_starts_with( $entry, ' ' ) ) {
1240                // The message exists and is not '!TOO BIG' or '!ERROR'
1241                return substr( $entry, 1 );
1242            } elseif ( $entry === '!NONEXISTENT' ) {
1243                // The text might be '-' or missing due to some data loss
1244                return false;
1245            }
1246            // Load the message page, utilizing the individual message cache.
1247            // If the page does not exist, there will be no hook handler fallbacks.
1248            $entry = $this->loadCachedMessagePageEntry(
1249                $title,
1250                $code,
1251                $this->cache->getField( $code, 'HASH' )
1252            );
1253        } else {
1254            // Message page either does not exist or does not override a software message
1255            if ( !$this->isMainCacheable( $title, $code ) ) {
1256                // Message page does not override any software-defined message. A custom
1257                // message might be defined to have content or settings specific to the wiki.
1258                // Load the message page, utilizing the individual message cache as needed.
1259                $entry = $this->loadCachedMessagePageEntry(
1260                    $title,
1261                    $code,
1262                    $this->cache->getField( $code, 'HASH' )
1263                );
1264            }
1265            if ( $entry === null || !str_starts_with( $entry, ' ' ) ) {
1266                // Message does not have a MediaWiki page definition; try hook handlers
1267                $message = false;
1268                // @phan-suppress-next-line PhanTypeMismatchArgument Type mismatch on pass-by-ref args
1269                $this->hookRunner->onMessagesPreLoad( $title, $message, $code );
1270                if ( $message !== false ) {
1271                    $this->cache->setField( $code, $title, ' ' . $message );
1272                } else {
1273                    $this->cache->setField( $code, $title, '!NONEXISTENT' );
1274                }
1275
1276                return $message;
1277            }
1278        }
1279
1280        if ( $entry !== false && str_starts_with( $entry, ' ' ) ) {
1281            if ( $this->isCacheVolatile[$code] ) {
1282                // Make sure that individual keys respect the WAN cache holdoff period too
1283                $this->logger->debug(
1284                    __METHOD__ . ': loading volatile key \'{titleKey}\'',
1285                    [ 'titleKey' => $title, 'code' => $code ] );
1286            } else {
1287                $this->cache->setField( $code, $title, $entry );
1288            }
1289            // The message exists, so make sure a string is returned
1290            return substr( $entry, 1 );
1291        }
1292
1293        $this->cache->setField( $code, $title, '!NONEXISTENT' );
1294
1295        return false;
1296    }
1297
1298    /**
1299     * @param string $dbKey
1300     * @param string $code
1301     * @param string $hash
1302     * @return string Either " <MESSAGE>" or "!NONEXISTENT"
1303     */
1304    private function loadCachedMessagePageEntry( $dbKey, $code, $hash ) {
1305        $fname = __METHOD__;
1306        return $this->srvCache->getWithSetCallback(
1307            $this->srvCache->makeKey( 'messages-big', $hash, $dbKey ),
1308            BagOStuff::TTL_HOUR,
1309            function () use ( $code, $dbKey, $hash, $fname ) {
1310                return $this->wanCache->getWithSetCallback(
1311                    $this->bigMessageCacheKey( $hash, $dbKey ),
1312                    self::WAN_TTL,
1313                    function ( $oldValue, &$ttl ) use ( $dbKey, $code, $fname ) {
1314                        // Try loading the message from the database
1315                        // Use newKnownCurrent() to avoid querying revision/user tables
1316                        $title = Title::makeTitle( NS_MEDIAWIKI, $dbKey );
1317                        // Injecting RevisionStore breaks installer since it
1318                        // instantiates MessageCache before DB.
1319                        $revision = MediaWikiServices::getInstance()
1320                            ->getRevisionLookup()
1321                            ->getKnownLatestRevision( $title );
1322                        if ( !$revision ) {
1323                            // The wiki doesn't have a local override page. Cache absence with normal TTL.
1324                            // When overrides are created, self::replace() takes care of the cache.
1325                            return '!NONEXISTENT';
1326                        }
1327                        $content = $revision->getContent( SlotRecord::MAIN );
1328                        if ( $content ) {
1329                            $message = $this->getMessageTextFromContent( $content );
1330                        } else {
1331                            $this->logger->warning(
1332                                $fname . ': failed to load page text for \'{titleKey}\'',
1333                                [ 'titleKey' => $dbKey, 'code' => $code ]
1334                            );
1335                            $message = null;
1336                        }
1337
1338                        if ( !is_string( $message ) ) {
1339                            // Revision failed to load Content, or Content is incompatible with wikitext.
1340                            // Possibly a temporary loading failure.
1341                            $ttl = 5;
1342
1343                            return '!NONEXISTENT';
1344                        }
1345
1346                        return ' ' . $message;
1347                    }
1348                );
1349            }
1350        );
1351    }
1352
1353    /**
1354     * @deprecated since 1.44 use MessageParser::transform()
1355     *
1356     * @param string $message
1357     * @param bool $interface
1358     * @param Language|null $language
1359     * @param PageReference|null $page
1360     * @return string
1361     */
1362    public function transform( $message, $interface = false, $language = null, ?PageReference $page = null ) {
1363        return $this->messageParser->transform(
1364            $message, $interface, $language, $page );
1365    }
1366
1367    /**
1368     * @deprecated since 1.44 use MessageParser::parse()
1369     * @internal
1370     *
1371     * @param string $text
1372     * @param PageReference $contextPage
1373     * @param bool $linestart Whether this should be parsed in start-of-line
1374     *  context (defaults to true)
1375     * @param bool $interface Whether this is an interface message
1376     *  (defaults to false)
1377     * @param Language|StubUserLang|string|null $language Language code
1378     * @return ParserOutput
1379     */
1380    public function parseWithPostprocessing(
1381        string $text, PageReference $contextPage,
1382        bool $linestart = true,
1383        bool $interface = false,
1384        $language = null
1385    ): ParserOutput {
1386        return $this->messageParser->parse(
1387            $text, $contextPage, $linestart, $interface, $language );
1388    }
1389
1390    /**
1391     * @deprecated since 1.44 use MessageParser::parseWithoutPostprocessing()
1392     *
1393     * @param string $text
1394     * @param PageReference|null $page
1395     * @param bool $linestart Whether this is at the start of a line
1396     * @param bool $interface Whether this is an interface message
1397     * @param Language|StubUserLang|string|null $language Language code
1398     * @return ParserOutput
1399     */
1400    public function parse( $text, ?PageReference $page = null,
1401        $linestart = true, $interface = false, $language = null
1402    ) {
1403        // phpcs:ignore MediaWiki.Usage.DeprecatedGlobalVariables.Deprecated$wgTitle
1404        global $wgTitle;
1405        if ( !$page ) {
1406            $logger = LoggerFactory::getInstance( 'GlobalTitleFail' );
1407            $logger->info(
1408                __METHOD__ . ' called with no title set.',
1409                [ 'exception' => new RuntimeException ]
1410            );
1411            $page = $wgTitle;
1412        }
1413        // Sometimes $wgTitle isn't set either...
1414        if ( !$page ) {
1415            // It's not uncommon having a null $wgTitle in scripts. See r80898
1416            // Create a ghost title in such case
1417            $page = PageReferenceValue::localReference(
1418                NS_SPECIAL,
1419                'Badtitle/title not set in ' . __METHOD__
1420            );
1421        }
1422
1423        return $this->messageParser->parseWithoutPostprocessing(
1424            $text, $page, $linestart, $interface, $language );
1425    }
1426
1427    /**
1428     * Disable loading of messages from the MediaWiki namespace. Use the
1429     * LocalisationCache only.
1430     *
1431     * @param string $logReason If given, log a message including this reason
1432     */
1433    public function disable( $logReason = '' ) {
1434        if ( $logReason !== '' ) {
1435            $this->logger->debug( "disabling MessageCache: $logReason" );
1436        }
1437        $this->disabled = true;
1438    }
1439
1440    /**
1441     * Re-enable the MessageCache if it was disabled by a call to disable()
1442     */
1443    public function enable() {
1444        $this->logger->debug( "re-enabling MessageCache" );
1445        $this->disabled = false;
1446    }
1447
1448    /**
1449     * Whether DB/cache usage is disabled for determining messages
1450     *
1451     * If so, this typically indicates either:
1452     *   - a) load() failed to find a cached copy nor query the DB
1453     *   - b) we are in a special context or error mode that cannot use the DB
1454     *
1455     * If the DB is ignored, any derived HTML output or cached objects may be wrong.
1456     * To avoid long-term cache pollution, TTLs can be adjusted accordingly.
1457     *
1458     * @return bool
1459     * @since 1.27
1460     */
1461    public function isDisabled() {
1462        return $this->disabled;
1463    }
1464
1465    /**
1466     * Clear all stored messages in global and local cache
1467     *
1468     * Mainly used after a mass rebuild
1469     */
1470    public function clear() {
1471        $langs = $this->languageNameUtils->getLanguageNames();
1472        foreach ( $langs as $code => $_ ) {
1473            $this->wanCache->touchCheckKey( $this->getCheckKey( $code ) );
1474        }
1475        $this->cache->clear();
1476    }
1477
1478    /**
1479     * Given a title string possibly containing a slash, determine the message
1480     * key and language code. No initial letter case normalisation is done.
1481     *
1482     * @param string $key
1483     * @return array
1484     */
1485    public function figureMessage( $key ) {
1486        $pieces = explode( '/', $key );
1487        if ( count( $pieces ) < 2 ) {
1488            return [ $key, $this->contLangCode ];
1489        }
1490
1491        $lang = array_pop( $pieces );
1492        if ( !$this->languageNameUtils->getLanguageName(
1493            $lang,
1494            LanguageNameUtils::AUTONYMS,
1495            LanguageNameUtils::DEFINED
1496        ) ) {
1497            return [ $key, $this->contLangCode ];
1498        }
1499
1500        $message = implode( '/', $pieces );
1501
1502        return [ $message, $lang ];
1503    }
1504
1505    /**
1506     * Get all message keys stored in the message cache for a given language.
1507     * If $code is the content language code, this will return all message keys
1508     * for which MediaWiki:msgkey exists. If $code is another language code, this
1509     * will ONLY return message keys for which MediaWiki:msgkey/$code exists.
1510     *
1511     * @param string $code Language code
1512     * @return string[]|null Array of message keys
1513     */
1514    public function getAllMessageKeys( $code ) {
1515        $this->load( $code );
1516        if ( !$this->cache->has( $code ) ) {
1517            // Apparently load() failed
1518            return null;
1519        }
1520        // Remove administrative keys
1521        $cache = $this->cache->get( $code );
1522        unset( $cache['VERSION'] );
1523        unset( $cache['EXPIRY'] );
1524        unset( $cache['EXCESSIVE'] );
1525        // Remove any !NONEXISTENT keys
1526        $cache = array_diff( $cache, [ '!NONEXISTENT' ] );
1527
1528        // Keys may appear with a capital first letter. lcfirst them.
1529        return array_map( $this->contLang->lcfirst( ... ), array_keys( $cache ) );
1530    }
1531
1532    /**
1533     * Purge message caches when a MediaWiki: page is created, updated, or deleted
1534     *
1535     * @param PageIdentity $page Message page
1536     * @param Content|null $content New content for edit/create, null on deletion
1537     *
1538     * @since 1.29
1539     */
1540    public function updateMessageOverride( $page, ?Content $content = null ) {
1541        // treat null as not existing
1542        $msgText = $this->getMessageTextFromContent( $content ) ?? false;
1543
1544        $this->replace( $page->getDBkey(), $msgText );
1545
1546        if ( $this->contLangConverter->hasVariants() ) {
1547            $this->contLangConverter->updateConversionTable( $page );
1548        }
1549    }
1550
1551    /**
1552     * @param string $code Language code
1553     * @return string WAN cache key usable as a "check key" against language page edits
1554     */
1555    public function getCheckKey( $code ) {
1556        return $this->wanCache->makeKey( 'messages', $code );
1557    }
1558
1559    /**
1560     * @param Content|null $content Content or null if the message page does not exist
1561     * @return string|false|null Returns false if $content is null and null on error
1562     */
1563    private function getMessageTextFromContent( ?Content $content = null ) {
1564        // @TODO: could skip pseudo-messages like js/css here, based on content model
1565        if ( $content && $content->isRedirect() ) {
1566            // Treat redirects as not existing (T376398)
1567            $msgText = false;
1568        } elseif ( $content ) {
1569            // Message page exists...
1570            // XXX: Is this the right way to turn a Content object into a message?
1571            // NOTE: $content is typically either WikitextContent, JavaScriptContent or
1572            //       CssContent.
1573            $msgText = $content->getWikitextForTransclusion();
1574            if ( $msgText === false || $msgText === null ) {
1575                // This might be due to some kind of misconfiguration...
1576                $msgText = null;
1577                $this->logger->warning(
1578                    __METHOD__ . ": message content doesn't provide wikitext "
1579                    . "(content model: " . $content->getModel() . ")" );
1580            }
1581        } else {
1582            // Message page does not exist...
1583            $msgText = false;
1584        }
1585
1586        return $msgText;
1587    }
1588
1589    /**
1590     * @param string $hash Hash for this version of the entire key/value overrides map
1591     * @param string $title Message cache key with the initial uppercase letter
1592     * @return string
1593     */
1594    private function bigMessageCacheKey( $hash, $title ) {
1595        return $this->wanCache->makeKey( 'messages-big', $hash, $title );
1596    }
1597}
1598
1599/** @deprecated class alias since 1.46 */
1600class_alias( MessageCache::class, 'MessageCache' );