58 MainConfigNames::UseDatabaseMessages,
59 MainConfigNames::MaxMsgCacheEntrySize,
60 MainConfigNames::AdaptiveMessageCache,
69 private const FOR_UPDATE = 1;
72 private const WAIT_SEC = 15;
74 private const LOCK_TTL = 30;
80 private const WAN_TTL = IExpiringStore::TTL_DAY;
97 private $systemMessageNames;
102 private $cacheVolatile = [];
111 private $maxEntrySize;
120 private $parserOptions;
127 private $inParser =
false;
132 private $clusterCache;
138 private $contLangCode;
140 private $contLangConverter;
142 private $langFactory;
144 private $localisationCache;
146 private $languageNameUtils;
148 private $languageFallback;
159 $lckey = strtr( $key,
' ',
'_' );
160 if ( $lckey ===
'' ) {
165 if ( ord( $lckey ) < 128 ) {
166 $lckey[0] = strtolower( $lckey[0] );
168 $lckey = MediaWikiServices::getInstance()->getContentLanguage()->lcfirst( $lckey );
195 LoggerInterface $logger,
203 $this->wanCache = $wanCache;
204 $this->clusterCache = $clusterCache;
205 $this->srvCache = $serverCache;
206 $this->contLang = $contLang;
208 $this->contLangCode = $contLang->
getCode();
209 $this->logger = $logger;
210 $this->langFactory = $langFactory;
211 $this->localisationCache = $localisationCache;
212 $this->languageNameUtils = $languageNameUtils;
213 $this->languageFallback = $languageFallback;
214 $this->hookRunner =
new HookRunner( $hookContainer );
217 $this->cache =
new MapCacheLRU( self::MAX_REQUEST_LANGUAGES );
220 $this->
disable = !$options->
get( MainConfigNames::UseDatabaseMessages );
221 $this->maxEntrySize = $options->
get( MainConfigNames::MaxMsgCacheEntrySize );
222 $this->adaptive = $options->
get( MainConfigNames::AdaptiveMessageCache );
226 $this->logger = $logger;
234 private function getParserOptions() {
235 if ( !$this->parserOptions ) {
236 $context = RequestContext::getMain();
237 $user = $context->getUser();
238 if ( !$user->isSafeToLoad() ) {
242 $po = ParserOptions::newFromAnon();
243 $po->setAllowUnsafeRawHtml(
false );
251 $this->parserOptions->setAllowUnsafeRawHtml(
false );
254 return $this->parserOptions;
263 private function getLocalCache( $code ) {
264 $cacheKey = $this->srvCache->makeKey( __CLASS__, $code );
266 return $this->srvCache->get( $cacheKey );
275 private function saveToLocalCache( $code,
$cache ) {
276 $cacheKey = $this->srvCache->makeKey( __CLASS__, $code );
277 $this->srvCache->set( $cacheKey,
$cache );
300 private function load(
string $code, $mode =
null ) {
302 if ( $this->isLanguageLoaded( $code ) && $mode !== self::FOR_UPDATE ) {
307 if ( $this->disable ) {
308 static $shownDisabled =
false;
309 if ( !$shownDisabled ) {
310 $this->logger->debug( __METHOD__ .
': disabled' );
311 $shownDisabled =
true;
318 return $this->loadUnguarded( $code, $mode );
319 }
catch ( Throwable $e ) {
333 private function loadUnguarded( $code, $mode ) {
340 list( $hash, $hashVolatile ) = $this->getValidationHash( $code );
341 $this->cacheVolatile[$code] = $hashVolatile;
342 $volatilityOnlyStaleness =
false;
345 $cache = $this->getLocalCache( $code );
347 $where[] =
'local cache is empty';
348 } elseif ( !isset(
$cache[
'HASH'] ) ||
$cache[
'HASH'] !== $hash ) {
349 $where[] =
'local cache has the wrong hash';
351 } elseif ( $this->isCacheExpired(
$cache ) ) {
352 $where[] =
'local cache is expired';
354 } elseif ( $hashVolatile ) {
356 $where[] =
'local cache validation key is expired/volatile';
358 $volatilityOnlyStaleness =
true;
360 $where[] =
'got from local cache';
361 $this->cache->set( $code,
$cache );
367 $cacheKey = $this->clusterCache->makeKey(
'messages', $code );
368 for ( $failedAttempts = 0; $failedAttempts <= 1; $failedAttempts++ ) {
369 if ( $volatilityOnlyStaleness && $staleCache ) {
374 $where[] =
'global cache is presumed expired';
376 $cache = $this->clusterCache->get( $cacheKey );
378 $where[] =
'global cache is empty';
379 } elseif ( $this->isCacheExpired(
$cache ) ) {
380 $where[] =
'global cache is expired';
382 } elseif ( $hashVolatile ) {
384 $where[] =
'global cache is expired/volatile';
387 $where[] =
'got from global cache';
388 $this->cache->set( $code,
$cache );
389 $this->saveToCaches(
$cache,
'local-only', $code );
398 $loadStatus = $this->loadFromDBWithMainLock( $code, $where, $mode );
399 if ( $loadStatus ===
true ) {
402 } elseif ( $staleCache ) {
404 $where[] =
'using stale cache';
405 $this->cache->set( $code, $staleCache );
408 } elseif ( $failedAttempts > 0 ) {
409 $where[] =
'failed to find cache after waiting';
414 } elseif ( $loadStatus ===
'cantacquire' ) {
417 $where[] =
'waiting for other thread to complete';
418 [ , $ioError ] = $this->getReentrantScopedLock( $code );
420 $where[] =
'failed waiting';
423 $success = $this->loadFromDBWithLocalLock( $code, $where, $mode );
434 $where[] =
'loading FAILED - cache is disabled';
436 $this->cache->set( $code, [] );
437 $this->logger->error( __METHOD__ .
": Failed to load $code" );
442 if ( !$this->isLanguageLoaded( $code ) ) {
443 throw new LogicException(
"Process cache for '$code' should be set by now." );
446 $info = implode(
', ', $where );
447 $this->logger->debug( __METHOD__ .
": Loading $code... $info" );
458 private function loadFromDBWithMainLock( $code, array &$where, $mode =
null ) {
461 $statusKey = $this->clusterCache->makeKey(
'messages', $code,
'status' );
462 $status = $this->clusterCache->get( $statusKey );
463 if ( $status ===
'error' ) {
464 $where[] =
"could not load; method is still globally disabled";
469 $where[] =
'loading from DB';
475 [ $scopedLock ] = $this->getReentrantScopedLock( $code, 0 );
476 if ( !$scopedLock ) {
477 $where[] =
'could not acquire main lock';
478 return 'cantacquire';
481 $cache = $this->loadFromDB( $code, $mode );
482 $this->cache->set( $code,
$cache );
483 $saveSuccess = $this->saveToCaches(
$cache,
'all', $code );
485 if ( !$saveSuccess ) {
500 $this->clusterCache->set( $statusKey,
'error', 60 * 5 );
501 $where[] =
'could not save cache, disabled globally for 5 minutes';
503 $where[] =
"could not save global cache";
516 private function loadFromDBWithLocalLock( $code, array &$where, $mode =
null ) {
518 $where[] =
'loading from DB using local lock';
520 $scopedLock = $this->srvCache->getScopedLock(
521 $this->srvCache->makeKey(
'messages', $code ),
527 $cache = $this->loadFromDB( $code, $mode );
528 $this->cache->set( $code,
$cache );
529 $this->saveToCaches(
$cache,
'local-only', $code );
545 private function loadFromDB( $code, $mode =
null ) {
551 if ( $this->adaptive && $code !== $this->contLangCode ) {
552 if ( !$this->cache->has( $this->contLangCode ) ) {
553 $this->load( $this->contLangCode );
555 $mostused = array_keys( $this->cache->get( $this->contLangCode ) );
556 foreach ( $mostused as $key => $value ) {
557 $mostused[$key] =
"$value/$code";
563 'page_is_redirect' => 0,
566 if ( count( $mostused ) ) {
567 $conds[
'page_title'] = $mostused;
568 } elseif ( $code !== $this->contLangCode ) {
569 $conds[] =
'page_title' .
$dbr->buildLike(
$dbr->anyString(),
'/', $code );
573 $conds[] =
'page_title NOT' .
574 $dbr->buildLike(
$dbr->anyString(),
'/',
$dbr->anyString() );
580 [
'page_title',
'page_latest' ],
581 array_merge( $conds, [
'page_len > ' . intval( $this->maxEntrySize ) ] ),
582 __METHOD__ .
"($code)-big"
584 foreach (
$res as $row ) {
586 if ( $this->adaptive || $this->isMainCacheable( $row->page_title ) ) {
587 $cache[$row->page_title] =
'!TOO BIG';
590 $cache[
'EXCESSIVE'][$row->page_title] = $row->page_latest;
595 $revisionStore = MediaWikiServices::getInstance()->getRevisionStore();
597 $revQuery = $revisionStore->getQueryInfo( [
'page' ] );
610 array_diff(
$revQuery[
'tables'], [
'page' ] )
616 array_merge( $conds, [
617 'page_len <= ' . intval( $this->maxEntrySize ),
618 'page_latest = rev_id'
620 __METHOD__ .
"($code)-small",
626 [ $cacheableRows, $uncacheableRows ] = $this->separateCacheableRows(
$res );
627 $result = $revisionStore->newRevisionsFromBatch( $cacheableRows, [
628 'slots' => [ SlotRecord::MAIN ],
631 $revisions = $result->isOK() ? $result->getValue() : [];
633 foreach ( $cacheableRows as $row ) {
635 $rev = $revisions[$row->rev_id] ??
null;
636 $content = $rev ? $rev->getContent( SlotRecord::MAIN ) :
null;
637 $text = $this->getMessageTextFromContent(
$content );
638 }
catch ( TimeoutException $e ) {
640 }
catch ( Exception $ex ) {
644 if ( !is_string( $text ) ) {
646 $this->logger->error(
648 .
": failed to load message page text for {$row->page_title} ($code)"
651 $entry =
' ' . $text;
653 $cache[$row->page_title] = $entry;
656 foreach ( $uncacheableRows as $row ) {
659 $cache[
'EXCESSIVE'][$row->page_title] = $row->page_latest;
670 unset(
$cache[
'EXCESSIVE'] );
681 private function isLanguageLoaded(
$lang ) {
688 return $this->cache->hasField(
$lang,
'VERSION' );
702 private function isMainCacheable( $name, $code =
null ) {
704 $name = $this->contLang->lcfirst( $name );
707 if ( strpos( $name,
'conversiontable/' ) === 0 ) {
710 $msg = preg_replace(
'/\/[a-z0-9-]{2,}$/',
'', $name );
712 if ( $code ===
null ) {
714 if ( $this->systemMessageNames ===
null ) {
715 $this->systemMessageNames = array_fill_keys(
716 $this->localisationCache->getSubitemList( $this->contLangCode,
'messages' ),
719 return isset( $this->systemMessageNames[$msg] );
722 return $this->localisationCache->getSubitem( $code,
'messages', $msg ) !==
null;
733 private function separateCacheableRows(
$res ) {
734 if ( $this->adaptive ) {
739 $uncacheableRows = [];
740 foreach (
$res as $row ) {
741 if ( $this->isMainCacheable( $row->page_title ) ) {
742 $cacheableRows[] = $row;
744 $uncacheableRows[] = $row;
747 return [ $cacheableRows, $uncacheableRows ];
762 if ( strpos(
$title,
'/' ) !==
false && $code === $this->contLangCode ) {
768 if ( $text ===
false ) {
770 $this->cache->setField( $code,
$title,
'!NONEXISTENT' );
773 $this->cache->setField( $code,
$title,
' ' . $text );
777 DeferredUpdates::addUpdate(
779 DeferredUpdates::PRESEND
789 [ $scopedLock ] = $this->getReentrantScopedLock( $code );
790 if ( !$scopedLock ) {
791 foreach ( $replacements as list(
$title ) ) {
792 $this->logger->error(
793 __METHOD__ .
': could not acquire lock to update {title} ({code})',
794 [
'title' =>
$title,
'code' => $code ] );
802 if ( $this->load( $code, self::FOR_UPDATE ) ) {
803 $cache = $this->cache->get( $code );
806 $cache = $this->loadFromDB( $code, self::FOR_UPDATE );
809 $newTextByTitle = [];
813 $wikiPageFactory = MediaWikiServices::getInstance()->getWikiPageFactory();
814 foreach ( $replacements as list(
$title ) ) {
816 $page->loadPageData( $page::READ_LATEST );
817 $text = $this->getMessageTextFromContent( $page->getContent() );
819 $newTextByTitle[
$title] = $text ??
'';
821 if ( !is_string( $text ) ) {
823 } elseif ( strlen( $text ) > $this->maxEntrySize ) {
825 $newBigTitles[
$title] = $page->getLatest();
836 foreach ( $newBigTitles as
$title => $id ) {
838 $this->wanCache->set(
840 ' ' . $newTextByTitle[
$title],
846 $cache[
'LATEST'] = time();
848 $this->cache->set( $code,
$cache );
853 $this->saveToCaches(
$cache,
'all', $code );
855 ScopedCallback::consume( $scopedLock );
859 $this->wanCache->touchCheckKey( $this->
getCheckKey( $code ) );
862 $blobStore = MediaWikiServices::getInstance()->getResourceLoader()->getMessageBlobStore();
863 foreach ( $replacements as list(
$title, $msg ) ) {
864 $blobStore->updateMessage( $this->contLang->lcfirst( $msg ) );
865 $this->hookRunner->onMessageCacheReplace(
$title, $newTextByTitle[
$title] );
875 private function isCacheExpired(
$cache ) {
876 if ( !isset(
$cache[
'VERSION'] ) || !isset(
$cache[
'EXPIRY'] ) ) {
898 private function saveToCaches( array
$cache, $dest, $code =
false ) {
899 if ( $dest ===
'all' ) {
900 $cacheKey = $this->clusterCache->makeKey(
'messages', $code );
902 $this->setValidationHash( $code,
$cache );
907 $this->saveToLocalCache( $code,
$cache );
918 private function getValidationHash( $code ) {
920 $value = $this->wanCache->get(
921 $this->wanCache->makeKey(
'messages', $code,
'hash',
'v1' ),
923 [ $this->getCheckKey( $code ) ]
927 $hash = $value[
'hash'];
928 if ( ( time() - $value[
'latest'] ) < WANObjectCache::TTL_MINUTE ) {
934 $expired = ( $curTTL < 0 );
942 return [ $hash, $expired ];
955 private function setValidationHash( $code, array
$cache ) {
956 $this->wanCache->set(
957 $this->wanCache->makeKey(
'messages', $code,
'hash',
'v1' ),
960 'latest' =>
$cache[
'LATEST'] ?? 0
962 WANObjectCache::TTL_INDEFINITE
972 private function getReentrantScopedLock( $code, $timeout = self::WAIT_SEC ) {
973 $key = $this->clusterCache->makeKey(
'messages', $code );
975 $watchPoint = $this->clusterCache->watchErrors();
976 $scopedLock = $this->clusterCache->getScopedLock(
982 $error = ( !$scopedLock && $this->clusterCache->getLastError( $watchPoint ) );
984 return [ $scopedLock, $error ];
1019 public function get( $key, $useDB =
true, $langcode =
true ) {
1020 if ( is_int( $key ) ) {
1022 $key = (string)$key;
1023 } elseif ( !is_string( $key ) ) {
1024 throw new TypeError(
'Message key must be a string' );
1025 } elseif ( $key ===
'' ) {
1031 $lckey = self::normalizeKey( $key );
1033 $this->hookRunner->onMessageCache__get( $lckey );
1036 $message = $this->getMessageFromFallbackChain(
1043 if ( $message ===
false ) {
1044 $parts = explode(
'/', $lckey );
1048 if ( count( $parts ) === 2 && $parts[1] !==
'' ) {
1049 $message = $this->localisationCache->getSubitem( $parts[1],
'messages', $parts[0] );
1050 if ( $message ===
null ) {
1057 if ( $message !==
false ) {
1059 $message = str_replace(
1093 private function getMessageFromFallbackChain(
$lang, $lckey, $useDB ) {
1097 $message = $this->getMessageForLang(
$lang, $lckey, $useDB, $alreadyTried );
1098 if ( $message !==
false ) {
1103 $message = $this->getMessageForLang( $this->contLang, $lckey, $useDB, $alreadyTried );
1117 private function getMessageForLang(
$lang, $lckey, $useDB, &$alreadyTried ) {
1118 $langcode =
$lang->getCode();
1122 $uckey = $this->contLang->ucfirst( $lckey );
1124 if ( !isset( $alreadyTried[$langcode] ) ) {
1126 $this->getMessagePageName( $langcode, $uckey ),
1129 if ( $message !==
false ) {
1132 $alreadyTried[$langcode] =
true;
1141 if ( $langcode ===
'qqx' ) {
1146 [ $defaultMessage, $messageSource ] =
1147 $this->localisationCache->getSubitemWithSource( $langcode,
'messages', $lckey );
1148 if ( $messageSource === $langcode ) {
1149 return $defaultMessage;
1154 $fallbackChain = $this->languageFallback->getAll( $langcode );
1156 foreach ( $fallbackChain as $code ) {
1157 if ( isset( $alreadyTried[$code] ) ) {
1163 $this->getMessagePageName( $code, $uckey ), $code );
1165 if ( $message !==
false ) {
1168 $alreadyTried[$code] =
true;
1172 if ( $code === $messageSource ) {
1173 return $defaultMessage;
1178 return $defaultMessage ??
false;
1188 private function getMessagePageName( $langcode, $uckey ) {
1189 if ( $langcode === $this->contLangCode ) {
1193 return "$uckey/$langcode";
1213 $this->load( $code );
1215 $entry = $this->cache->getField( $code,
$title );
1217 if ( $entry !==
null ) {
1219 if ( substr( $entry, 0, 1 ) ===
' ' ) {
1221 return (
string)substr( $entry, 1 );
1222 } elseif ( $entry ===
'!NONEXISTENT' ) {
1228 $entry = $this->loadCachedMessagePageEntry(
1231 $this->cache->getField( $code,
'HASH' )
1235 if ( !$this->isMainCacheable(
$title, $code ) ) {
1239 $entry = $this->loadCachedMessagePageEntry(
1242 $this->cache->getField( $code,
'HASH' )
1245 if ( $entry ===
null || substr( $entry, 0, 1 ) !==
' ' ) {
1249 $this->hookRunner->onMessagesPreLoad(
$title, $message, $code );
1250 if ( $message !==
false ) {
1251 $this->cache->setField( $code,
$title,
' ' . $message );
1253 $this->cache->setField( $code,
$title,
'!NONEXISTENT' );
1260 if ( $entry !==
false && substr( $entry, 0, 1 ) ===
' ' ) {
1261 if ( $this->cacheVolatile[$code] ) {
1263 $this->logger->debug(
1264 __METHOD__ .
': loading volatile key \'{titleKey}\'',
1265 [
'titleKey' =>
$title,
'code' => $code ] );
1267 $this->cache->setField( $code,
$title, $entry );
1270 return (
string)substr( $entry, 1 );
1273 $this->cache->setField( $code,
$title,
'!NONEXISTENT' );
1284 private function loadCachedMessagePageEntry( $dbKey, $code, $hash ) {
1285 $fname = __METHOD__;
1286 return $this->srvCache->getWithSetCallback(
1287 $this->srvCache->makeKey(
'messages-big', $hash, $dbKey ),
1288 BagOStuff::TTL_HOUR,
1289 function () use ( $code, $dbKey, $hash, $fname ) {
1290 return $this->wanCache->getWithSetCallback(
1291 $this->bigMessageCacheKey( $hash, $dbKey ),
1293 function ( $oldValue, &$ttl, &$setOpts ) use ( $dbKey, $code, $fname ) {
1300 $revision = MediaWikiServices::getInstance()
1301 ->getRevisionLookup()
1302 ->getKnownCurrentRevision(
$title );
1306 return '!NONEXISTENT';
1308 $content = $revision->getContent( SlotRecord::MAIN );
1310 $message = $this->getMessageTextFromContent(
$content );
1312 $this->logger->warning(
1313 $fname .
': failed to load page text for \'{titleKey}\'',
1314 [
'titleKey' => $dbKey,
'code' => $code ]
1319 if ( !is_string( $message ) ) {
1324 return '!NONEXISTENT';
1327 return ' ' . $message;
1343 if ( strpos( $message,
'{{' ) ===
false ) {
1347 if ( $this->inParser ) {
1353 $popts = $this->getParserOptions();
1354 $popts->setInterfaceMessage( $interface );
1355 $popts->setTargetLanguage( $language );
1357 $userlang = $popts->setUserLang( $language );
1358 $this->inParser =
true;
1359 $message = $parser->
transformMsg( $message, $popts, $page );
1360 $this->inParser =
false;
1361 $popts->setUserLang( $userlang );
1371 if ( !$this->parser ) {
1372 $parser = MediaWikiServices::getInstance()->getParser();
1374 $this->parser = clone $parser;
1377 return $this->parser;
1389 $interface =
false, $language =
null
1393 if ( $this->inParser ) {
1394 return htmlspecialchars( $text );
1398 $popts = $this->getParserOptions();
1399 $popts->setInterfaceMessage( $interface );
1401 if ( is_string( $language ) ) {
1402 $language = $this->langFactory->getLanguage( $language );
1404 $popts->setTargetLanguage( $language );
1407 $logger = LoggerFactory::getInstance(
'GlobalTitleFail' );
1409 __METHOD__ .
' called with no title set.',
1410 [
'exception' =>
new Exception ]
1418 $page = PageReferenceValue::localReference(
1420 'Badtitle/title not set in ' . __METHOD__
1424 $this->inParser =
true;
1425 $res = $parser->
parse( $text, $page, $popts, $linestart );
1426 $this->inParser =
false;
1452 return $this->disable;
1461 $langs = $this->languageNameUtils->getLanguageNames();
1462 foreach ( array_keys( $langs ) as $code ) {
1463 $this->wanCache->touchCheckKey( $this->
getCheckKey( $code ) );
1465 $this->cache->clear();
1473 $pieces = explode(
'/', $key );
1474 if ( count( $pieces ) < 2 ) {
1475 return [ $key, $this->contLangCode ];
1478 $lang = array_pop( $pieces );
1479 if ( !$this->languageNameUtils->getLanguageName(
1481 LanguageNameUtils::AUTONYMS,
1482 LanguageNameUtils::DEFINED
1484 return [ $key, $this->contLangCode ];
1487 $message = implode(
'/', $pieces );
1489 return [ $message,
$lang ];
1501 $this->load( $code );
1502 if ( !$this->cache->has( $code ) ) {
1507 $cache = $this->cache->get( $code );
1508 unset(
$cache[
'VERSION'] );
1509 unset(
$cache[
'EXPIRY'] );
1510 unset(
$cache[
'EXCESSIVE'] );
1515 return array_map( [ $this->contLang,
'lcfirst' ], array_keys(
$cache ) );
1526 $msgText = $this->getMessageTextFromContent(
$content );
1527 if ( $msgText ===
null ) {
1533 if ( $this->contLangConverter->hasVariants() ) {
1534 $this->contLangConverter->updateConversionTable( $linkTarget );
1543 return $this->wanCache->makeKey(
'messages', $code );
1550 private function getMessageTextFromContent(
Content $content =
null ) {
1558 $msgText =
$content->getWikitextForTransclusion();
1559 if ( $msgText ===
false || $msgText ===
null ) {
1562 $this->logger->warning(
1563 __METHOD__ .
": message content doesn't provide wikitext "
1564 .
"(content model: " .
$content->getModel() .
")" );
1579 private function bigMessageCacheKey( $hash,
$title ) {
1580 return $this->wanCache->makeKey(
'messages-big', $hash,
$title );