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