24 use Wikimedia\ScopedCallback;
32 define(
'MSG_CACHE_VERSION', 2 );
115 return MediaWikiServices::getInstance()->getMessageCache();
125 $lckey = strtr( $key,
' ',
'_' );
126 if ( ord( $lckey ) < 128 ) {
127 $lckey[0] = strtolower( $lckey[0] );
129 $lckey = MediaWikiServices::getInstance()->getContentLanguage()->lcfirst( $lckey );
151 $this->srvCache = $serverCache;
155 $this->mDisable = !$useDB;
156 $this->contLang =
$contLang ?? MediaWikiServices::getInstance()->getContentLanguage();
167 if ( !$this->mParserOptions ) {
168 if ( !$wgUser || !$wgUser->isSafeToLoad() ) {
173 $po->setAllowUnsafeRawHtml(
false );
174 $po->setTidy(
true );
184 $this->mParserOptions->setTidy(
true );
197 $cacheKey = $this->srvCache->makeKey( __CLASS__, $code );
199 return $this->srvCache->get( $cacheKey );
209 $cacheKey = $this->srvCache->makeKey( __CLASS__, $code );
210 $this->srvCache->set( $cacheKey,
$cache );
234 protected function load( $code, $mode =
null ) {
235 if ( !is_string( $code ) ) {
236 throw new InvalidArgumentException(
"Missing language code" );
239 # Don't do double loading...
240 if ( isset( $this->loadedLanguages[$code] ) && $mode != self::FOR_UPDATE ) {
246 # 8 lines of code just to say (once) that message cache is disabled
247 if ( $this->mDisable ) {
248 static $shownDisabled =
false;
249 if ( !$shownDisabled ) {
250 wfDebug( __METHOD__ .
": disabled\n" );
251 $shownDisabled =
true;
257 # Loading code starts
258 $success =
false; # Keep track of success
259 $staleCache =
false; # a cache array with expired data, or
false if none has been loaded
260 $where = []; # Debug info, delayed to avoid spamming debug log too much
262 # Hash of the contents is stored in memcache, to detect if data-center cache
263 # or local cache goes out of date (e.g. due to replace() on some other server)
265 $this->cacheVolatile[$code] = $hashVolatile;
267 # Try the local cache and check against the cluster hash key...
270 $where[] =
'local cache is empty';
271 } elseif ( !isset(
$cache[
'HASH'] ) ||
$cache[
'HASH'] !== $hash ) {
272 $where[] =
'local cache has the wrong hash';
275 $where[] =
'local cache is expired';
277 } elseif ( $hashVolatile ) {
278 $where[] =
'local cache validation key is expired/volatile';
281 $where[] =
'got from local cache';
287 $cacheKey = $this->clusterCache->makeKey(
'messages', $code );
288 # Try the global cache. If it is empty, try to acquire a lock. If
289 # the lock can't be acquired, wait for the other thread to finish
290 # and then try the global cache a second time.
291 for ( $failedAttempts = 0; $failedAttempts <= 1; $failedAttempts++ ) {
292 if ( $hashVolatile && $staleCache ) {
293 # Do not bother fetching the whole cache blob to avoid I/O.
294 # Instead, just try to get the non-blocking $statusKey lock
295 # below, and use the local stale value if it was not acquired.
296 $where[] =
'global cache is presumed expired';
298 $cache = $this->clusterCache->
get( $cacheKey );
300 $where[] =
'global cache is empty';
302 $where[] =
'global cache is expired';
304 } elseif ( $hashVolatile ) {
305 # DB results are replica DB lag prone until the holdoff TTL passes.
306 # By then, updates should be reflected in loadFromDBWithLock().
307 # One thread regenerates the cache while others use old values.
308 $where[] =
'global cache is expired/volatile';
311 $where[] =
'got from global cache';
319 # Done, no need to retry
323 # We need to call loadFromDB. Limit the concurrency to one process.
324 # This prevents the site from going down when the cache expires.
325 # Note that the DB slam protection lock here is non-blocking.
327 if ( $loadStatus ===
true ) {
330 } elseif ( $staleCache ) {
331 # Use the stale cache while some other thread constructs the new one
332 $where[] =
'using stale cache';
333 $this->cache->set( $code, $staleCache );
336 } elseif ( $failedAttempts > 0 ) {
337 # Already blocked once, so avoid another lock/unlock cycle.
338 # This case will typically be hit if memcached is down, or if
339 # loadFromDB() takes longer than LOCK_WAIT.
340 $where[] =
"could not acquire status key.";
342 } elseif ( $loadStatus ===
'cantacquire' ) {
343 # Wait for the other thread to finish, then retry. Normally,
344 # the memcached get() will then yield the other thread's result.
345 $where[] =
'waited for other thread to complete';
348 # Disable cache; $loadStatus is 'disabled'
355 $where[] =
'loading FAILED - cache is disabled';
356 $this->mDisable =
true;
357 $this->cache->set( $code, [] );
358 wfDebugLog(
'MessageCacheError', __METHOD__ .
": Failed to load $code\n" );
359 # This used to throw an exception, but that led to nasty side effects like
360 # the whole wiki being instantly down if the memcached server died
362 # All good, just record the success
363 $this->loadedLanguages[$code] =
true;
366 if ( !$this->cache->has( $code ) ) {
367 throw new LogicException(
"Process cache for '$code' should be set by now." );
370 $info = implode(
', ', $where );
371 wfDebugLog(
'MessageCache', __METHOD__ .
": Loading $code... $info\n" );
383 # If cache updates on all levels fail, give up on message overrides.
384 # This is to avoid easy site outages; see $saveSuccess comments below.
385 $statusKey = $this->clusterCache->makeKey(
'messages', $code,
'status' );
386 $status = $this->clusterCache->get( $statusKey );
388 $where[] =
"could not load; method is still globally disabled";
392 # Now let's regenerate
393 $where[] =
'loading from database';
395 # Lock the cache to prevent conflicting writes.
396 # This lock is non-blocking so stale cache can quickly be used.
397 # Note that load() will call a blocking getReentrantScopedLock()
398 # after this if it really need to wait for any current thread.
399 $cacheKey = $this->clusterCache->makeKey(
'messages', $code );
401 if ( !$scopedLock ) {
402 $where[] =
'could not acquire main lock';
403 return 'cantacquire';
407 $this->cache->set( $code,
$cache );
410 if ( !$saveSuccess ) {
425 $this->clusterCache->set( $statusKey,
'error', 60 * 5 );
426 $where[] =
'could not save cache, disabled globally for 5 minutes';
428 $where[] =
"could not save global cache";
458 $this->
load( $wgLanguageCode );
461 foreach ( $mostused as $key => $value ) {
462 $mostused[$key] =
"$value/$code";
471 'page_is_redirect' => 0,
474 if ( count( $mostused ) ) {
475 $conds[
'page_title'] = $mostused;
477 $conds[] =
'page_title' .
$dbr->buildLike(
$dbr->anyString(),
'/', $code );
479 # Effectively disallows use of '/' character in NS_MEDIAWIKI for uses
480 # other than language code.
481 $conds[] =
'page_title NOT' .
482 $dbr->buildLike(
$dbr->anyString(),
'/',
$dbr->anyString() );
488 [
'page_title',
'page_latest' ],
490 __METHOD__ .
"($code)-big"
492 foreach (
$res as $row ) {
495 $cache[$row->page_title] =
'!TOO BIG';
498 $cache[
'EXCESSIVE'][$row->page_title] = $row->page_latest;
502 $revisionStore = MediaWikiServices::getInstance()->getRevisionStore();
503 $revQuery = $revisionStore->getQueryInfo( [
'page',
'user' ] );
516 array_diff(
$revQuery[
'tables'], [
'page' ] )
522 array_merge( $conds, [
524 'page_latest = rev_id'
526 __METHOD__ .
"($code)-small",
530 foreach (
$res as $row ) {
534 $rev = $revisionStore->newRevisionFromRow( $row );
537 }
catch ( Exception $ex ) {
541 if ( !is_string( $text ) ) {
546 .
": failed to load message page text for {$row->page_title} ($code)"
549 $entry =
' ' . $text;
551 $cache[$row->page_title] = $entry;
555 $cache[
'EXCESSIVE'][$row->page_title] = $row->page_latest;
562 # Hash for validating local cache (APC). No need to take into account
563 # messages larger than $wgMaxMsgCacheEntrySize, since those are only
564 # stored and fetched from memcache.
567 unset(
$cache[
'EXCESSIVE'] );
579 $name = $this->contLang->lcfirst( $name );
580 $msg = preg_replace(
'/\/[a-z0-9-]{2,}$/',
'', $name );
583 return ( isset(
$overridable[$msg] ) || strpos( $name,
'conversiontable/' ) === 0 );
595 if ( $this->mDisable ) {
606 if ( $text ===
false ) {
608 $this->cache->setField( $code,
$title,
'!NONEXISTENT' );
611 $this->cache->setField( $code,
$title,
' ' . $text );
631 $this->clusterCache->makeKey(
'messages', $code )
633 if ( !$scopedLock ) {
634 foreach ( $replacements as list(
$title ) ) {
635 LoggerFactory::getInstance(
'MessageCache' )->error(
636 __METHOD__ .
': could not acquire lock to update {title} ({code})',
637 [
'title' =>
$title,
'code' => $code ] );
645 if ( $this->
load( $code, self::FOR_UPDATE ) ) {
652 $newTextByTitle = [];
654 foreach ( $replacements as list(
$title ) ) {
656 $page->loadPageData( $page::READ_LATEST );
659 $newTextByTitle[
$title] = $text;
661 if ( !is_string( $text ) ) {
665 $newBigTitles[
$title] = $page->getLatest();
676 foreach ( $newBigTitles as
$title => $id ) {
678 $this->wanCache->set(
680 ' ' . $newTextByTitle[
$title],
686 $cache[
'LATEST'] = time();
688 $this->cache->set( $code,
$cache );
695 ScopedCallback::consume( $scopedLock );
699 $this->wanCache->touchCheckKey( $this->
getCheckKey( $code ) );
702 $blobStore = MediaWikiServices::getInstance()->getResourceLoader()->getMessageBlobStore();
703 foreach ( $replacements as list(
$title, $msg ) ) {
704 $blobStore->updateMessage( $this->contLang->lcfirst( $msg ) );
716 if ( !isset(
$cache[
'VERSION'] ) || !isset(
$cache[
'EXPIRY'] ) ) {
739 if ( $dest ===
'all' ) {
740 $cacheKey = $this->clusterCache->makeKey(
'messages', $code );
760 $value = $this->wanCache->get(
761 $this->wanCache->makeKey(
'messages', $code,
'hash',
'v1' ),
767 $hash = $value[
'hash'];
775 $expired = ( $curTTL < 0 );
783 return [ $hash, $expired ];
797 $this->wanCache->set(
798 $this->wanCache->makeKey(
'messages', $code,
'hash',
'v1' ),
801 'latest' =>
$cache[
'LATEST'] ?? 0
813 return $this->clusterCache->getScopedLock( $key, $timeout, self::LOCK_TTL, __METHOD__ );
849 function get( $key, $useDB =
true, $langcode =
true ) {
850 if ( is_int( $key ) ) {
854 } elseif ( !is_string( $key ) ) {
856 } elseif ( $key ===
'' ) {
864 Hooks::run(
'MessageCache::get', [ &$lckey ] );
870 !$this->mDisable && $useDB
874 if ( $message ===
false ) {
875 $parts = explode(
'/', $lckey );
879 if ( count( $parts ) == 2 && $parts[1] !==
'' ) {
881 if ( $message ===
null ) {
888 if ( $message !==
false ) {
890 $message = str_replace(
892 # Fix
for trailing whitespace, removed by textarea
894 # Fix
for NBSP, converted to space by firefox
929 if ( $message !==
false ) {
934 $message = $this->
getMessageForLang( $this->contLang, $lckey, $useDB, $alreadyTried );
949 $langcode =
$lang->getCode();
953 $uckey = $this->contLang->ucfirst( $lckey );
955 if ( !isset( $alreadyTried[$langcode] ) ) {
960 if ( $message !==
false ) {
963 $alreadyTried[$langcode] =
true;
970 $message =
$lang->getMessage( $lckey );
971 if ( $message !==
null ) {
979 foreach ( $fallbackChain as $code ) {
980 if ( isset( $alreadyTried[$code] ) ) {
987 if ( $message !==
false ) {
990 $alreadyTried[$code] =
true;
1011 return "$uckey/$langcode";
1031 $this->
load( $code );
1033 $entry = $this->cache->getField( $code,
$title );
1035 if ( $entry !==
null ) {
1037 if ( substr( $entry, 0, 1 ) ===
' ' ) {
1039 return (
string)substr( $entry, 1 );
1040 } elseif ( $entry ===
'!NONEXISTENT' ) {
1049 $this->cache->getField( $code,
'HASH' )
1060 $this->cache->getField( $code,
'HASH' )
1063 if ( $entry ===
null || substr( $entry, 0, 1 ) !==
' ' ) {
1067 if ( $message !==
false ) {
1068 $this->cache->setField( $code,
$title,
' ' . $message );
1070 $this->cache->setField( $code,
$title,
'!NONEXISTENT' );
1077 if ( $entry !==
false && substr( $entry, 0, 1 ) ===
' ' ) {
1078 if ( $this->cacheVolatile[$code] ) {
1080 LoggerFactory::getInstance(
'MessageCache' )->debug(
1081 __METHOD__ .
': loading volatile key \'{titleKey}\'',
1082 [
'titleKey' =>
$title,
'code' => $code ] );
1084 $this->cache->setField( $code,
$title, $entry );
1087 return (
string)substr( $entry, 1 );
1090 $this->cache->setField( $code,
$title,
'!NONEXISTENT' );
1102 $fname = __METHOD__;
1103 return $this->srvCache->getWithSetCallback(
1104 $this->srvCache->makeKey(
'messages-big', $hash, $dbKey ),
1106 function () use ( $code, $dbKey, $hash, $fname ) {
1107 return $this->wanCache->getWithSetCallback(
1110 function ( $oldValue, &$ttl, &$setOpts ) use ( $dbKey, $code, $fname ) {
1113 $setOpts += Database::getCacheSetOptions(
$dbr );
1120 return '!NONEXISTENT';
1122 $content = $revision->getContent();
1126 LoggerFactory::getInstance(
'MessageCache' )->warning(
1127 $fname .
': failed to load page text for \'{titleKey}\'',
1128 [
'titleKey' => $dbKey,
'code' => $code ]
1133 if ( !is_string( $message ) ) {
1138 return '!NONEXISTENT';
1141 return ' ' . $message;
1155 public function transform( $message, $interface =
false, $language =
null,
$title =
null ) {
1157 if ( strpos( $message,
'{{' ) ===
false ) {
1161 if ( $this->mInParser ) {
1168 $popts->setInterfaceMessage( $interface );
1169 $popts->setTargetLanguage( $language );
1171 $userlang = $popts->setUserLang( $language );
1172 $this->mInParser =
true;
1173 $message = $parser->transformMsg( $message, $popts,
$title );
1174 $this->mInParser =
false;
1175 $popts->setUserLang( $userlang );
1186 if ( !$this->mParser ) {
1187 $parser = MediaWikiServices::getInstance()->getParser();
1188 # Do some initialisation so that we don't have to do it twice
1189 $parser->firstCallInit();
1190 # Clone it and store it
1192 if ( $class == ParserDiffTest::class ) {
1197 $this->mParser = clone $parser;
1213 $interface =
false, $language =
null
1217 if ( $this->mInParser ) {
1218 return htmlspecialchars( $text );
1223 $popts->setInterfaceMessage( $interface );
1225 if ( is_string( $language ) ) {
1228 $popts->setTargetLanguage( $language );
1231 wfDebugLog(
'GlobalTitleFail', __METHOD__ .
' called by ' .
1237 # It's not uncommon having a null $wgTitle in scripts. See r80898
1238 # Create a ghost title in such case
1242 $this->mInParser =
true;
1243 $res = $parser->parse( $text,
$title, $popts, $linestart );
1244 $this->mInParser =
false;
1250 $this->mDisable =
true;
1254 $this->mDisable =
false;
1280 foreach ( array_keys( $langs ) as $code ) {
1281 $this->wanCache->touchCheckKey( $this->
getCheckKey( $code ) );
1283 $this->cache->clear();
1284 $this->loadedLanguages = [];
1294 $pieces = explode(
'/', $key );
1295 if ( count( $pieces ) < 2 ) {
1299 $lang = array_pop( $pieces );
1304 $message = implode(
'/', $pieces );
1306 return [ $message,
$lang ];
1318 $this->
load( $code );
1319 if ( !$this->cache->has( $code ) ) {
1325 unset(
$cache[
'VERSION'] );
1326 unset(
$cache[
'EXPIRY'] );
1327 unset(
$cache[
'EXCESSIVE'] );
1332 return array_map( [ $this->contLang,
'lcfirst' ], array_keys(
$cache ) );
1344 if ( $msgText ===
null ) {
1350 if ( $this->contLang->hasVariants() ) {
1351 $this->contLang->updateConversionTable(
$title );
1360 return $this->wanCache->makeKey(
'messages', $code );
1375 $msgText =
$content->getWikitextForTransclusion();
1376 if ( $msgText ===
false || $msgText ===
null ) {
1379 LoggerFactory::getInstance(
'MessageCache' )->warning(
1380 __METHOD__ .
": message content doesn't provide wikitext "
1381 .
"(content model: " .
$content->getModel() .
")" );
1397 return $this->wanCache->makeKey(
'messages-big', $hash,
$title );