33 use Psr\Log\LoggerAwareInterface;
34 use Psr\Log\LoggerInterface;
36 use Wikimedia\ScopedCallback;
42 define(
'MSG_CACHE_VERSION', 2 );
62 private const WAN_TTL = IExpiringStore::TTL_DAY;
134 return MediaWikiServices::getInstance()->getMessageCache();
144 $lckey = strtr( $key,
' ',
'_' );
145 if ( ord( $lckey ) < 128 ) {
146 $lckey[0] = strtolower( $lckey[0] );
148 $lckey = MediaWikiServices::getInstance()->getContentLanguage()->lcfirst( $lckey );
187 $this->srvCache = $serverCache;
195 $this->hookRunner =
new HookRunner( $hookContainer );
199 $this->mDisable = !( $options[
'useDB'] ??
true );
214 if ( !$this->mParserOptions ) {
215 if ( !$wgUser || !$wgUser->isSafeToLoad() ) {
220 $po->setAllowUnsafeRawHtml(
false );
228 $this->mParserOptions->setAllowUnsafeRawHtml(
false );
241 $cacheKey = $this->srvCache->makeKey( __CLASS__, $code );
243 return $this->srvCache->get( $cacheKey );
253 $cacheKey = $this->srvCache->makeKey( __CLASS__, $code );
254 $this->srvCache->set( $cacheKey,
$cache );
278 protected function load( $code, $mode =
null ) {
279 if ( !is_string( $code ) ) {
280 throw new InvalidArgumentException(
"Missing language code" );
283 # Don't do double loading...
289 array_flip( $this->localisationCache->getSubitemList( $code,
'messages' ) );
291 # 8 lines of code just to say (once) that message cache is disabled
292 if ( $this->mDisable ) {
293 static $shownDisabled =
false;
294 if ( !$shownDisabled ) {
295 $this->logger->debug( __METHOD__ .
': disabled' );
296 $shownDisabled =
true;
302 # Loading code starts
303 $success =
false; # Keep track of success
304 $staleCache =
false; # a cache array with expired data, or
false if none has been loaded
305 $where = []; # Debug info, delayed to avoid spamming debug log too much
307 # Hash of the contents is stored in memcache, to detect if data-center cache
308 # or local cache goes out of date (e.g. due to replace() on some other server)
310 $this->cacheVolatile[$code] = $hashVolatile;
312 # Try the local cache and check against the cluster hash key...
315 $where[] =
'local cache is empty';
316 } elseif ( !isset(
$cache[
'HASH'] ) ||
$cache[
'HASH'] !== $hash ) {
317 $where[] =
'local cache has the wrong hash';
320 $where[] =
'local cache is expired';
322 } elseif ( $hashVolatile ) {
323 $where[] =
'local cache validation key is expired/volatile';
326 $where[] =
'got from local cache';
332 $cacheKey = $this->clusterCache->makeKey(
'messages', $code );
333 # Try the global cache. If it is empty, try to acquire a lock. If
334 # the lock can't be acquired, wait for the other thread to finish
335 # and then try the global cache a second time.
336 for ( $failedAttempts = 0; $failedAttempts <= 1; $failedAttempts++ ) {
337 if ( $hashVolatile && $staleCache ) {
338 # Do not bother fetching the whole cache blob to avoid I/O.
339 # Instead, just try to get the non-blocking $statusKey lock
340 # below, and use the local stale value if it was not acquired.
341 $where[] =
'global cache is presumed expired';
343 $cache = $this->clusterCache->
get( $cacheKey );
345 $where[] =
'global cache is empty';
347 $where[] =
'global cache is expired';
349 } elseif ( $hashVolatile ) {
350 # DB results are replica DB lag prone until the holdoff TTL passes.
351 # By then, updates should be reflected in loadFromDBWithLock().
352 # One thread regenerates the cache while others use old values.
353 $where[] =
'global cache is expired/volatile';
356 $where[] =
'got from global cache';
364 # Done, no need to retry
368 # We need to call loadFromDB. Limit the concurrency to one process.
369 # This prevents the site from going down when the cache expires.
370 # Note that the DB slam protection lock here is non-blocking.
372 if ( $loadStatus ===
true ) {
375 } elseif ( $staleCache ) {
376 # Use the stale cache while some other thread constructs the new one
377 $where[] =
'using stale cache';
378 $this->cache->set( $code, $staleCache );
381 } elseif ( $failedAttempts > 0 ) {
382 # Already blocked once, so avoid another lock/unlock cycle.
383 # This case will typically be hit if memcached is down, or if
384 # loadFromDB() takes longer than LOCK_WAIT.
385 $where[] =
"could not acquire status key.";
387 } elseif ( $loadStatus ===
'cantacquire' ) {
388 # Wait for the other thread to finish, then retry. Normally,
389 # the memcached get() will then yield the other thread's result.
390 $where[] =
'waited for other thread to complete';
393 # Disable cache; $loadStatus is 'disabled'
400 $where[] =
'loading FAILED - cache is disabled';
401 $this->mDisable =
true;
402 $this->cache->set( $code, [] );
403 $this->logger->error( __METHOD__ .
": Failed to load $code" );
404 # This used to throw an exception, but that led to nasty side effects like
405 # the whole wiki being instantly down if the memcached server died
409 throw new LogicException(
"Process cache for '$code' should be set by now." );
412 $info = implode(
', ', $where );
413 $this->logger->debug( __METHOD__ .
": Loading $code... $info" );
425 # If cache updates on all levels fail, give up on message overrides.
426 # This is to avoid easy site outages; see $saveSuccess comments below.
427 $statusKey = $this->clusterCache->makeKey(
'messages', $code,
'status' );
428 $status = $this->clusterCache->get( $statusKey );
429 if ( $status ===
'error' ) {
430 $where[] =
"could not load; method is still globally disabled";
434 # Now let's regenerate
435 $where[] =
'loading from database';
437 # Lock the cache to prevent conflicting writes.
438 # This lock is non-blocking so stale cache can quickly be used.
439 # Note that load() will call a blocking getReentrantScopedLock()
440 # after this if it really need to wait for any current thread.
441 $cacheKey = $this->clusterCache->makeKey(
'messages', $code );
443 if ( !$scopedLock ) {
444 $where[] =
'could not acquire main lock';
445 return 'cantacquire';
449 $this->cache->set( $code,
$cache );
452 if ( !$saveSuccess ) {
467 $this->clusterCache->set( $statusKey,
'error', 60 * 5 );
468 $where[] =
'could not save cache, disabled globally for 5 minutes';
470 $where[] =
"could not save global cache";
500 $this->
load( $wgLanguageCode );
503 foreach ( $mostused as $key => $value ) {
504 $mostused[$key] =
"$value/$code";
510 array_flip( $this->localisationCache->getSubitemList(
$wgLanguageCode,
'messages' ) );
514 'page_is_redirect' => 0,
517 if ( count( $mostused ) ) {
518 $conds[
'page_title'] = $mostused;
520 $conds[] =
'page_title' .
$dbr->buildLike(
$dbr->anyString(),
'/', $code );
522 # Effectively disallows use of '/' character in NS_MEDIAWIKI for uses
523 # other than language code.
524 $conds[] =
'page_title NOT' .
525 $dbr->buildLike(
$dbr->anyString(),
'/',
$dbr->anyString() );
531 [
'page_title',
'page_latest' ],
533 __METHOD__ .
"($code)-big"
535 foreach (
$res as $row ) {
538 $cache[$row->page_title] =
'!TOO BIG';
541 $cache[
'EXCESSIVE'][$row->page_title] = $row->page_latest;
546 $revisionStore = MediaWikiServices::getInstance()->getRevisionStore();
548 $revQuery = $revisionStore->getQueryInfo( [
'page' ] );
561 array_diff(
$revQuery[
'tables'], [
'page' ] )
567 array_merge( $conds, [
569 'page_latest = rev_id'
571 __METHOD__ .
"($code)-small",
575 $result = $revisionStore->newRevisionsFromBatch(
$res, [
576 'slots' => [ SlotRecord::MAIN ],
579 $revisions = $result->isOK() ? $result->getValue() : [];
580 foreach (
$res as $row ) {
584 $rev = $revisions[$row->rev_id] ??
null;
585 $content = $rev ? $rev->getContent( SlotRecord::MAIN ) :
null;
587 }
catch ( Exception $ex ) {
591 if ( !is_string( $text ) ) {
593 $this->logger->error(
595 .
": failed to load message page text for {$row->page_title} ($code)"
598 $entry =
' ' . $text;
600 $cache[$row->page_title] = $entry;
604 $cache[
'EXCESSIVE'][$row->page_title] = $row->page_latest;
611 # Hash for validating local cache (APC). No need to take into account
612 # messages larger than $wgMaxMsgCacheEntrySize, since those are only
613 # stored and fetched from memcache.
616 unset(
$cache[
'EXCESSIVE'] );
634 return $this->cache->hasField(
$lang,
'VERSION' );
644 $name = $this->contLang->lcfirst( $name );
645 $msg = preg_replace(
'/\/[a-z0-9-]{2,}$/',
'', $name );
648 return ( isset(
$overridable[$msg] ) || strpos( $name,
'conversiontable/' ) === 0 );
660 if ( $this->mDisable ) {
671 if ( $text ===
false ) {
673 $this->cache->setField( $code,
$title,
'!NONEXISTENT' );
676 $this->cache->setField( $code,
$title,
' ' . $text );
682 DeferredUpdates::PRESEND
696 $this->clusterCache->makeKey(
'messages', $code )
698 if ( !$scopedLock ) {
699 foreach ( $replacements as list(
$title ) ) {
700 $this->logger->error(
701 __METHOD__ .
': could not acquire lock to update {title} ({code})',
702 [
'title' =>
$title,
'code' => $code ] );
710 if ( $this->
load( $code, self::FOR_UPDATE ) ) {
717 $newTextByTitle = [];
719 foreach ( $replacements as list(
$title ) ) {
721 $page->loadPageData( $page::READ_LATEST );
724 $newTextByTitle[
$title] = $text;
726 if ( !is_string( $text ) ) {
730 $newBigTitles[
$title] = $page->getLatest();
741 foreach ( $newBigTitles as
$title => $id ) {
743 $this->wanCache->set(
745 ' ' . $newTextByTitle[
$title],
751 $cache[
'LATEST'] = time();
753 $this->cache->set( $code,
$cache );
760 ScopedCallback::consume( $scopedLock );
764 $this->wanCache->touchCheckKey( $this->
getCheckKey( $code ) );
767 $blobStore = MediaWikiServices::getInstance()->getResourceLoader()->getMessageBlobStore();
768 foreach ( $replacements as list(
$title, $msg ) ) {
769 $blobStore->updateMessage( $this->contLang->lcfirst( $msg ) );
770 $this->hookRunner->onMessageCacheReplace(
$title, $newTextByTitle[
$title] );
781 if ( !isset(
$cache[
'VERSION'] ) || !isset(
$cache[
'EXPIRY'] ) ) {
804 if ( $dest ===
'all' ) {
805 $cacheKey = $this->clusterCache->makeKey(
'messages', $code );
825 $value = $this->wanCache->get(
826 $this->wanCache->makeKey(
'messages', $code,
'hash',
'v1' ),
828 [ $this->getCheckKey( $code ) ]
832 $hash = $value[
'hash'];
833 if ( ( time() - $value[
'latest'] ) < WANObjectCache::TTL_MINUTE ) {
840 $expired = ( $curTTL < 0 );
848 return [ $hash, $expired ];
862 $this->wanCache->set(
863 $this->wanCache->makeKey(
'messages', $code,
'hash',
'v1' ),
866 'latest' =>
$cache[
'LATEST'] ?? 0
868 WANObjectCache::TTL_INDEFINITE
878 return $this->clusterCache->getScopedLock( $key, $timeout, self::LOCK_TTL, __METHOD__ );
914 public function get( $key, $useDB =
true, $langcode =
true ) {
915 if ( is_int( $key ) ) {
919 } elseif ( !is_string( $key ) ) {
921 } elseif ( $key ===
'' ) {
929 $this->hookRunner->onMessageCache__get( $lckey );
935 !$this->mDisable && $useDB
939 if ( $message ===
false ) {
940 $parts = explode(
'/', $lckey );
944 if ( count( $parts ) == 2 && $parts[1] !==
'' ) {
945 $message = $this->localisationCache->getSubitem( $parts[1],
'messages', $parts[0] );
946 if ( $message ===
null ) {
953 if ( $message !==
false ) {
955 $message = str_replace(
957 # Fix
for trailing whitespace, removed by textarea
959 # Fix
for NBSP, converted to space by firefox
994 if ( $message !==
false ) {
999 $message = $this->
getMessageForLang( $this->contLang, $lckey, $useDB, $alreadyTried );
1014 $langcode =
$lang->getCode();
1018 $uckey = $this->contLang->ucfirst( $lckey );
1020 if ( !isset( $alreadyTried[$langcode] ) ) {
1025 if ( $message !==
false ) {
1028 $alreadyTried[$langcode] =
true;
1035 $message =
$lang->getMessage( $lckey );
1036 if ( $message !==
null ) {
1042 $fallbackChain = $this->languageFallback->getAll( $langcode );
1044 foreach ( $fallbackChain as $code ) {
1045 if ( isset( $alreadyTried[$code] ) ) {
1052 if ( $message !==
false ) {
1055 $alreadyTried[$code] =
true;
1076 return "$uckey/$langcode";
1096 $this->
load( $code );
1098 $entry = $this->cache->getField( $code,
$title );
1100 if ( $entry !==
null ) {
1102 if ( substr( $entry, 0, 1 ) ===
' ' ) {
1104 return (
string)substr( $entry, 1 );
1105 } elseif ( $entry ===
'!NONEXISTENT' ) {
1114 $this->cache->getField( $code,
'HASH' )
1125 $this->cache->getField( $code,
'HASH' )
1128 if ( $entry ===
null || substr( $entry, 0, 1 ) !==
' ' ) {
1131 $this->hookRunner->onMessagesPreLoad(
$title, $message, $code );
1132 if ( $message !==
false ) {
1133 $this->cache->setField( $code,
$title,
' ' . $message );
1135 $this->cache->setField( $code,
$title,
'!NONEXISTENT' );
1142 if ( $entry !==
false && substr( $entry, 0, 1 ) ===
' ' ) {
1143 if ( $this->cacheVolatile[$code] ) {
1145 $this->logger->debug(
1146 __METHOD__ .
': loading volatile key \'{titleKey}\'',
1147 [
'titleKey' =>
$title,
'code' => $code ] );
1149 $this->cache->setField( $code,
$title, $entry );
1152 return (
string)substr( $entry, 1 );
1155 $this->cache->setField( $code,
$title,
'!NONEXISTENT' );
1167 $fname = __METHOD__;
1168 return $this->srvCache->getWithSetCallback(
1169 $this->srvCache->makeKey(
'messages-big', $hash, $dbKey ),
1170 BagOStuff::TTL_HOUR,
1171 function () use ( $code, $dbKey, $hash, $fname ) {
1172 return $this->wanCache->getWithSetCallback(
1175 function ( $oldValue, &$ttl, &$setOpts ) use ( $dbKey, $code, $fname ) {
1182 $revision = MediaWikiServices::getInstance()
1183 ->getRevisionLookup()
1184 ->getKnownCurrentRevision(
$title );
1188 return '!NONEXISTENT';
1190 $content = $revision->getContent( SlotRecord::MAIN );
1194 $this->logger->warning(
1195 $fname .
': failed to load page text for \'{titleKey}\'',
1196 [
'titleKey' => $dbKey,
'code' => $code ]
1201 if ( !is_string( $message ) ) {
1206 return '!NONEXISTENT';
1209 return ' ' . $message;
1223 public function transform( $message, $interface =
false, $language =
null,
$title =
null ) {
1225 if ( strpos( $message,
'{{' ) ===
false ) {
1229 if ( $this->mInParser ) {
1236 $popts->setInterfaceMessage( $interface );
1237 $popts->setTargetLanguage( $language );
1239 $userlang = $popts->setUserLang( $language );
1240 $this->mInParser =
true;
1241 $message = $parser->transformMsg( $message, $popts,
$title );
1242 $this->mInParser =
false;
1243 $popts->setUserLang( $userlang );
1253 if ( !$this->mParser ) {
1254 $parser = MediaWikiServices::getInstance()->getParser();
1255 # Do some initialisation so that we don't have to do it twice
1256 $parser->firstCallInit();
1257 # Clone it and store it
1258 $this->mParser = clone $parser;
1273 $interface =
false, $language =
null
1277 if ( $this->mInParser ) {
1278 return htmlspecialchars( $text );
1283 $popts->setInterfaceMessage( $interface );
1285 if ( is_string( $language ) ) {
1286 $language = $this->langFactory->getLanguage( $language );
1288 $popts->setTargetLanguage( $language );
1291 $logger = LoggerFactory::getInstance(
'GlobalTitleFail' );
1293 __METHOD__ .
' called with no title set.',
1294 [
'exception' =>
new Exception ]
1300 # It's not uncommon having a null $wgTitle in scripts. See r80898
1301 # Create a ghost title in such case
1305 $this->mInParser =
true;
1306 $res = $parser->parse( $text,
$title, $popts, $linestart );
1307 $this->mInParser =
false;
1313 $this->mDisable =
true;
1317 $this->mDisable =
false;
1342 $langs = $this->languageNameUtils->getLanguageNames(
null,
'mw' );
1343 foreach ( array_keys( $langs ) as $code ) {
1344 $this->wanCache->touchCheckKey( $this->
getCheckKey( $code ) );
1346 $this->cache->clear();
1356 $pieces = explode(
'/', $key );
1357 if ( count( $pieces ) < 2 ) {
1361 $lang = array_pop( $pieces );
1362 if ( !$this->languageNameUtils->getLanguageName(
$lang,
null,
'mw' ) ) {
1366 $message = implode(
'/', $pieces );
1368 return [ $message,
$lang ];
1380 $this->
load( $code );
1381 if ( !$this->cache->has( $code ) ) {
1387 unset(
$cache[
'VERSION'] );
1388 unset(
$cache[
'EXPIRY'] );
1389 unset(
$cache[
'EXCESSIVE'] );
1394 return array_map( [ $this->contLang,
'lcfirst' ], array_keys(
$cache ) );
1406 if ( $msgText ===
null ) {
1412 if ( $this->contLangConverter->hasVariants() ) {
1413 $this->contLangConverter->updateConversionTable( $linkTarget );
1422 return $this->wanCache->makeKey(
'messages', $code );
1437 $msgText =
$content->getWikitextForTransclusion();
1438 if ( $msgText ===
false || $msgText ===
null ) {
1441 $this->logger->warning(
1442 __METHOD__ .
": message content doesn't provide wikitext "
1443 .
"(content model: " .
$content->getModel() .
")" );
1459 return $this->wanCache->makeKey(
'messages-big', $hash,
$title );