MediaWiki  master
MessageCache.php
Go to the documentation of this file.
1 <?php
29 
34 define( 'MSG_CACHE_VERSION', 2 );
35 
42 class MessageCache implements LoggerAwareInterface {
43  const FOR_UPDATE = 1; // force message reload
44 
46  const WAIT_SEC = 15;
48  const LOCK_TTL = 30;
49 
54  const WAN_TTL = IExpiringStore::TTL_DAY;
55 
57  private $logger;
58 
64  protected $cache;
65 
71  protected $overridable;
72 
76  protected $cacheVolatile = [];
77 
82  protected $mDisable;
83 
88  protected $mParserOptions;
90  protected $mParser;
91 
95  protected $mInParser = false;
96 
98  protected $wanCache;
100  protected $clusterCache;
102  protected $srvCache;
104  protected $contLang;
106  protected $langFactory;
109 
117  public static function singleton() {
118  return MediaWikiServices::getInstance()->getMessageCache();
119  }
120 
127  public static function normalizeKey( $key ) {
128  $lckey = strtr( $key, ' ', '_' );
129  if ( ord( $lckey ) < 128 ) {
130  $lckey[0] = strtolower( $lckey[0] );
131  } else {
132  $lckey = MediaWikiServices::getInstance()->getContentLanguage()->lcfirst( $lckey );
133  }
134 
135  return $lckey;
136  }
137 
151  public function __construct(
154  BagOStuff $serverCache,
156  LoggerInterface $logger,
157  array $options,
160  ) {
161  $this->wanCache = $wanCache;
162  $this->clusterCache = $clusterCache;
163  $this->srvCache = $serverCache;
164  $this->contLang = $contLang;
165  $this->logger = $logger;
166  $this->langFactory = $langFactory;
167  $this->localisationCache = $localisationCache;
168 
169  $this->cache = new MapCacheLRU( 5 ); // limit size for sanity
170 
171  $this->mDisable = !( $options['useDB'] ?? true );
172  }
173 
174  public function setLogger( LoggerInterface $logger ) {
175  $this->logger = $logger;
176  }
177 
183  function getParserOptions() {
184  global $wgUser;
185 
186  if ( !$this->mParserOptions ) {
187  if ( !$wgUser || !$wgUser->isSafeToLoad() ) {
188  // $wgUser isn't available yet, so don't try to get a
189  // ParserOptions for it. And don't cache this ParserOptions
190  // either.
192  $po->setAllowUnsafeRawHtml( false );
193  $po->setTidy( true );
194  return $po;
195  }
196 
197  $this->mParserOptions = new ParserOptions;
198  // Messages may take parameters that could come
199  // from malicious sources. As a precaution, disable
200  // the <html> parser tag when parsing messages.
201  $this->mParserOptions->setAllowUnsafeRawHtml( false );
202  // For the same reason, tidy the output!
203  $this->mParserOptions->setTidy( true );
204  }
205 
206  return $this->mParserOptions;
207  }
208 
215  protected function getLocalCache( $code ) {
216  $cacheKey = $this->srvCache->makeKey( __CLASS__, $code );
217 
218  return $this->srvCache->get( $cacheKey );
219  }
220 
227  protected function saveToLocalCache( $code, $cache ) {
228  $cacheKey = $this->srvCache->makeKey( __CLASS__, $code );
229  $this->srvCache->set( $cacheKey, $cache );
230  }
231 
253  protected function load( $code, $mode = null ) {
254  if ( !is_string( $code ) ) {
255  throw new InvalidArgumentException( "Missing language code" );
256  }
257 
258  # Don't do double loading...
259  if ( $this->isLanguageLoaded( $code ) && $mode != self::FOR_UPDATE ) {
260  return true;
261  }
262 
263  $this->overridable =
264  array_flip( $this->localisationCache->getSubitemList( $code, 'messages' ) );
265 
266  # 8 lines of code just to say (once) that message cache is disabled
267  if ( $this->mDisable ) {
268  static $shownDisabled = false;
269  if ( !$shownDisabled ) {
270  $this->logger->debug( __METHOD__ . ': disabled' );
271  $shownDisabled = true;
272  }
273 
274  return true;
275  }
276 
277  # Loading code starts
278  $success = false; # Keep track of success
279  $staleCache = false; # a cache array with expired data, or false if none has been loaded
280  $where = []; # Debug info, delayed to avoid spamming debug log too much
281 
282  # Hash of the contents is stored in memcache, to detect if data-center cache
283  # or local cache goes out of date (e.g. due to replace() on some other server)
284  list( $hash, $hashVolatile ) = $this->getValidationHash( $code );
285  $this->cacheVolatile[$code] = $hashVolatile;
286 
287  # Try the local cache and check against the cluster hash key...
288  $cache = $this->getLocalCache( $code );
289  if ( !$cache ) {
290  $where[] = 'local cache is empty';
291  } elseif ( !isset( $cache['HASH'] ) || $cache['HASH'] !== $hash ) {
292  $where[] = 'local cache has the wrong hash';
293  $staleCache = $cache;
294  } elseif ( $this->isCacheExpired( $cache ) ) {
295  $where[] = 'local cache is expired';
296  $staleCache = $cache;
297  } elseif ( $hashVolatile ) {
298  $where[] = 'local cache validation key is expired/volatile';
299  $staleCache = $cache;
300  } else {
301  $where[] = 'got from local cache';
302  $this->cache->set( $code, $cache );
303  $success = true;
304  }
305 
306  if ( !$success ) {
307  $cacheKey = $this->clusterCache->makeKey( 'messages', $code );
308  # Try the global cache. If it is empty, try to acquire a lock. If
309  # the lock can't be acquired, wait for the other thread to finish
310  # and then try the global cache a second time.
311  for ( $failedAttempts = 0; $failedAttempts <= 1; $failedAttempts++ ) {
312  if ( $hashVolatile && $staleCache ) {
313  # Do not bother fetching the whole cache blob to avoid I/O.
314  # Instead, just try to get the non-blocking $statusKey lock
315  # below, and use the local stale value if it was not acquired.
316  $where[] = 'global cache is presumed expired';
317  } else {
318  $cache = $this->clusterCache->get( $cacheKey );
319  if ( !$cache ) {
320  $where[] = 'global cache is empty';
321  } elseif ( $this->isCacheExpired( $cache ) ) {
322  $where[] = 'global cache is expired';
323  $staleCache = $cache;
324  } elseif ( $hashVolatile ) {
325  # DB results are replica DB lag prone until the holdoff TTL passes.
326  # By then, updates should be reflected in loadFromDBWithLock().
327  # One thread regenerates the cache while others use old values.
328  $where[] = 'global cache is expired/volatile';
329  $staleCache = $cache;
330  } else {
331  $where[] = 'got from global cache';
332  $this->cache->set( $code, $cache );
333  $this->saveToCaches( $cache, 'local-only', $code );
334  $success = true;
335  }
336  }
337 
338  if ( $success ) {
339  # Done, no need to retry
340  break;
341  }
342 
343  # We need to call loadFromDB. Limit the concurrency to one process.
344  # This prevents the site from going down when the cache expires.
345  # Note that the DB slam protection lock here is non-blocking.
346  $loadStatus = $this->loadFromDBWithLock( $code, $where, $mode );
347  if ( $loadStatus === true ) {
348  $success = true;
349  break;
350  } elseif ( $staleCache ) {
351  # Use the stale cache while some other thread constructs the new one
352  $where[] = 'using stale cache';
353  $this->cache->set( $code, $staleCache );
354  $success = true;
355  break;
356  } elseif ( $failedAttempts > 0 ) {
357  # Already blocked once, so avoid another lock/unlock cycle.
358  # This case will typically be hit if memcached is down, or if
359  # loadFromDB() takes longer than LOCK_WAIT.
360  $where[] = "could not acquire status key.";
361  break;
362  } elseif ( $loadStatus === 'cantacquire' ) {
363  # Wait for the other thread to finish, then retry. Normally,
364  # the memcached get() will then yield the other thread's result.
365  $where[] = 'waited for other thread to complete';
366  $this->getReentrantScopedLock( $cacheKey );
367  } else {
368  # Disable cache; $loadStatus is 'disabled'
369  break;
370  }
371  }
372  }
373 
374  if ( !$success ) {
375  $where[] = 'loading FAILED - cache is disabled';
376  $this->mDisable = true;
377  $this->cache->set( $code, [] );
378  $this->logger->error( __METHOD__ . ": Failed to load $code" );
379  # This used to throw an exception, but that led to nasty side effects like
380  # the whole wiki being instantly down if the memcached server died
381  }
382 
383  if ( !$this->isLanguageLoaded( $code ) ) { // sanity
384  throw new LogicException( "Process cache for '$code' should be set by now." );
385  }
386 
387  $info = implode( ', ', $where );
388  $this->logger->debug( __METHOD__ . ": Loading $code... $info" );
389 
390  return $success;
391  }
392 
399  protected function loadFromDBWithLock( $code, array &$where, $mode = null ) {
400  # If cache updates on all levels fail, give up on message overrides.
401  # This is to avoid easy site outages; see $saveSuccess comments below.
402  $statusKey = $this->clusterCache->makeKey( 'messages', $code, 'status' );
403  $status = $this->clusterCache->get( $statusKey );
404  if ( $status === 'error' ) {
405  $where[] = "could not load; method is still globally disabled";
406  return 'disabled';
407  }
408 
409  # Now let's regenerate
410  $where[] = 'loading from database';
411 
412  # Lock the cache to prevent conflicting writes.
413  # This lock is non-blocking so stale cache can quickly be used.
414  # Note that load() will call a blocking getReentrantScopedLock()
415  # after this if it really need to wait for any current thread.
416  $cacheKey = $this->clusterCache->makeKey( 'messages', $code );
417  $scopedLock = $this->getReentrantScopedLock( $cacheKey, 0 );
418  if ( !$scopedLock ) {
419  $where[] = 'could not acquire main lock';
420  return 'cantacquire';
421  }
422 
423  $cache = $this->loadFromDB( $code, $mode );
424  $this->cache->set( $code, $cache );
425  $saveSuccess = $this->saveToCaches( $cache, 'all', $code );
426 
427  if ( !$saveSuccess ) {
441  if ( $this->srvCache instanceof EmptyBagOStuff ) {
442  $this->clusterCache->set( $statusKey, 'error', 60 * 5 );
443  $where[] = 'could not save cache, disabled globally for 5 minutes';
444  } else {
445  $where[] = "could not save global cache";
446  }
447  }
448 
449  return true;
450  }
451 
461  protected function loadFromDB( $code, $mode = null ) {
463 
464  // (T164666) The query here performs really poorly on WMF's
465  // contributions replicas. We don't have a way to say "any group except
466  // contributions", so for the moment let's specify 'api'.
467  // @todo: Get rid of this hack.
468  $dbr = wfGetDB( ( $mode == self::FOR_UPDATE ) ? DB_MASTER : DB_REPLICA, 'api' );
469 
470  $cache = [];
471 
472  $mostused = []; // list of "<cased message key>/<code>"
473  if ( $wgAdaptiveMessageCache && $code !== $wgLanguageCode ) {
474  if ( !$this->cache->has( $wgLanguageCode ) ) {
475  $this->load( $wgLanguageCode );
476  }
477  $mostused = array_keys( $this->cache->get( $wgLanguageCode ) );
478  foreach ( $mostused as $key => $value ) {
479  $mostused[$key] = "$value/$code";
480  }
481  }
482 
483  // Get the list of software-defined messages in core/extensions
484  $overridable =
485  array_flip( $this->localisationCache->getSubitemList( $wgLanguageCode, 'messages' ) );
486 
487  // Common conditions
488  $conds = [
489  'page_is_redirect' => 0,
490  'page_namespace' => NS_MEDIAWIKI,
491  ];
492  if ( count( $mostused ) ) {
493  $conds['page_title'] = $mostused;
494  } elseif ( $code !== $wgLanguageCode ) {
495  $conds[] = 'page_title' . $dbr->buildLike( $dbr->anyString(), '/', $code );
496  } else {
497  # Effectively disallows use of '/' character in NS_MEDIAWIKI for uses
498  # other than language code.
499  $conds[] = 'page_title NOT' .
500  $dbr->buildLike( $dbr->anyString(), '/', $dbr->anyString() );
501  }
502 
503  // Set the stubs for oversized software-defined messages in the main cache map
504  $res = $dbr->select(
505  'page',
506  [ 'page_title', 'page_latest' ],
507  array_merge( $conds, [ 'page_len > ' . intval( $wgMaxMsgCacheEntrySize ) ] ),
508  __METHOD__ . "($code)-big"
509  );
510  foreach ( $res as $row ) {
511  // Include entries/stubs for all keys in $mostused in adaptive mode
512  if ( $wgAdaptiveMessageCache || $this->isMainCacheable( $row->page_title, $overridable ) ) {
513  $cache[$row->page_title] = '!TOO BIG';
514  }
515  // At least include revision ID so page changes are reflected in the hash
516  $cache['EXCESSIVE'][$row->page_title] = $row->page_latest;
517  }
518 
519  // Set the text for small software-defined messages in the main cache map
520  $revisionStore = MediaWikiServices::getInstance()->getRevisionStore();
521  $revQuery = $revisionStore->getQueryInfo( [ 'page', 'user' ] );
522 
523  // T231196: MySQL/MariaDB (10.1.37) can sometimes irrationally decide that querying `actor` then
524  // `revision` then `page` is somehow better than starting with `page`. Tell it not to reorder the
525  // query (and also reorder it ourselves because as generated by RevisionStore it'll have
526  // `revision` first rather than `page`).
527  $revQuery['joins']['revision'] = $revQuery['joins']['page'];
528  unset( $revQuery['joins']['page'] );
529  // It isn't actually necesssary to reorder $revQuery['tables'] as Database does the right thing
530  // when join conditions are given for all joins, but GergÅ‘ is wary of relying on that so pull
531  // `page` to the start.
532  $revQuery['tables'] = array_merge(
533  [ 'page' ],
534  array_diff( $revQuery['tables'], [ 'page' ] )
535  );
536 
537  $res = $dbr->select(
538  $revQuery['tables'],
539  $revQuery['fields'],
540  array_merge( $conds, [
541  'page_len <= ' . intval( $wgMaxMsgCacheEntrySize ),
542  'page_latest = rev_id' // get the latest revision only
543  ] ),
544  __METHOD__ . "($code)-small",
545  [ 'STRAIGHT_JOIN' ],
546  $revQuery['joins']
547  );
548  foreach ( $res as $row ) {
549  // Include entries/stubs for all keys in $mostused in adaptive mode
550  if ( $wgAdaptiveMessageCache || $this->isMainCacheable( $row->page_title, $overridable ) ) {
551  try {
552  $rev = $revisionStore->newRevisionFromRow( $row );
553  $content = $rev->getContent( MediaWiki\Revision\SlotRecord::MAIN );
554  $text = $this->getMessageTextFromContent( $content );
555  } catch ( Exception $ex ) {
556  $text = false;
557  }
558 
559  if ( !is_string( $text ) ) {
560  $entry = '!ERROR';
561  $this->logger->error(
562  __METHOD__
563  . ": failed to load message page text for {$row->page_title} ($code)"
564  );
565  } else {
566  $entry = ' ' . $text;
567  }
568  $cache[$row->page_title] = $entry;
569  } else {
570  // T193271: cache object gets too big and slow to generate.
571  // At least include revision ID so page changes are reflected in the hash.
572  $cache['EXCESSIVE'][$row->page_title] = $row->page_latest;
573  }
574  }
575 
576  $cache['VERSION'] = MSG_CACHE_VERSION;
577  ksort( $cache );
578 
579  # Hash for validating local cache (APC). No need to take into account
580  # messages larger than $wgMaxMsgCacheEntrySize, since those are only
581  # stored and fetched from memcache.
582  $cache['HASH'] = md5( serialize( $cache ) );
583  $cache['EXPIRY'] = wfTimestamp( TS_MW, time() + self::WAN_TTL );
584  unset( $cache['EXCESSIVE'] ); // only needed for hash
585 
586  return $cache;
587  }
588 
595  private function isLanguageLoaded( $lang ) {
596  // It is important that this only returns true if the cache was fully
597  // populated by load(), so that callers can assume all cache keys exist.
598  // It is possible for $this->cache to be only patially populated through
599  // methods like MessageCache::replace(), which must not make this method
600  // return true (T208897). And this method must cease to return true
601  // if the language was evicted by MapCacheLRU (T230690).
602  return $this->cache->hasField( $lang, 'VERSION' );
603  }
604 
610  private function isMainCacheable( $name, array $overridable ) {
611  // Convert first letter to lowercase, and strip /code suffix
612  $name = $this->contLang->lcfirst( $name );
613  $msg = preg_replace( '/\/[a-z0-9-]{2,}$/', '', $name );
614  // Include common conversion table pages. This also avoids problems with
615  // Installer::parse() bailing out due to disallowed DB queries (T207979).
616  return ( isset( $overridable[$msg] ) || strpos( $name, 'conversiontable/' ) === 0 );
617  }
618 
625  public function replace( $title, $text ) {
626  global $wgLanguageCode;
627 
628  if ( $this->mDisable ) {
629  return;
630  }
631 
632  list( $msg, $code ) = $this->figureMessage( $title );
633  if ( strpos( $title, '/' ) !== false && $code === $wgLanguageCode ) {
634  // Content language overrides do not use the /<code> suffix
635  return;
636  }
637 
638  // (a) Update the process cache with the new message text
639  if ( $text === false ) {
640  // Page deleted
641  $this->cache->setField( $code, $title, '!NONEXISTENT' );
642  } else {
643  // Ignore $wgMaxMsgCacheEntrySize so the process cache is up to date
644  $this->cache->setField( $code, $title, ' ' . $text );
645  }
646 
647  // (b) Update the shared caches in a deferred update with a fresh DB snapshot
649  new MessageCacheUpdate( $code, $title, $msg ),
651  );
652  }
653 
659  public function refreshAndReplaceInternal( $code, array $replacements ) {
661 
662  // Allow one caller at a time to avoid race conditions
663  $scopedLock = $this->getReentrantScopedLock(
664  $this->clusterCache->makeKey( 'messages', $code )
665  );
666  if ( !$scopedLock ) {
667  foreach ( $replacements as list( $title ) ) {
668  $this->logger->error(
669  __METHOD__ . ': could not acquire lock to update {title} ({code})',
670  [ 'title' => $title, 'code' => $code ] );
671  }
672 
673  return;
674  }
675 
676  // Load the existing cache to update it in the local DC cache.
677  // The other DCs will see a hash mismatch.
678  if ( $this->load( $code, self::FOR_UPDATE ) ) {
679  $cache = $this->cache->get( $code );
680  } else {
681  // Err? Fall back to loading from the database.
682  $cache = $this->loadFromDB( $code, self::FOR_UPDATE );
683  }
684  // Check if individual cache keys should exist and update cache accordingly
685  $newTextByTitle = []; // map of (title => content)
686  $newBigTitles = []; // map of (title => latest revision ID), like EXCESSIVE in loadFromDB()
687  foreach ( $replacements as list( $title ) ) {
689  $page->loadPageData( $page::READ_LATEST );
690  $text = $this->getMessageTextFromContent( $page->getContent() );
691  // Remember the text for the blob store update later on
692  $newTextByTitle[$title] = $text;
693  // Note that if $text is false, then $cache should have a !NONEXISTANT entry
694  if ( !is_string( $text ) ) {
695  $cache[$title] = '!NONEXISTENT';
696  } elseif ( strlen( $text ) > $wgMaxMsgCacheEntrySize ) {
697  $cache[$title] = '!TOO BIG';
698  $newBigTitles[$title] = $page->getLatest();
699  } else {
700  $cache[$title] = ' ' . $text;
701  }
702  }
703  // Update HASH for the new key. Incorporates various administrative keys,
704  // including the old HASH (and thereby the EXCESSIVE value from loadFromDB()
705  // and previous replace() calls), but that doesn't really matter since we
706  // only ever compare it for equality with a copy saved by saveToCaches().
707  $cache['HASH'] = md5( serialize( $cache + [ 'EXCESSIVE' => $newBigTitles ] ) );
708  // Update the too-big WAN cache entries now that we have the new HASH
709  foreach ( $newBigTitles as $title => $id ) {
710  // Match logic of loadCachedMessagePageEntry()
711  $this->wanCache->set(
712  $this->bigMessageCacheKey( $cache['HASH'], $title ),
713  ' ' . $newTextByTitle[$title],
714  self::WAN_TTL
715  );
716  }
717  // Mark this cache as definitely being "latest" (non-volatile) so
718  // load() calls do not try to refresh the cache with replica DB data
719  $cache['LATEST'] = time();
720  // Update the process cache
721  $this->cache->set( $code, $cache );
722  // Pre-emptively update the local datacenter cache so things like edit filter and
723  // blacklist changes are reflected immediately; these often use MediaWiki: pages.
724  // The datacenter handling replace() calls should be the same one handling edits
725  // as they require HTTP POST.
726  $this->saveToCaches( $cache, 'all', $code );
727  // Release the lock now that the cache is saved
728  ScopedCallback::consume( $scopedLock );
729 
730  // Relay the purge. Touching this check key expires cache contents
731  // and local cache (APC) validation hash across all datacenters.
732  $this->wanCache->touchCheckKey( $this->getCheckKey( $code ) );
733 
734  // Purge the messages in the message blob store and fire any hook handlers
735  $blobStore = MediaWikiServices::getInstance()->getResourceLoader()->getMessageBlobStore();
736  foreach ( $replacements as list( $title, $msg ) ) {
737  $blobStore->updateMessage( $this->contLang->lcfirst( $msg ) );
738  Hooks::run( 'MessageCacheReplace', [ $title, $newTextByTitle[$title] ] );
739  }
740  }
741 
748  protected function isCacheExpired( $cache ) {
749  if ( !isset( $cache['VERSION'] ) || !isset( $cache['EXPIRY'] ) ) {
750  return true;
751  }
752  if ( $cache['VERSION'] != MSG_CACHE_VERSION ) {
753  return true;
754  }
755  if ( wfTimestampNow() >= $cache['EXPIRY'] ) {
756  return true;
757  }
758 
759  return false;
760  }
761 
771  protected function saveToCaches( array $cache, $dest, $code = false ) {
772  if ( $dest === 'all' ) {
773  $cacheKey = $this->clusterCache->makeKey( 'messages', $code );
774  $success = $this->clusterCache->set( $cacheKey, $cache );
775  $this->setValidationHash( $code, $cache );
776  } else {
777  $success = true;
778  }
779 
780  $this->saveToLocalCache( $code, $cache );
781 
782  return $success;
783  }
784 
791  protected function getValidationHash( $code ) {
792  $curTTL = null;
793  $value = $this->wanCache->get(
794  $this->wanCache->makeKey( 'messages', $code, 'hash', 'v1' ),
795  $curTTL,
796  [ $this->getCheckKey( $code ) ]
797  );
798 
799  if ( $value ) {
800  $hash = $value['hash'];
801  if ( ( time() - $value['latest'] ) < WANObjectCache::TTL_MINUTE ) {
802  // Cache was recently updated via replace() and should be up-to-date.
803  // That method is only called in the primary datacenter and uses FOR_UPDATE.
804  // Also, it is unlikely that the current datacenter is *now* secondary one.
805  $expired = false;
806  } else {
807  // See if the "check" key was bumped after the hash was generated
808  $expired = ( $curTTL < 0 );
809  }
810  } else {
811  // No hash found at all; cache must regenerate to be safe
812  $hash = false;
813  $expired = true;
814  }
815 
816  return [ $hash, $expired ];
817  }
818 
829  protected function setValidationHash( $code, array $cache ) {
830  $this->wanCache->set(
831  $this->wanCache->makeKey( 'messages', $code, 'hash', 'v1' ),
832  [
833  'hash' => $cache['HASH'],
834  'latest' => $cache['LATEST'] ?? 0
835  ],
837  );
838  }
839 
845  protected function getReentrantScopedLock( $key, $timeout = self::WAIT_SEC ) {
846  return $this->clusterCache->getScopedLock( $key, $timeout, self::LOCK_TTL, __METHOD__ );
847  }
848 
882  function get( $key, $useDB = true, $langcode = true ) {
883  if ( is_int( $key ) ) {
884  // Fix numerical strings that somehow become ints
885  // on their way here
886  $key = (string)$key;
887  } elseif ( !is_string( $key ) ) {
888  throw new MWException( 'Non-string key given' );
889  } elseif ( $key === '' ) {
890  // Shortcut: the empty key is always missing
891  return false;
892  }
893 
894  // Normalise title-case input (with some inlining)
895  $lckey = self::normalizeKey( $key );
896 
897  Hooks::run( 'MessageCache::get', [ &$lckey ] );
898 
899  // Loop through each language in the fallback list until we find something useful
900  $message = $this->getMessageFromFallbackChain(
901  wfGetLangObj( $langcode ),
902  $lckey,
903  !$this->mDisable && $useDB
904  );
905 
906  // If we still have no message, maybe the key was in fact a full key so try that
907  if ( $message === false ) {
908  $parts = explode( '/', $lckey );
909  // We may get calls for things that are http-urls from sidebar
910  // Let's not load nonexistent languages for those
911  // They usually have more than one slash.
912  if ( count( $parts ) == 2 && $parts[1] !== '' ) {
913  $message = $this->localisationCache->getSubitem( $parts[1], 'messages', $parts[0] );
914  if ( $message === null ) {
915  $message = false;
916  }
917  }
918  }
919 
920  // Post-processing if the message exists
921  if ( $message !== false ) {
922  // Fix whitespace
923  $message = str_replace(
924  [
925  # Fix for trailing whitespace, removed by textarea
926  '&#32;',
927  # Fix for NBSP, converted to space by firefox
928  '&nbsp;',
929  '&#160;',
930  '&shy;'
931  ],
932  [
933  ' ',
934  "\u{00A0}",
935  "\u{00A0}",
936  "\u{00AD}"
937  ],
938  $message
939  );
940  }
941 
942  return $message;
943  }
944 
957  protected function getMessageFromFallbackChain( $lang, $lckey, $useDB ) {
958  $alreadyTried = [];
959 
960  // First try the requested language.
961  $message = $this->getMessageForLang( $lang, $lckey, $useDB, $alreadyTried );
962  if ( $message !== false ) {
963  return $message;
964  }
965 
966  // Now try checking the site language.
967  $message = $this->getMessageForLang( $this->contLang, $lckey, $useDB, $alreadyTried );
968  return $message;
969  }
970 
981  private function getMessageForLang( $lang, $lckey, $useDB, &$alreadyTried ) {
982  $langcode = $lang->getCode();
983 
984  // Try checking the database for the requested language
985  if ( $useDB ) {
986  $uckey = $this->contLang->ucfirst( $lckey );
987 
988  if ( !isset( $alreadyTried[$langcode] ) ) {
989  $message = $this->getMsgFromNamespace(
990  $this->getMessagePageName( $langcode, $uckey ),
991  $langcode
992  );
993  if ( $message !== false ) {
994  return $message;
995  }
996  $alreadyTried[$langcode] = true;
997  }
998  } else {
999  $uckey = null;
1000  }
1001 
1002  // Check the CDB cache
1003  $message = $lang->getMessage( $lckey );
1004  if ( $message !== null ) {
1005  return $message;
1006  }
1007 
1008  // Try checking the database for all of the fallback languages
1009  if ( $useDB ) {
1010  $fallbackChain = Language::getFallbacksFor( $langcode );
1011 
1012  foreach ( $fallbackChain as $code ) {
1013  if ( isset( $alreadyTried[$code] ) ) {
1014  continue;
1015  }
1016 
1017  $message = $this->getMsgFromNamespace(
1018  $this->getMessagePageName( $code, $uckey ), $code );
1019 
1020  if ( $message !== false ) {
1021  return $message;
1022  }
1023  $alreadyTried[$code] = true;
1024  }
1025  }
1026 
1027  return false;
1028  }
1029 
1037  private function getMessagePageName( $langcode, $uckey ) {
1038  global $wgLanguageCode;
1039 
1040  if ( $langcode === $wgLanguageCode ) {
1041  // Messages created in the content language will not have the /lang extension
1042  return $uckey;
1043  } else {
1044  return "$uckey/$langcode";
1045  }
1046  }
1047 
1060  public function getMsgFromNamespace( $title, $code ) {
1061  // Load all MediaWiki page definitions into cache. Note that individual keys
1062  // already loaded into cache during this request remain in the cache, which
1063  // includes the value of hook-defined messages.
1064  $this->load( $code );
1065 
1066  $entry = $this->cache->getField( $code, $title );
1067 
1068  if ( $entry !== null ) {
1069  // Message page exists as an override of a software messages
1070  if ( substr( $entry, 0, 1 ) === ' ' ) {
1071  // The message exists and is not '!TOO BIG' or '!ERROR'
1072  return (string)substr( $entry, 1 );
1073  } elseif ( $entry === '!NONEXISTENT' ) {
1074  // The text might be '-' or missing due to some data loss
1075  return false;
1076  }
1077  // Load the message page, utilizing the individual message cache.
1078  // If the page does not exist, there will be no hook handler fallbacks.
1079  $entry = $this->loadCachedMessagePageEntry(
1080  $title,
1081  $code,
1082  $this->cache->getField( $code, 'HASH' )
1083  );
1084  } else {
1085  // Message page either does not exist or does not override a software message
1086  if ( !$this->isMainCacheable( $title, $this->overridable ) ) {
1087  // Message page does not override any software-defined message. A custom
1088  // message might be defined to have content or settings specific to the wiki.
1089  // Load the message page, utilizing the individual message cache as needed.
1090  $entry = $this->loadCachedMessagePageEntry(
1091  $title,
1092  $code,
1093  $this->cache->getField( $code, 'HASH' )
1094  );
1095  }
1096  if ( $entry === null || substr( $entry, 0, 1 ) !== ' ' ) {
1097  // Message does not have a MediaWiki page definition; try hook handlers
1098  $message = false;
1099  Hooks::run( 'MessagesPreLoad', [ $title, &$message, $code ] );
1100  if ( $message !== false ) {
1101  $this->cache->setField( $code, $title, ' ' . $message );
1102  } else {
1103  $this->cache->setField( $code, $title, '!NONEXISTENT' );
1104  }
1105 
1106  return $message;
1107  }
1108  }
1109 
1110  if ( $entry !== false && substr( $entry, 0, 1 ) === ' ' ) {
1111  if ( $this->cacheVolatile[$code] ) {
1112  // Make sure that individual keys respect the WAN cache holdoff period too
1113  $this->logger->debug(
1114  __METHOD__ . ': loading volatile key \'{titleKey}\'',
1115  [ 'titleKey' => $title, 'code' => $code ] );
1116  } else {
1117  $this->cache->setField( $code, $title, $entry );
1118  }
1119  // The message exists, so make sure a string is returned
1120  return (string)substr( $entry, 1 );
1121  }
1122 
1123  $this->cache->setField( $code, $title, '!NONEXISTENT' );
1124 
1125  return false;
1126  }
1127 
1134  private function loadCachedMessagePageEntry( $dbKey, $code, $hash ) {
1135  $fname = __METHOD__;
1136  return $this->srvCache->getWithSetCallback(
1137  $this->srvCache->makeKey( 'messages-big', $hash, $dbKey ),
1139  function () use ( $code, $dbKey, $hash, $fname ) {
1140  return $this->wanCache->getWithSetCallback(
1141  $this->bigMessageCacheKey( $hash, $dbKey ),
1142  self::WAN_TTL,
1143  function ( $oldValue, &$ttl, &$setOpts ) use ( $dbKey, $code, $fname ) {
1144  // Try loading the message from the database
1145  $dbr = wfGetDB( DB_REPLICA );
1146  $setOpts += Database::getCacheSetOptions( $dbr );
1147  // Use newKnownCurrent() to avoid querying revision/user tables
1148  $title = Title::makeTitle( NS_MEDIAWIKI, $dbKey );
1149  $revision = Revision::newKnownCurrent( $dbr, $title );
1150  if ( !$revision ) {
1151  // The wiki doesn't have a local override page. Cache absence with normal TTL.
1152  // When overrides are created, self::replace() takes care of the cache.
1153  return '!NONEXISTENT';
1154  }
1155  $content = $revision->getContent();
1156  if ( $content ) {
1157  $message = $this->getMessageTextFromContent( $content );
1158  } else {
1159  $this->logger->warning(
1160  $fname . ': failed to load page text for \'{titleKey}\'',
1161  [ 'titleKey' => $dbKey, 'code' => $code ]
1162  );
1163  $message = null;
1164  }
1165 
1166  if ( !is_string( $message ) ) {
1167  // Revision failed to load Content, or Content is incompatible with wikitext.
1168  // Possibly a temporary loading failure.
1169  $ttl = 5;
1170 
1171  return '!NONEXISTENT';
1172  }
1173 
1174  return ' ' . $message;
1175  }
1176  );
1177  }
1178  );
1179  }
1180 
1188  public function transform( $message, $interface = false, $language = null, $title = null ) {
1189  // Avoid creating parser if nothing to transform
1190  if ( strpos( $message, '{{' ) === false ) {
1191  return $message;
1192  }
1193 
1194  if ( $this->mInParser ) {
1195  return $message;
1196  }
1197 
1198  $parser = $this->getParser();
1199  if ( $parser ) {
1200  $popts = $this->getParserOptions();
1201  $popts->setInterfaceMessage( $interface );
1202  $popts->setTargetLanguage( $language );
1203 
1204  $userlang = $popts->setUserLang( $language );
1205  $this->mInParser = true;
1206  $message = $parser->transformMsg( $message, $popts, $title );
1207  $this->mInParser = false;
1208  $popts->setUserLang( $userlang );
1209  }
1210 
1211  return $message;
1212  }
1213 
1217  public function getParser() {
1218  global $wgParserConf;
1219  if ( !$this->mParser ) {
1220  $parser = MediaWikiServices::getInstance()->getParser();
1221  # Do some initialisation so that we don't have to do it twice
1222  $parser->firstCallInit();
1223  # Clone it and store it
1224  $class = $wgParserConf['class'];
1225  if ( $class == ParserDiffTest::class ) {
1226  # Uncloneable
1227  // @phan-suppress-next-line PhanTypeMismatchProperty
1228  $this->mParser = new $class( $wgParserConf );
1229  } else {
1230  $this->mParser = clone $parser;
1231  }
1232  }
1233 
1234  return $this->mParser;
1235  }
1236 
1245  public function parse( $text, $title = null, $linestart = true,
1246  $interface = false, $language = null
1247  ) {
1248  global $wgTitle;
1249 
1250  if ( $this->mInParser ) {
1251  return htmlspecialchars( $text );
1252  }
1253 
1254  $parser = $this->getParser();
1255  $popts = $this->getParserOptions();
1256  $popts->setInterfaceMessage( $interface );
1257 
1258  if ( is_string( $language ) ) {
1259  $language = $this->langFactory->getLanguage( $language );
1260  }
1261  $popts->setTargetLanguage( $language );
1262 
1263  if ( !$title || !$title instanceof Title ) {
1264  wfDebugLog( 'GlobalTitleFail', __METHOD__ . ' called by ' .
1265  wfGetAllCallers( 6 ) . ' with no title set.' );
1266  $title = $wgTitle;
1267  }
1268  // Sometimes $wgTitle isn't set either...
1269  if ( !$title ) {
1270  # It's not uncommon having a null $wgTitle in scripts. See r80898
1271  # Create a ghost title in such case
1272  $title = Title::makeTitle( NS_SPECIAL, 'Badtitle/title not set in ' . __METHOD__ );
1273  }
1274 
1275  $this->mInParser = true;
1276  $res = $parser->parse( $text, $title, $popts, $linestart );
1277  $this->mInParser = false;
1278 
1279  return $res;
1280  }
1281 
1282  public function disable() {
1283  $this->mDisable = true;
1284  }
1285 
1286  public function enable() {
1287  $this->mDisable = false;
1288  }
1289 
1302  public function isDisabled() {
1303  return $this->mDisable;
1304  }
1305 
1311  public function clear() {
1312  $langs = Language::fetchLanguageNames( null, 'mw' );
1313  foreach ( array_keys( $langs ) as $code ) {
1314  $this->wanCache->touchCheckKey( $this->getCheckKey( $code ) );
1315  }
1316  $this->cache->clear();
1317  }
1318 
1323  public function figureMessage( $key ) {
1324  global $wgLanguageCode;
1325 
1326  $pieces = explode( '/', $key );
1327  if ( count( $pieces ) < 2 ) {
1328  return [ $key, $wgLanguageCode ];
1329  }
1330 
1331  $lang = array_pop( $pieces );
1332  if ( !Language::fetchLanguageName( $lang, null, 'mw' ) ) {
1333  return [ $key, $wgLanguageCode ];
1334  }
1335 
1336  $message = implode( '/', $pieces );
1337 
1338  return [ $message, $lang ];
1339  }
1340 
1349  public function getAllMessageKeys( $code ) {
1350  $this->load( $code );
1351  if ( !$this->cache->has( $code ) ) {
1352  // Apparently load() failed
1353  return null;
1354  }
1355  // Remove administrative keys
1356  $cache = $this->cache->get( $code );
1357  unset( $cache['VERSION'] );
1358  unset( $cache['EXPIRY'] );
1359  unset( $cache['EXCESSIVE'] );
1360  // Remove any !NONEXISTENT keys
1361  $cache = array_diff( $cache, [ '!NONEXISTENT' ] );
1362 
1363  // Keys may appear with a capital first letter. lcfirst them.
1364  return array_map( [ $this->contLang, 'lcfirst' ], array_keys( $cache ) );
1365  }
1366 
1374  public function updateMessageOverride( Title $title, Content $content = null ) {
1375  $msgText = $this->getMessageTextFromContent( $content );
1376  if ( $msgText === null ) {
1377  $msgText = false; // treat as not existing
1378  }
1379 
1380  $this->replace( $title->getDBkey(), $msgText );
1381 
1382  if ( $this->contLang->hasVariants() ) {
1383  $this->contLang->updateConversionTable( $title );
1384  }
1385  }
1386 
1391  public function getCheckKey( $code ) {
1392  return $this->wanCache->makeKey( 'messages', $code );
1393  }
1394 
1399  private function getMessageTextFromContent( Content $content = null ) {
1400  // @TODO: could skip pseudo-messages like js/css here, based on content model
1401  if ( $content ) {
1402  // Message page exists...
1403  // XXX: Is this the right way to turn a Content object into a message?
1404  // NOTE: $content is typically either WikitextContent, JavaScriptContent or
1405  // CssContent. MessageContent is *not* used for storing messages, it's
1406  // only used for wrapping them when needed.
1407  $msgText = $content->getWikitextForTransclusion();
1408  if ( $msgText === false || $msgText === null ) {
1409  // This might be due to some kind of misconfiguration...
1410  $msgText = null;
1411  $this->logger->warning(
1412  __METHOD__ . ": message content doesn't provide wikitext "
1413  . "(content model: " . $content->getModel() . ")" );
1414  }
1415  } else {
1416  // Message page does not exist...
1417  $msgText = false;
1418  }
1419 
1420  return $msgText;
1421  }
1422 
1428  private function bigMessageCacheKey( $hash, $title ) {
1429  return $this->wanCache->makeKey( 'messages-big', $hash, $title );
1430  }
1431 }
load( $code, $mode=null)
Loads messages from caches or from database in this order: (1) local message cache (if $wgUseLocalMes...
static factory(Title $title)
Create a WikiPage object of the appropriate class for the given title.
Definition: WikiPage.php:142
saveToCaches(array $cache, $dest, $code=false)
Shortcut to update caches.
loadFromDBWithLock( $code, array &$where, $mode=null)
const MSG_CACHE_VERSION
MediaWiki message cache structure version.
static fetchLanguageNames( $inLanguage=self::AS_AUTONYMS, $include='mw')
Get an array of language names, indexed by code.
Definition: Language.php:854
get( $key, $maxAge=INF, $default=null)
Get the value for a key.
figureMessage( $key)
Internationalisation code.
$success
serialize()
transform( $message, $interface=false, $language=null, $title=null)
setValidationHash( $code, array $cache)
Set the md5 used to validate the local disk cache.
if(!isset( $args[0])) $lang
getAllMessageKeys( $code)
Get all message keys stored in the message cache for a given language.
wfGetDB( $db, $groups=[], $wiki=false)
Get a Database object.
LocalisationCache $localisationCache
bigMessageCacheKey( $hash, $title)
saveToLocalCache( $code, $cache)
Save the cache to APC.
getMessageTextFromContent(Content $content=null)
const NS_SPECIAL
Definition: Defines.php:49
__construct(WANObjectCache $wanCache, BagOStuff $clusterCache, BagOStuff $serverCache, Language $contLang, LoggerInterface $logger, array $options, LanguageFactory $langFactory, LocalisationCache $localisationCache)
isMainCacheable( $name, array $overridable)
BagOStuff $clusterCache
Message cache purging and in-place update handler for specific message page changes.
A helper class for throttling authentication attempts.
loadFromDB( $code, $mode=null)
Loads cacheable messages from the database.
Parser $mParser
const DB_MASTER
Definition: defines.php:26
static normalizeKey( $key)
Normalize message key input.
wfGetLangObj( $langcode=false)
Return a Language object from $langcode.
getParserOptions()
ParserOptions is lazy initialised.
refreshAndReplaceInternal( $code, array $replacements)
getMsgFromNamespace( $title, $code)
Get a message from the MediaWiki namespace, with caching.
wfGetAllCallers( $limit=3)
Return a string consisting of callers in the stack.
$wgLanguageCode
Site language code.
wfTimestamp( $outputtype=TS_UNIX, $ts=0)
Get a timestamp string in one of various formats.
MapCacheLRU $cache
Process cache of loaded messages that are defined in MediaWiki namespace.
$wgMaxMsgCacheEntrySize
Maximum entry size in the message cache, in bytes.
loadCachedMessagePageEntry( $dbKey, $code, $hash)
set( $key, $value, $rank=self::RANK_TOP)
Set a key/value pair.
replace( $title, $text)
Updates cache as necessary when message page is changed.
getDBkey()
Get the main part with underscores.
Definition: Title.php:1014
static newKnownCurrent(IDatabase $db, $pageIdOrTitle, $revId=0)
Load a revision based on a known page ID and current revision ID from the DB.
Definition: Revision.php:1124
getValidationHash( $code)
Get the md5 used to validate the local APC cache.
LoggerInterface $logger
$wgAdaptiveMessageCache
Instead of caching everything, only cache those messages which have been customised in the site conte...
array $overridable
Map of (lowercase message key => index) for all software defined messages.
wfTimestampNow()
Convenience function; returns MediaWiki timestamp for the present time.
getLocalCache( $code)
Try to load the cache from APC.
LanguageFactory $langFactory
bool [] $cacheVolatile
Map of (language code => boolean)
$wgParserConf
Parser configuration.
const NS_MEDIAWIKI
Definition: Defines.php:68
static newFromAnon()
Get a ParserOptions object for an anonymous user.
getReentrantScopedLock( $key, $timeout=self::WAIT_SEC)
const WAIT_SEC
How long to wait for memcached locks.
static makeTitle( $ns, $title, $fragment='', $interwiki='')
Create a new Title from a namespace index and a DB key.
Definition: Title.php:584
static fetchLanguageName( $code, $inLanguage=self::AS_AUTONYMS, $include=self::ALL)
Definition: Language.php:868
getMessagePageName( $langcode, $uckey)
Get the message page name for a given language.
getMessageFromFallbackChain( $lang, $lckey, $useDB)
Given a language, try and fetch messages from that language.
const FOR_UPDATE
isDisabled()
Whether DB/cache usage is disabled for determining messages.
updateMessageOverride(Title $title, Content $content=null)
Purge message caches when a MediaWiki: page is created, updated, or deleted.
parse( $text, $title=null, $linestart=true, $interface=false, $language=null)
ParserOptions $mParserOptions
Message cache has its own parser which it uses to transform messages.
static addUpdate(DeferrableUpdate $update, $stage=self::POSTSEND)
Add an update to the deferred list to be run later by execute()
BagOStuff $srvCache
wfDebugLog( $logGroup, $text, $dest='all', array $context=[])
Send a line to a supplementary debug log file, if configured, or main debug log if not...
getMessageForLang( $lang, $lckey, $useDB, &$alreadyTried)
Given a language, try and fetch messages from that language and its fallbacks.
static getFallbacksFor( $code, $mode=self::MESSAGES_FALLBACKS)
Get the ordered list of fallback languages.
Definition: Language.php:4379
$revQuery
Language $contLang
clear()
Clear all stored messages in global and local cache.
getCheckKey( $code)
const DB_REPLICA
Definition: defines.php:25
$content
Definition: router.php:78
if(isset( $_SERVER['PATH_INFO']) && $_SERVER['PATH_INFO'] !='') $wgTitle
Definition: api.php:53
bool $mDisable
Should mean that database cannot be used, but check.
isLanguageLoaded( $lang)
Whether the language was loaded and its data is still in the process cache.
WANObjectCache $wanCache
return true
Definition: router.php:92
const LOCK_TTL
How long memcached locks last.
static singleton()
Get the singleton instance of this class.
isCacheExpired( $cache)
Is the given cache array expired due to time passing or a version change?
static run( $event, array $args=[], $deprecatedVersion=null)
Call hook functions defined in Hooks::register and $wgHooks.
Definition: Hooks.php:200
setLogger(LoggerInterface $logger)