Completed
Branch master (939199)
by
unknown
39:35
created

includes/cache/MessageCache.php (1 issue)

Upgrade to new PHP Analysis Engine

These results are based on our legacy PHP analysis, consider migrating to our new PHP analysis engine instead. Learn more

1
<?php
2
/**
3
 * Localisation messages cache.
4
 *
5
 * This program is free software; you can redistribute it and/or modify
6
 * it under the terms of the GNU General Public License as published by
7
 * the Free Software Foundation; either version 2 of the License, or
8
 * (at your option) any later version.
9
 *
10
 * This program is distributed in the hope that it will be useful,
11
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
 * GNU General Public License for more details.
14
 *
15
 * You should have received a copy of the GNU General Public License along
16
 * with this program; if not, write to the Free Software Foundation, Inc.,
17
 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18
 * http://www.gnu.org/copyleft/gpl.html
19
 *
20
 * @file
21
 * @ingroup Cache
22
 */
23
use MediaWiki\MediaWikiServices;
24
use Wikimedia\ScopedCallback;
0 ignored issues
show
This use statement conflicts with another class in this namespace, ScopedCallback.

Let’s assume that you have a directory layout like this:

.
|-- OtherDir
|   |-- Bar.php
|   `-- Foo.php
`-- SomeDir
    `-- Foo.php

and let’s assume the following content of Bar.php:

// Bar.php
namespace OtherDir;

use SomeDir\Foo; // This now conflicts the class OtherDir\Foo

If both files OtherDir/Foo.php and SomeDir/Foo.php are loaded in the same runtime, you will see a PHP error such as the following:

PHP Fatal error:  Cannot use SomeDir\Foo as Foo because the name is already in use in OtherDir/Foo.php

However, as OtherDir/Foo.php does not necessarily have to be loaded and the error is only triggered if it is loaded before OtherDir/Bar.php, this problem might go unnoticed for a while. In order to prevent this error from surfacing, you must import the namespace with a different alias:

// Bar.php
namespace OtherDir;

use SomeDir\Foo as SomeDirFoo; // There is no conflict anymore.
Loading history...
25
26
/**
27
 * MediaWiki message cache structure version.
28
 * Bump this whenever the message cache format has changed.
29
 */
30
define( 'MSG_CACHE_VERSION', 2 );
31
32
/**
33
 * Message cache
34
 * Performs various MediaWiki namespace-related functions
35
 * @ingroup Cache
36
 */
37
class MessageCache {
38
	const FOR_UPDATE = 1; // force message reload
39
40
	/** How long to wait for memcached locks */
41
	const WAIT_SEC = 15;
42
	/** How long memcached locks last */
43
	const LOCK_TTL = 30;
44
45
	/**
46
	 * Process local cache of loaded messages that are defined in
47
	 * MediaWiki namespace. First array level is a language code,
48
	 * second level is message key and the values are either message
49
	 * content prefixed with space, or !NONEXISTENT for negative
50
	 * caching.
51
	 * @var array $mCache
52
	 */
53
	protected $mCache;
54
55
	/**
56
	 * Should mean that database cannot be used, but check
57
	 * @var bool $mDisable
58
	 */
59
	protected $mDisable;
60
61
	/**
62
	 * Lifetime for cache, used by object caching.
63
	 * Set on construction, see __construct().
64
	 */
65
	protected $mExpiry;
66
67
	/**
68
	 * Message cache has its own parser which it uses to transform
69
	 * messages.
70
	 */
71
	protected $mParserOptions, $mParser;
72
73
	/**
74
	 * Variable for tracking which variables are already loaded
75
	 * @var array $mLoadedLanguages
76
	 */
77
	protected $mLoadedLanguages = [];
78
79
	/**
80
	 * @var bool $mInParser
81
	 */
82
	protected $mInParser = false;
83
84
	/** @var BagOStuff */
85
	protected $mMemc;
86
	/** @var WANObjectCache */
87
	protected $wanCache;
88
89
	/**
90
	 * Singleton instance
91
	 *
92
	 * @var MessageCache $instance
93
	 */
94
	private static $instance;
95
96
	/**
97
	 * Get the signleton instance of this class
98
	 *
99
	 * @since 1.18
100
	 * @return MessageCache
101
	 */
102
	public static function singleton() {
103
		if ( self::$instance === null ) {
104
			global $wgUseDatabaseMessages, $wgMsgCacheExpiry;
105
			self::$instance = new self(
106
				wfGetMessageCacheStorage(),
107
				$wgUseDatabaseMessages,
108
				$wgMsgCacheExpiry
109
			);
110
		}
111
112
		return self::$instance;
113
	}
114
115
	/**
116
	 * Destroy the singleton instance
117
	 *
118
	 * @since 1.18
119
	 */
120
	public static function destroyInstance() {
121
		self::$instance = null;
122
	}
123
124
	/**
125
	 * Normalize message key input
126
	 *
127
	 * @param string $key Input message key to be normalized
128
	 * @return string Normalized message key
129
	 */
130
	public static function normalizeKey( $key ) {
131
		global $wgContLang;
132
		$lckey = strtr( $key, ' ', '_' );
133
		if ( ord( $lckey ) < 128 ) {
134
			$lckey[0] = strtolower( $lckey[0] );
135
		} else {
136
			$lckey = $wgContLang->lcfirst( $lckey );
137
		}
138
139
		return $lckey;
140
	}
141
142
	/**
143
	 * @param BagOStuff $memCached A cache instance. If none, fall back to CACHE_NONE.
144
	 * @param bool $useDB
145
	 * @param int $expiry Lifetime for cache. @see $mExpiry.
146
	 */
147
	function __construct( $memCached, $useDB, $expiry ) {
148
		global $wgUseLocalMessageCache;
149
150
		if ( !$memCached ) {
151
			$memCached = wfGetCache( CACHE_NONE );
152
		}
153
154
		$this->mMemc = $memCached;
155
		$this->mDisable = !$useDB;
156
		$this->mExpiry = $expiry;
157
158
		if ( $wgUseLocalMessageCache ) {
159
			$this->localCache = MediaWikiServices::getInstance()->getLocalServerObjectCache();
160
		} else {
161
			$this->localCache = new EmptyBagOStuff();
162
		}
163
164
		$this->wanCache = ObjectCache::getMainWANInstance();
165
	}
166
167
	/**
168
	 * ParserOptions is lazy initialised.
169
	 *
170
	 * @return ParserOptions
171
	 */
172
	function getParserOptions() {
173
		global $wgUser;
174
175
		if ( !$this->mParserOptions ) {
176
			if ( !$wgUser->isSafeToLoad() ) {
177
				// $wgUser isn't unstubbable yet, so don't try to get a
178
				// ParserOptions for it. And don't cache this ParserOptions
179
				// either.
180
				$po = ParserOptions::newFromAnon();
181
				$po->setEditSection( false );
182
				return $po;
183
			}
184
185
			$this->mParserOptions = new ParserOptions;
186
			$this->mParserOptions->setEditSection( false );
187
		}
188
189
		return $this->mParserOptions;
190
	}
191
192
	/**
193
	 * Try to load the cache from APC.
194
	 *
195
	 * @param string $code Optional language code, see documenation of load().
196
	 * @return array|bool The cache array, or false if not in cache.
197
	 */
198
	protected function getLocalCache( $code ) {
199
		$cacheKey = wfMemcKey( __CLASS__, $code );
200
201
		return $this->localCache->get( $cacheKey );
202
	}
203
204
	/**
205
	 * Save the cache to APC.
206
	 *
207
	 * @param string $code
208
	 * @param array $cache The cache array
209
	 */
210
	protected function saveToLocalCache( $code, $cache ) {
211
		$cacheKey = wfMemcKey( __CLASS__, $code );
212
		$this->localCache->set( $cacheKey, $cache );
213
	}
214
215
	/**
216
	 * Loads messages from caches or from database in this order:
217
	 * (1) local message cache (if $wgUseLocalMessageCache is enabled)
218
	 * (2) memcached
219
	 * (3) from the database.
220
	 *
221
	 * When succesfully loading from (2) or (3), all higher level caches are
222
	 * updated for the newest version.
223
	 *
224
	 * Nothing is loaded if member variable mDisable is true, either manually
225
	 * set by calling code or if message loading fails (is this possible?).
226
	 *
227
	 * Returns true if cache is already populated or it was succesfully populated,
228
	 * or false if populating empty cache fails. Also returns true if MessageCache
229
	 * is disabled.
230
	 *
231
	 * @param string $code Language to which load messages
232
	 * @param integer $mode Use MessageCache::FOR_UPDATE to skip process cache [optional]
233
	 * @throws MWException
234
	 * @return bool
235
	 */
236
	protected function load( $code, $mode = null ) {
237
		if ( !is_string( $code ) ) {
238
			throw new InvalidArgumentException( "Missing language code" );
239
		}
240
241
		# Don't do double loading...
242
		if ( isset( $this->mLoadedLanguages[$code] ) && $mode != self::FOR_UPDATE ) {
243
			return true;
244
		}
245
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;
252
			}
253
254
			return true;
255
		}
256
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
261
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)
264
		list( $hash, $hashVolatile ) = $this->getValidationHash( $code );
265
266
		# Try the local cache and check against the cluster hash key...
267
		$cache = $this->getLocalCache( $code );
268
		if ( !$cache ) {
269
			$where[] = 'local cache is empty';
270
		} elseif ( !isset( $cache['HASH'] ) || $cache['HASH'] !== $hash ) {
271
			$where[] = 'local cache has the wrong hash';
272
			$staleCache = $cache;
273
		} elseif ( $this->isCacheExpired( $cache ) ) {
274
			$where[] = 'local cache is expired';
275
			$staleCache = $cache;
276
		} elseif ( $hashVolatile ) {
277
			$where[] = 'local cache validation key is expired/volatile';
278
			$staleCache = $cache;
279
		} else {
280
			$where[] = 'got from local cache';
281
			$success = true;
282
			$this->mCache[$code] = $cache;
283
		}
284
285
		if ( !$success ) {
286
			$cacheKey = wfMemcKey( 'messages', $code ); # Key in memc for messages
287
			# Try the global cache. If it is empty, try to acquire a lock. If
288
			# the lock can't be acquired, wait for the other thread to finish
289
			# and then try the global cache a second time.
290
			for ( $failedAttempts = 0; $failedAttempts <= 1; $failedAttempts++ ) {
291
				if ( $hashVolatile && $staleCache ) {
292
					# Do not bother fetching the whole cache blob to avoid I/O.
293
					# Instead, just try to get the non-blocking $statusKey lock
294
					# below, and use the local stale value if it was not acquired.
295
					$where[] = 'global cache is presumed expired';
296
				} else {
297
					$cache = $this->mMemc->get( $cacheKey );
298
					if ( !$cache ) {
299
						$where[] = 'global cache is empty';
300
					} elseif ( $this->isCacheExpired( $cache ) ) {
301
						$where[] = 'global cache is expired';
302
						$staleCache = $cache;
303
					} elseif ( $hashVolatile ) {
304
						# DB results are replica DB lag prone until the holdoff TTL passes.
305
						# By then, updates should be reflected in loadFromDBWithLock().
306
						# One thread renerates the cache while others use old values.
307
						$where[] = 'global cache is expired/volatile';
308
						$staleCache = $cache;
309
					} else {
310
						$where[] = 'got from global cache';
311
						$this->mCache[$code] = $cache;
312
						$this->saveToCaches( $cache, 'local-only', $code );
313
						$success = true;
314
					}
315
				}
316
317
				if ( $success ) {
318
					# Done, no need to retry
319
					break;
320
				}
321
322
				# We need to call loadFromDB. Limit the concurrency to one process.
323
				# This prevents the site from going down when the cache expires.
324
				# Note that the DB slam protection lock here is non-blocking.
325
				$loadStatus = $this->loadFromDBWithLock( $code, $where, $mode );
326
				if ( $loadStatus === true ) {
327
					$success = true;
328
					break;
329
				} elseif ( $staleCache ) {
330
					# Use the stale cache while some other thread constructs the new one
331
					$where[] = 'using stale cache';
332
					$this->mCache[$code] = $staleCache;
333
					$success = true;
334
					break;
335
				} elseif ( $failedAttempts > 0 ) {
336
					# Already blocked once, so avoid another lock/unlock cycle.
337
					# This case will typically be hit if memcached is down, or if
338
					# loadFromDB() takes longer than LOCK_WAIT.
339
					$where[] = "could not acquire status key.";
340
					break;
341
				} elseif ( $loadStatus === 'cantacquire' ) {
342
					# Wait for the other thread to finish, then retry. Normally,
343
					# the memcached get() will then yeild the other thread's result.
344
					$where[] = 'waited for other thread to complete';
345
					$this->getReentrantScopedLock( $cacheKey );
346
				} else {
347
					# Disable cache; $loadStatus is 'disabled'
348
					break;
349
				}
350
			}
351
		}
352
353
		if ( !$success ) {
354
			$where[] = 'loading FAILED - cache is disabled';
355
			$this->mDisable = true;
356
			$this->mCache = false;
357
			wfDebugLog( 'MessageCacheError', __METHOD__ . ": Failed to load $code\n" );
358
			# This used to throw an exception, but that led to nasty side effects like
359
			# the whole wiki being instantly down if the memcached server died
360
		} else {
361
			# All good, just record the success
362
			$this->mLoadedLanguages[$code] = true;
363
		}
364
365
		$info = implode( ', ', $where );
366
		wfDebugLog( 'MessageCache', __METHOD__ . ": Loading $code... $info\n" );
367
368
		return $success;
369
	}
370
371
	/**
372
	 * @param string $code
373
	 * @param array $where List of wfDebug() comments
374
	 * @param integer $mode Use MessageCache::FOR_UPDATE to use DB_MASTER
375
	 * @return bool|string True on success or one of ("cantacquire", "disabled")
376
	 */
377
	protected function loadFromDBWithLock( $code, array &$where, $mode = null ) {
378
		global $wgUseLocalMessageCache;
379
380
		# If cache updates on all levels fail, give up on message overrides.
381
		# This is to avoid easy site outages; see $saveSuccess comments below.
382
		$statusKey = wfMemcKey( 'messages', $code, 'status' );
383
		$status = $this->mMemc->get( $statusKey );
384
		if ( $status === 'error' ) {
385
			$where[] = "could not load; method is still globally disabled";
386
			return 'disabled';
387
		}
388
389
		# Now let's regenerate
390
		$where[] = 'loading from database';
391
392
		# Lock the cache to prevent conflicting writes.
393
		# This lock is non-blocking so stale cache can quickly be used.
394
		# Note that load() will call a blocking getReentrantScopedLock()
395
		# after this if it really need to wait for any current thread.
396
		$cacheKey = wfMemcKey( 'messages', $code );
397
		$scopedLock = $this->getReentrantScopedLock( $cacheKey, 0 );
398
		if ( !$scopedLock ) {
399
			$where[] = 'could not acquire main lock';
400
			return 'cantacquire';
401
		}
402
403
		$cache = $this->loadFromDB( $code, $mode );
404
		$this->mCache[$code] = $cache;
405
		$saveSuccess = $this->saveToCaches( $cache, 'all', $code );
406
407
		if ( !$saveSuccess ) {
408
			/**
409
			 * Cache save has failed.
410
			 *
411
			 * There are two main scenarios where this could be a problem:
412
			 * - The cache is more than the maximum size (typically 1MB compressed).
413
			 * - Memcached has no space remaining in the relevant slab class. This is
414
			 *   unlikely with recent versions of memcached.
415
			 *
416
			 * Either way, if there is a local cache, nothing bad will happen. If there
417
			 * is no local cache, disabling the message cache for all requests avoids
418
			 * incurring a loadFromDB() overhead on every request, and thus saves the
419
			 * wiki from complete downtime under moderate traffic conditions.
420
			 */
421
			if ( !$wgUseLocalMessageCache ) {
422
				$this->mMemc->set( $statusKey, 'error', 60 * 5 );
423
				$where[] = 'could not save cache, disabled globally for 5 minutes';
424
			} else {
425
				$where[] = "could not save global cache";
426
			}
427
		}
428
429
		return true;
430
	}
431
432
	/**
433
	 * Loads cacheable messages from the database. Messages bigger than
434
	 * $wgMaxMsgCacheEntrySize are assigned a special value, and are loaded
435
	 * on-demand from the database later.
436
	 *
437
	 * @param string $code Language code
438
	 * @param integer $mode Use MessageCache::FOR_UPDATE to skip process cache
439
	 * @return array Loaded messages for storing in caches
440
	 */
441
	function loadFromDB( $code, $mode = null ) {
442
		global $wgMaxMsgCacheEntrySize, $wgLanguageCode, $wgAdaptiveMessageCache;
443
444
		$dbr = wfGetDB( ( $mode == self::FOR_UPDATE ) ? DB_MASTER : DB_REPLICA );
445
446
		$cache = [];
447
448
		# Common conditions
449
		$conds = [
450
			'page_is_redirect' => 0,
451
			'page_namespace' => NS_MEDIAWIKI,
452
		];
453
454
		$mostused = [];
455
		if ( $wgAdaptiveMessageCache && $code !== $wgLanguageCode ) {
456
			if ( !isset( $this->mCache[$wgLanguageCode] ) ) {
457
				$this->load( $wgLanguageCode );
458
			}
459
			$mostused = array_keys( $this->mCache[$wgLanguageCode] );
460
			foreach ( $mostused as $key => $value ) {
461
				$mostused[$key] = "$value/$code";
462
			}
463
		}
464
465
		if ( count( $mostused ) ) {
466
			$conds['page_title'] = $mostused;
467
		} elseif ( $code !== $wgLanguageCode ) {
468
			$conds[] = 'page_title' . $dbr->buildLike( $dbr->anyString(), '/', $code );
469
		} else {
470
			# Effectively disallows use of '/' character in NS_MEDIAWIKI for uses
471
			# other than language code.
472
			$conds[] = 'page_title NOT' . $dbr->buildLike( $dbr->anyString(), '/', $dbr->anyString() );
473
		}
474
475
		# Conditions to fetch oversized pages to ignore them
476
		$bigConds = $conds;
477
		$bigConds[] = 'page_len > ' . intval( $wgMaxMsgCacheEntrySize );
478
479
		# Load titles for all oversized pages in the MediaWiki namespace
480
		$res = $dbr->select( 'page', 'page_title', $bigConds, __METHOD__ . "($code)-big" );
481
		foreach ( $res as $row ) {
482
			$cache[$row->page_title] = '!TOO BIG';
483
		}
484
485
		# Conditions to load the remaining pages with their contents
486
		$smallConds = $conds;
487
		$smallConds[] = 'page_latest=rev_id';
488
		$smallConds[] = 'rev_text_id=old_id';
489
		$smallConds[] = 'page_len <= ' . intval( $wgMaxMsgCacheEntrySize );
490
491
		$res = $dbr->select(
492
			[ 'page', 'revision', 'text' ],
493
			[ 'page_title', 'old_text', 'old_flags' ],
494
			$smallConds,
495
			__METHOD__ . "($code)-small"
496
		);
497
498
		foreach ( $res as $row ) {
499
			$text = Revision::getRevisionText( $row );
500
			if ( $text === false ) {
501
				// Failed to fetch data; possible ES errors?
502
				// Store a marker to fetch on-demand as a workaround...
503
				$entry = '!TOO BIG';
504
				wfDebugLog(
505
					'MessageCache',
506
					__METHOD__
507
						. ": failed to load message page text for {$row->page_title} ($code)"
508
				);
509
			} else {
510
				$entry = ' ' . $text;
511
			}
512
			$cache[$row->page_title] = $entry;
513
		}
514
515
		$cache['VERSION'] = MSG_CACHE_VERSION;
516
		ksort( $cache );
517
		$cache['HASH'] = md5( serialize( $cache ) );
518
		$cache['EXPIRY'] = wfTimestamp( TS_MW, time() + $this->mExpiry );
519
520
		return $cache;
521
	}
522
523
	/**
524
	 * Updates cache as necessary when message page is changed
525
	 *
526
	 * @param string|bool $title Name of the page changed (false if deleted)
527
	 * @param mixed $text New contents of the page.
528
	 */
529
	public function replace( $title, $text ) {
530
		global $wgMaxMsgCacheEntrySize, $wgContLang, $wgLanguageCode;
531
532
		if ( $this->mDisable ) {
533
			return;
534
		}
535
536
		list( $msg, $code ) = $this->figureMessage( $title );
537
		if ( strpos( $title, '/' ) !== false && $code === $wgLanguageCode ) {
538
			// Content language overrides do not use the /<code> suffix
539
			return;
540
		}
541
542
		// Note that if the cache is volatile, load() may trigger a DB fetch.
543
		// In that case we reenter/reuse the existing cache key lock to avoid
544
		// a self-deadlock. This is safe as no reads happen *directly* in this
545
		// method between getReentrantScopedLock() and load() below. There is
546
		// no risk of data "changing under our feet" for replace().
547
		$cacheKey = wfMemcKey( 'messages', $code );
548
		$scopedLock = $this->getReentrantScopedLock( $cacheKey );
549
		$this->load( $code, self::FOR_UPDATE );
550
551
		$titleKey = wfMemcKey( 'messages', 'individual', $title );
552
		if ( $text === false ) {
553
			// Article was deleted
554
			$this->mCache[$code][$title] = '!NONEXISTENT';
555
			$this->wanCache->delete( $titleKey );
556 View Code Duplication
		} elseif ( strlen( $text ) > $wgMaxMsgCacheEntrySize ) {
557
			// Check for size
558
			$this->mCache[$code][$title] = '!TOO BIG';
559
			$this->wanCache->set( $titleKey, ' ' . $text, $this->mExpiry );
560
		} else {
561
			$this->mCache[$code][$title] = ' ' . $text;
562
			$this->wanCache->delete( $titleKey );
563
		}
564
565
		// Mark this cache as definitely "latest" (non-volatile) so
566
		// load() calls do try to refresh the cache with replica DB data
567
		$this->mCache[$code]['LATEST'] = time();
568
569
		// Update caches if the lock was acquired
570
		if ( $scopedLock ) {
571
			$this->saveToCaches( $this->mCache[$code], 'all', $code );
572
		}
573
574
		ScopedCallback::consume( $scopedLock );
575
		// Relay the purge to APC and other DCs
576
		$this->wanCache->touchCheckKey( wfMemcKey( 'messages', $code ) );
577
578
		// Also delete cached sidebar... just in case it is affected
579
		$codes = [ $code ];
580
		if ( $code === 'en' ) {
581
			// Delete all sidebars, like for example on action=purge on the
582
			// sidebar messages
583
			$codes = array_keys( Language::fetchLanguageNames() );
584
		}
585
586
		foreach ( $codes as $code ) {
587
			$sidebarKey = wfMemcKey( 'sidebar', $code );
588
			$this->wanCache->delete( $sidebarKey );
589
		}
590
591
		// Update the message in the message blob store
592
		$resourceloader = RequestContext::getMain()->getOutput()->getResourceLoader();
593
		$blobStore = $resourceloader->getMessageBlobStore();
594
		$blobStore->updateMessage( $wgContLang->lcfirst( $msg ) );
595
596
		Hooks::run( 'MessageCacheReplace', [ $title, $text ] );
597
	}
598
599
	/**
600
	 * Is the given cache array expired due to time passing or a version change?
601
	 *
602
	 * @param array $cache
603
	 * @return bool
604
	 */
605
	protected function isCacheExpired( $cache ) {
606
		if ( !isset( $cache['VERSION'] ) || !isset( $cache['EXPIRY'] ) ) {
607
			return true;
608
		}
609
		if ( $cache['VERSION'] != MSG_CACHE_VERSION ) {
610
			return true;
611
		}
612
		if ( wfTimestampNow() >= $cache['EXPIRY'] ) {
613
			return true;
614
		}
615
616
		return false;
617
	}
618
619
	/**
620
	 * Shortcut to update caches.
621
	 *
622
	 * @param array $cache Cached messages with a version.
623
	 * @param string $dest Either "local-only" to save to local caches only
624
	 *   or "all" to save to all caches.
625
	 * @param string|bool $code Language code (default: false)
626
	 * @return bool
627
	 */
628
	protected function saveToCaches( array $cache, $dest, $code = false ) {
629
		if ( $dest === 'all' ) {
630
			$cacheKey = wfMemcKey( 'messages', $code );
631
			$success = $this->mMemc->set( $cacheKey, $cache );
632
			$this->setValidationHash( $code, $cache );
633
		} else {
634
			$success = true;
635
		}
636
637
		$this->saveToLocalCache( $code, $cache );
638
639
		return $success;
640
	}
641
642
	/**
643
	 * Get the md5 used to validate the local APC cache
644
	 *
645
	 * @param string $code
646
	 * @return array (hash or false, bool expiry/volatility status)
647
	 */
648
	protected function getValidationHash( $code ) {
649
		$curTTL = null;
650
		$value = $this->wanCache->get(
651
			wfMemcKey( 'messages', $code, 'hash', 'v1' ),
652
			$curTTL,
653
			[ wfMemcKey( 'messages', $code ) ]
654
		);
655
656
		if ( !$value ) {
657
			// No hash found at all; cache must regenerate to be safe
658
			$hash = false;
659
			$expired = true;
660
		} else {
661
			$hash = $value['hash'];
662
			if ( ( time() - $value['latest'] ) < WANObjectCache::HOLDOFF_TTL ) {
663
				// Cache was recently updated via replace() and should be up-to-date
664
				$expired = false;
665
			} else {
666
				// See if the "check" key was bumped after the hash was generated
667
				$expired = ( $curTTL < 0 );
668
			}
669
		}
670
671
		return [ $hash, $expired ];
672
	}
673
674
	/**
675
	 * Set the md5 used to validate the local disk cache
676
	 *
677
	 * If $cache has a 'LATEST' UNIX timestamp key, then the hash will not
678
	 * be treated as "volatile" by getValidationHash() for the next few seconds
679
	 *
680
	 * @param string $code
681
	 * @param array $cache Cached messages with a version
682
	 */
683
	protected function setValidationHash( $code, array $cache ) {
684
		$this->wanCache->set(
685
			wfMemcKey( 'messages', $code, 'hash', 'v1' ),
686
			[
687
				'hash' => $cache['HASH'],
688
				'latest' => isset( $cache['LATEST'] ) ? $cache['LATEST'] : 0
689
			],
690
			WANObjectCache::TTL_INDEFINITE
691
		);
692
	}
693
694
	/**
695
	 * @param string $key A language message cache key that stores blobs
696
	 * @param integer $timeout Wait timeout in seconds
697
	 * @return null|ScopedCallback
698
	 */
699
	protected function getReentrantScopedLock( $key, $timeout = self::WAIT_SEC ) {
700
		return $this->mMemc->getScopedLock( $key, $timeout, self::LOCK_TTL, __METHOD__ );
701
	}
702
703
	/**
704
	 * Get a message from either the content language or the user language.
705
	 *
706
	 * First, assemble a list of languages to attempt getting the message from. This
707
	 * chain begins with the requested language and its fallbacks and then continues with
708
	 * the content language and its fallbacks. For each language in the chain, the following
709
	 * process will occur (in this order):
710
	 *  1. If a language-specific override, i.e., [[MW:msg/lang]], is available, use that.
711
	 *     Note: for the content language, there is no /lang subpage.
712
	 *  2. Fetch from the static CDB cache.
713
	 *  3. If available, check the database for fallback language overrides.
714
	 *
715
	 * This process provides a number of guarantees. When changing this code, make sure all
716
	 * of these guarantees are preserved.
717
	 *  * If the requested language is *not* the content language, then the CDB cache for that
718
	 *    specific language will take precedence over the root database page ([[MW:msg]]).
719
	 *  * Fallbacks will be just that: fallbacks. A fallback language will never be reached if
720
	 *    the message is available *anywhere* in the language for which it is a fallback.
721
	 *
722
	 * @param string $key The message key
723
	 * @param bool $useDB If true, look for the message in the DB, false
724
	 *   to use only the compiled l10n cache.
725
	 * @param bool|string|object $langcode Code of the language to get the message for.
726
	 *   - If string and a valid code, will create a standard language object
727
	 *   - If string but not a valid code, will create a basic language object
728
	 *   - If boolean and false, create object from the current users language
729
	 *   - If boolean and true, create object from the wikis content language
730
	 *   - If language object, use it as given
731
	 * @param bool $isFullKey Specifies whether $key is a two part key "msg/lang".
732
	 *
733
	 * @throws MWException When given an invalid key
734
	 * @return string|bool False if the message doesn't exist, otherwise the
735
	 *   message (which can be empty)
736
	 */
737
	function get( $key, $useDB = true, $langcode = true, $isFullKey = false ) {
738
		if ( is_int( $key ) ) {
739
			// Fix numerical strings that somehow become ints
740
			// on their way here
741
			$key = (string)$key;
742
		} elseif ( !is_string( $key ) ) {
743
			throw new MWException( 'Non-string key given' );
744
		} elseif ( $key === '' ) {
745
			// Shortcut: the empty key is always missing
746
			return false;
747
		}
748
749
		// For full keys, get the language code from the key
750
		$pos = strrpos( $key, '/' );
751
		if ( $isFullKey && $pos !== false ) {
752
			$langcode = substr( $key, $pos + 1 );
753
			$key = substr( $key, 0, $pos );
754
		}
755
756
		// Normalise title-case input (with some inlining)
757
		$lckey = MessageCache::normalizeKey( $key );
758
759
		Hooks::run( 'MessageCache::get', [ &$lckey ] );
760
761
		// Loop through each language in the fallback list until we find something useful
762
		$lang = wfGetLangObj( $langcode );
763
		$message = $this->getMessageFromFallbackChain(
764
			$lang,
765
			$lckey,
766
			!$this->mDisable && $useDB
767
		);
768
769
		// If we still have no message, maybe the key was in fact a full key so try that
770
		if ( $message === false ) {
771
			$parts = explode( '/', $lckey );
772
			// We may get calls for things that are http-urls from sidebar
773
			// Let's not load nonexistent languages for those
774
			// They usually have more than one slash.
775
			if ( count( $parts ) == 2 && $parts[1] !== '' ) {
776
				$message = Language::getMessageFor( $parts[0], $parts[1] );
777
				if ( $message === null ) {
778
					$message = false;
779
				}
780
			}
781
		}
782
783
		// Post-processing if the message exists
784
		if ( $message !== false ) {
785
			// Fix whitespace
786
			$message = str_replace(
787
				[
788
					# Fix for trailing whitespace, removed by textarea
789
					'&#32;',
790
					# Fix for NBSP, converted to space by firefox
791
					'&nbsp;',
792
					'&#160;',
793
					'&shy;'
794
				],
795
				[
796
					' ',
797
					"\xc2\xa0",
798
					"\xc2\xa0",
799
					"\xc2\xad"
800
				],
801
				$message
802
			);
803
		}
804
805
		return $message;
806
	}
807
808
	/**
809
	 * Given a language, try and fetch messages from that language.
810
	 *
811
	 * Will also consider fallbacks of that language, the site language, and fallbacks for
812
	 * the site language.
813
	 *
814
	 * @see MessageCache::get
815
	 * @param Language|StubObject $lang Preferred language
816
	 * @param string $lckey Lowercase key for the message (as for localisation cache)
817
	 * @param bool $useDB Whether to include messages from the wiki database
818
	 * @return string|bool The message, or false if not found
819
	 */
820
	protected function getMessageFromFallbackChain( $lang, $lckey, $useDB ) {
821
		global $wgContLang;
822
823
		$alreadyTried = [];
824
825
		 // First try the requested language.
826
		$message = $this->getMessageForLang( $lang, $lckey, $useDB, $alreadyTried );
827
		if ( $message !== false ) {
828
			return $message;
829
		}
830
831
		// Now try checking the site language.
832
		$message = $this->getMessageForLang( $wgContLang, $lckey, $useDB, $alreadyTried );
833
		return $message;
834
	}
835
836
	/**
837
	 * Given a language, try and fetch messages from that language and its fallbacks.
838
	 *
839
	 * @see MessageCache::get
840
	 * @param Language|StubObject $lang Preferred language
841
	 * @param string $lckey Lowercase key for the message (as for localisation cache)
842
	 * @param bool $useDB Whether to include messages from the wiki database
843
	 * @param bool[] $alreadyTried Contains true for each language that has been tried already
844
	 * @return string|bool The message, or false if not found
845
	 */
846
	private function getMessageForLang( $lang, $lckey, $useDB, &$alreadyTried ) {
847
		global $wgContLang;
848
		$langcode = $lang->getCode();
849
850
		// Try checking the database for the requested language
851
		if ( $useDB ) {
852
			$uckey = $wgContLang->ucfirst( $lckey );
853
854
			if ( !isset( $alreadyTried[ $langcode ] ) ) {
855
				$message = $this->getMsgFromNamespace(
856
					$this->getMessagePageName( $langcode, $uckey ),
857
					$langcode
858
				);
859
860
				if ( $message !== false ) {
861
					return $message;
862
				}
863
				$alreadyTried[ $langcode ] = true;
864
			}
865
		} else {
866
			$uckey = null;
867
		}
868
869
		// Check the CDB cache
870
		$message = $lang->getMessage( $lckey );
871
		if ( $message !== null ) {
872
			return $message;
873
		}
874
875
		// Try checking the database for all of the fallback languages
876
		if ( $useDB ) {
877
			$fallbackChain = Language::getFallbacksFor( $langcode );
878
879
			foreach ( $fallbackChain as $code ) {
880
				if ( isset( $alreadyTried[ $code ] ) ) {
881
					continue;
882
				}
883
884
				$message = $this->getMsgFromNamespace(
885
					$this->getMessagePageName( $code, $uckey ), $code );
886
887
				if ( $message !== false ) {
888
					return $message;
889
				}
890
				$alreadyTried[ $code ] = true;
891
			}
892
		}
893
894
		return false;
895
	}
896
897
	/**
898
	 * Get the message page name for a given language
899
	 *
900
	 * @param string $langcode
901
	 * @param string $uckey Uppercase key for the message
902
	 * @return string The page name
903
	 */
904
	private function getMessagePageName( $langcode, $uckey ) {
905
		global $wgLanguageCode;
906
		if ( $langcode === $wgLanguageCode ) {
907
			// Messages created in the content language will not have the /lang extension
908
			return $uckey;
909
		} else {
910
			return "$uckey/$langcode";
911
		}
912
	}
913
914
	/**
915
	 * Get a message from the MediaWiki namespace, with caching. The key must
916
	 * first be converted to two-part lang/msg form if necessary.
917
	 *
918
	 * Unlike self::get(), this function doesn't resolve fallback chains, and
919
	 * some callers require this behavior. LanguageConverter::parseCachedTable()
920
	 * and self::get() are some examples in core.
921
	 *
922
	 * @param string $title Message cache key with initial uppercase letter.
923
	 * @param string $code Code denoting the language to try.
924
	 * @return string|bool The message, or false if it does not exist or on error
925
	 */
926
	public function getMsgFromNamespace( $title, $code ) {
927
		$this->load( $code );
928
		if ( isset( $this->mCache[$code][$title] ) ) {
929
			$entry = $this->mCache[$code][$title];
930
			if ( substr( $entry, 0, 1 ) === ' ' ) {
931
				// The message exists, so make sure a string
932
				// is returned.
933
				return (string)substr( $entry, 1 );
934
			} elseif ( $entry === '!NONEXISTENT' ) {
935
				return false;
936
			} elseif ( $entry === '!TOO BIG' ) {
937
				// Fall through and try invididual message cache below
938
			}
939
		} else {
940
			// XXX: This is not cached in process cache, should it?
941
			$message = false;
942
			Hooks::run( 'MessagesPreLoad', [ $title, &$message ] );
943
			if ( $message !== false ) {
944
				return $message;
945
			}
946
947
			return false;
948
		}
949
950
		// Try the individual message cache
951
		$titleKey = wfMemcKey( 'messages', 'individual', $title );
952
953
		$curTTL = null;
954
		$entry = $this->wanCache->get(
955
			$titleKey,
956
			$curTTL,
957
			[ wfMemcKey( 'messages', $code ) ]
958
		);
959
		$entry = ( $curTTL >= 0 ) ? $entry : false;
960
961
		if ( $entry ) {
962
			if ( substr( $entry, 0, 1 ) === ' ' ) {
963
				$this->mCache[$code][$title] = $entry;
964
				// The message exists, so make sure a string is returned
965
				return (string)substr( $entry, 1 );
966
			} elseif ( $entry === '!NONEXISTENT' ) {
967
				$this->mCache[$code][$title] = '!NONEXISTENT';
968
969
				return false;
970
			} else {
971
				// Corrupt/obsolete entry, delete it
972
				$this->wanCache->delete( $titleKey );
973
			}
974
		}
975
976
		// Try loading it from the database
977
		$dbr = wfGetDB( DB_REPLICA );
978
		$cacheOpts = Database::getCacheSetOptions( $dbr );
979
		// Use newKnownCurrent() to avoid querying revision/user tables
980
		$titleObj = Title::makeTitle( NS_MEDIAWIKI, $title );
981
		if ( $titleObj->getLatestRevID() ) {
982
			$revision = Revision::newKnownCurrent(
983
				$dbr,
984
				$titleObj->getArticleID(),
985
				$titleObj->getLatestRevID()
986
			);
987
		} else {
988
			$revision = false;
989
		}
990
991
		if ( $revision ) {
992
			$content = $revision->getContent();
993
			if ( !$content ) {
994
				// A possibly temporary loading failure.
995
				wfDebugLog(
996
					'MessageCache',
997
					__METHOD__ . ": failed to load message page text for {$title} ($code)"
998
				);
999
				$message = null; // no negative caching
1000
			} else {
1001
				// XXX: Is this the right way to turn a Content object into a message?
1002
				// NOTE: $content is typically either WikitextContent, JavaScriptContent or
1003
				//       CssContent. MessageContent is *not* used for storing messages, it's
1004
				//       only used for wrapping them when needed.
1005
				$message = $content->getWikitextForTransclusion();
1006
1007
				if ( $message === false || $message === null ) {
1008
					wfDebugLog(
1009
						'MessageCache',
1010
						__METHOD__ . ": message content doesn't provide wikitext "
1011
							. "(content model: " . $content->getModel() . ")"
1012
					);
1013
1014
					$message = false; // negative caching
1015
				} else {
1016
					$this->mCache[$code][$title] = ' ' . $message;
1017
					$this->wanCache->set( $titleKey, ' ' . $message, $this->mExpiry, $cacheOpts );
1018
				}
1019
			}
1020
		} else {
1021
			$message = false; // negative caching
1022
		}
1023
1024 View Code Duplication
		if ( $message === false ) { // negative caching
1025
			$this->mCache[$code][$title] = '!NONEXISTENT';
1026
			$this->wanCache->set( $titleKey, '!NONEXISTENT', $this->mExpiry, $cacheOpts );
1027
		}
1028
1029
		return $message;
1030
	}
1031
1032
	/**
1033
	 * @param string $message
1034
	 * @param bool $interface
1035
	 * @param string $language Language code
1036
	 * @param Title $title
1037
	 * @return string
1038
	 */
1039
	function transform( $message, $interface = false, $language = null, $title = null ) {
1040
		// Avoid creating parser if nothing to transform
1041
		if ( strpos( $message, '{{' ) === false ) {
1042
			return $message;
1043
		}
1044
1045
		if ( $this->mInParser ) {
1046
			return $message;
1047
		}
1048
1049
		$parser = $this->getParser();
1050
		if ( $parser ) {
1051
			$popts = $this->getParserOptions();
1052
			$popts->setInterfaceMessage( $interface );
1053
			$popts->setTargetLanguage( $language );
1054
1055
			$userlang = $popts->setUserLang( $language );
1056
			$this->mInParser = true;
1057
			$message = $parser->transformMsg( $message, $popts, $title );
1058
			$this->mInParser = false;
1059
			$popts->setUserLang( $userlang );
1060
		}
1061
1062
		return $message;
1063
	}
1064
1065
	/**
1066
	 * @return Parser
1067
	 */
1068
	function getParser() {
1069
		global $wgParser, $wgParserConf;
1070
		if ( !$this->mParser && isset( $wgParser ) ) {
1071
			# Do some initialisation so that we don't have to do it twice
1072
			$wgParser->firstCallInit();
1073
			# Clone it and store it
1074
			$class = $wgParserConf['class'];
1075
			if ( $class == 'ParserDiffTest' ) {
1076
				# Uncloneable
1077
				$this->mParser = new $class( $wgParserConf );
1078
			} else {
1079
				$this->mParser = clone $wgParser;
1080
			}
1081
		}
1082
1083
		return $this->mParser;
1084
	}
1085
1086
	/**
1087
	 * @param string $text
1088
	 * @param Title $title
1089
	 * @param bool $linestart Whether or not this is at the start of a line
1090
	 * @param bool $interface Whether this is an interface message
1091
	 * @param Language|string $language Language code
1092
	 * @return ParserOutput|string
1093
	 */
1094
	public function parse( $text, $title = null, $linestart = true,
1095
		$interface = false, $language = null
1096
	) {
1097
		if ( $this->mInParser ) {
1098
			return htmlspecialchars( $text );
1099
		}
1100
1101
		$parser = $this->getParser();
1102
		$popts = $this->getParserOptions();
1103
		$popts->setInterfaceMessage( $interface );
1104
1105
		if ( is_string( $language ) ) {
1106
			$language = Language::factory( $language );
1107
		}
1108
		$popts->setTargetLanguage( $language );
1109
1110
		if ( !$title || !$title instanceof Title ) {
1111
			global $wgTitle;
1112
			wfDebugLog( 'GlobalTitleFail', __METHOD__ . ' called by ' .
1113
				wfGetAllCallers( 6 ) . ' with no title set.' );
1114
			$title = $wgTitle;
1115
		}
1116
		// Sometimes $wgTitle isn't set either...
1117
		if ( !$title ) {
1118
			# It's not uncommon having a null $wgTitle in scripts. See r80898
1119
			# Create a ghost title in such case
1120
			$title = Title::makeTitle( NS_SPECIAL, 'Badtitle/title not set in ' . __METHOD__ );
1121
		}
1122
1123
		$this->mInParser = true;
1124
		$res = $parser->parse( $text, $title, $popts, $linestart );
1125
		$this->mInParser = false;
1126
1127
		return $res;
1128
	}
1129
1130
	function disable() {
1131
		$this->mDisable = true;
1132
	}
1133
1134
	function enable() {
1135
		$this->mDisable = false;
1136
	}
1137
1138
	/**
1139
	 * Whether DB/cache usage is disabled for determining messages
1140
	 *
1141
	 * If so, this typically indicates either:
1142
	 *   - a) load() failed to find a cached copy nor query the DB
1143
	 *   - b) we are in a special context or error mode that cannot use the DB
1144
	 * If the DB is ignored, any derived HTML output or cached objects may be wrong.
1145
	 * To avoid long-term cache pollution, TTLs can be adjusted accordingly.
1146
	 *
1147
	 * @return bool
1148
	 * @since 1.27
1149
	 */
1150
	public function isDisabled() {
1151
		return $this->mDisable;
1152
	}
1153
1154
	/**
1155
	 * Clear all stored messages. Mainly used after a mass rebuild.
1156
	 */
1157
	function clear() {
1158
		$langs = Language::fetchLanguageNames( null, 'mw' );
1159
		foreach ( array_keys( $langs ) as $code ) {
1160
			# Global and local caches
1161
			$this->wanCache->touchCheckKey( wfMemcKey( 'messages', $code ) );
1162
		}
1163
1164
		$this->mLoadedLanguages = [];
1165
	}
1166
1167
	/**
1168
	 * @param string $key
1169
	 * @return array
1170
	 */
1171
	public function figureMessage( $key ) {
1172
		global $wgLanguageCode;
1173
1174
		$pieces = explode( '/', $key );
1175
		if ( count( $pieces ) < 2 ) {
1176
			return [ $key, $wgLanguageCode ];
1177
		}
1178
1179
		$lang = array_pop( $pieces );
1180
		if ( !Language::fetchLanguageName( $lang, null, 'mw' ) ) {
1181
			return [ $key, $wgLanguageCode ];
1182
		}
1183
1184
		$message = implode( '/', $pieces );
1185
1186
		return [ $message, $lang ];
1187
	}
1188
1189
	/**
1190
	 * Get all message keys stored in the message cache for a given language.
1191
	 * If $code is the content language code, this will return all message keys
1192
	 * for which MediaWiki:msgkey exists. If $code is another language code, this
1193
	 * will ONLY return message keys for which MediaWiki:msgkey/$code exists.
1194
	 * @param string $code Language code
1195
	 * @return array Array of message keys (strings)
1196
	 */
1197
	public function getAllMessageKeys( $code ) {
1198
		global $wgContLang;
1199
		$this->load( $code );
1200
		if ( !isset( $this->mCache[$code] ) ) {
1201
			// Apparently load() failed
1202
			return null;
1203
		}
1204
		// Remove administrative keys
1205
		$cache = $this->mCache[$code];
1206
		unset( $cache['VERSION'] );
1207
		unset( $cache['EXPIRY'] );
1208
		// Remove any !NONEXISTENT keys
1209
		$cache = array_diff( $cache, [ '!NONEXISTENT' ] );
1210
1211
		// Keys may appear with a capital first letter. lcfirst them.
1212
		return array_map( [ $wgContLang, 'lcfirst' ], array_keys( $cache ) );
1213
	}
1214
}
1215