MediaWiki REL1_35
WANObjectCache.php
Go to the documentation of this file.
1<?php
22use Liuggio\StatsdClient\Factory\StatsdDataFactoryInterface;
23use Psr\Log\LoggerAwareInterface;
24use Psr\Log\LoggerInterface;
25use Psr\Log\NullLogger;
28
120class WANObjectCache implements
124 LoggerAwareInterface
125{
127 protected $cache;
129 protected $processCaches = [];
131 protected $logger;
133 protected $stats;
135 protected $asyncHandler;
136
138 protected $mcrouterAware;
140 protected $region;
142 protected $cluster;
146 protected $epoch;
148 protected $secret;
150 protected $coalesceKeys;
153
155 private $keyHighQps;
158
160 private $callbackDepth = 0;
162 private $warmupCache = [];
164 private $warmupKeyMisses = 0;
165
168
170 private const MAX_COMMIT_DELAY = 3;
172 private const MAX_READ_LAG = 7;
174 public const HOLDOFF_TTL = self::MAX_COMMIT_DELAY + self::MAX_READ_LAG + 1;
175
177 private const LOW_TTL = 30;
179 public const TTL_LAGGED = 30;
180
182 private const HOT_TTR = 900;
184 private const AGE_NEW = 60;
185
187 private const TSE_NONE = -1;
188
190 private const STALE_TTL_NONE = 0;
192 private const GRACE_TTL_NONE = 0;
194 public const HOLDOFF_TTL_NONE = 0;
196 public const HOLDOFF_NONE = self::HOLDOFF_TTL_NONE;
197
199 public const MIN_TIMESTAMP_NONE = 0.0;
200
202 private const PC_PRIMARY = 'primary:1000';
203
205 public const PASS_BY_REF = -1;
206
208 private const SCHEME_HASH_TAG = 1;
210 private const SCHEME_HASH_STOP = 2;
211
213 private static $CHECK_KEY_TTL = self::TTL_YEAR;
215 private static $INTERIM_KEY_TTL = 1;
216
218 private static $LOCK_TTL = 10;
220 private static $COOLOFF_TTL = 1;
222 private static $RAMPUP_TTL = 30;
223
225 private static $TINY_NEGATIVE = -0.000001;
227 private static $TINY_POSTIVE = 0.000001;
228
230 private static $RECENT_SET_LOW_MS = 50;
232 private static $RECENT_SET_HIGH_MS = 100;
233
235 private static $GENERATION_SLOW_SEC = 3;
236
238 private static $PURGE_TIME = 0;
240 private static $PURGE_HOLDOFF = 1;
241
243 private static $VERSION = 1;
244
246 private static $FLD_FORMAT_VERSION = 0;
248 private static $FLD_VALUE = 1;
250 private static $FLD_TTL = 2;
252 private static $FLD_TIME = 3;
254 private static $FLD_FLAGS = 4;
256 private static $FLD_VALUE_VERSION = 5;
258 private static $FLD_GENERATION_TIME = 6;
259
261 private static $TYPE_VALUE = 'v';
263 private static $TYPE_TIMESTAMP = 't';
265 private static $TYPE_MUTEX = 'm';
267 private static $TYPE_INTERIM = 'i';
269 private static $TYPE_COOLOFF = 'c';
270
272 private static $PURGE_VAL_PREFIX = 'PURGED:';
273
310 public function __construct( array $params ) {
311 $this->cache = $params['cache'];
312 $this->region = $params['region'] ?? 'main';
313 $this->cluster = $params['cluster'] ?? 'wan-main';
314 $this->mcrouterAware = !empty( $params['mcrouterAware'] );
315 $this->epoch = $params['epoch'] ?? 0;
316 $this->secret = $params['secret'] ?? (string)$this->epoch;
317 $this->coalesceKeys = $params['coalesceKeys'] ?? false;
318 if ( !empty( $params['mcrouterAware'] ) ) {
319 // https://github.com/facebook/mcrouter/wiki/Key-syntax
320 $this->coalesceScheme = self::SCHEME_HASH_STOP;
321 } else {
322 // https://redis.io/topics/cluster-spec
323 // https://github.com/twitter/twemproxy/blob/v0.4.1/notes/recommendation.md#hash-tags
324 // https://github.com/Netflix/dynomite/blob/v0.7.0/notes/recommendation.md#hash-tags
325 $this->coalesceScheme = self::SCHEME_HASH_TAG;
326 }
327
328 $this->keyHighQps = $params['keyHighQps'] ?? 100;
329 $this->keyHighUplinkBps = $params['keyHighUplinkBps'] ?? ( 1e9 / 8 / 100 );
330
331 $this->setLogger( $params['logger'] ?? new NullLogger() );
332 $this->stats = $params['stats'] ?? new NullStatsdDataFactory();
333 $this->asyncHandler = $params['asyncHandler'] ?? null;
334 }
335
339 public function setLogger( LoggerInterface $logger ) {
340 $this->logger = $logger;
341 }
342
348 public static function newEmpty() {
349 return new static( [ 'cache' => new EmptyBagOStuff() ] );
350 }
351
402 final public function get(
403 $key, &$curTTL = null, array $checkKeys = [], &$info = null
404 ) {
405 $curTTLs = self::PASS_BY_REF;
406 $infoByKey = self::PASS_BY_REF;
407 $values = $this->getMulti( [ $key ], $curTTLs, $checkKeys, $infoByKey );
408
409 $curTTL = $curTTLs[$key] ?? null;
410 if ( $info === self::PASS_BY_REF ) {
411 $info = [
412 'asOf' => $infoByKey[$key]['asOf'] ?? null,
413 'tombAsOf' => $infoByKey[$key]['tombAsOf'] ?? null,
414 'lastCKPurge' => $infoByKey[$key]['lastCKPurge'] ?? null,
415 'version' => $infoByKey[$key]['version'] ?? null
416 ];
417 } else {
418 $info = $infoByKey[$key]['asOf'] ?? null; // b/c
419 }
420
421 return array_key_exists( $key, $values ) ? $values[$key] : false;
422 }
423
446 final public function getMulti(
447 array $keys,
448 &$curTTLs = [],
449 array $checkKeys = [],
450 &$info = null
451 ) {
452 $result = [];
453 $curTTLs = [];
454 $infoByKey = [];
455
456 // Order-corresponding list of value keys for the provided base keys
457 $valueKeys = $this->makeSisterKeys( $keys, self::$TYPE_VALUE );
458
459 $fullKeysNeeded = $valueKeys;
460 $checkKeysForAll = [];
461 $checkKeysByKey = [];
462 foreach ( $checkKeys as $i => $checkKeyOrKeyGroup ) {
463 // Note: avoid array_merge() inside loop in case there are many keys
464 if ( is_int( $i ) ) {
465 // Single check key that applies to all value keys
466 $fullKey = $this->makeSisterKey( $checkKeyOrKeyGroup, self::$TYPE_TIMESTAMP );
467 $fullKeysNeeded[] = $fullKey;
468 $checkKeysForAll[] = $fullKey;
469 } else {
470 // List of check keys that apply to a specific value key
471 foreach ( (array)$checkKeyOrKeyGroup as $checkKey ) {
472 $fullKey = $this->makeSisterKey( $checkKey, self::$TYPE_TIMESTAMP );
473 $fullKeysNeeded[] = $fullKey;
474 $checkKeysByKey[$i][] = $fullKey;
475 }
476 }
477 }
478
479 if ( $this->warmupCache ) {
480 // Get the raw values of the keys from the warmup cache
481 $wrappedValues = $this->warmupCache;
482 $fullKeysMissing = array_diff( $fullKeysNeeded, array_keys( $wrappedValues ) );
483 if ( $fullKeysMissing ) { // sanity
484 $this->warmupKeyMisses += count( $fullKeysMissing );
485 $wrappedValues += $this->cache->getMulti( $fullKeysMissing );
486 }
487 } else {
488 // Fetch the raw values of the keys from the backend
489 $wrappedValues = $this->cache->getMulti( $fullKeysNeeded );
490 }
491
492 // Time used to compare/init "check" keys (derived after getMulti() to be pessimistic)
493 $now = $this->getCurrentTime();
494
495 // Collect timestamps from all "check" keys
496 $purgeValuesForAll = $this->processCheckKeys( $checkKeysForAll, $wrappedValues, $now );
497 $purgeValuesByKey = [];
498 foreach ( $checkKeysByKey as $cacheKey => $checks ) {
499 $purgeValuesByKey[$cacheKey] = $this->processCheckKeys( $checks, $wrappedValues, $now );
500 }
501
502 // Get the main cache value for each key and validate them
503 reset( $keys );
504 foreach ( $valueKeys as $i => $vKey ) {
505 // Get the corresponding base key for this value key
506 $key = current( $keys );
507 next( $keys );
508
509 list( $value, $keyInfo ) = $this->unwrap(
510 array_key_exists( $vKey, $wrappedValues ) ? $wrappedValues[$vKey] : false,
511 $now
512 );
513 // Force dependent keys to be seen as stale for a while after purging
514 // to reduce race conditions involving stale data getting cached
515 $purgeValues = $purgeValuesForAll;
516 if ( isset( $purgeValuesByKey[$key] ) ) {
517 $purgeValues = array_merge( $purgeValues, $purgeValuesByKey[$key] );
518 }
519
520 $lastCKPurge = null; // timestamp of the highest check key
521 foreach ( $purgeValues as $purge ) {
522 $lastCKPurge = max( $purge[self::$PURGE_TIME], $lastCKPurge );
523 $safeTimestamp = $purge[self::$PURGE_TIME] + $purge[self::$PURGE_HOLDOFF];
524 if ( $value !== false && $safeTimestamp >= $keyInfo['asOf'] ) {
525 // How long ago this value was invalidated by *this* check key
526 $ago = min( $purge[self::$PURGE_TIME] - $now, self::$TINY_NEGATIVE );
527 // How long ago this value was invalidated by *any* known check key
528 $keyInfo['curTTL'] = min( $keyInfo['curTTL'], $ago );
529 }
530 }
531 $keyInfo[ 'lastCKPurge'] = $lastCKPurge;
532
533 if ( $value !== false ) {
534 $result[$key] = $value;
535 }
536 if ( $keyInfo['curTTL'] !== null ) {
537 $curTTLs[$key] = $keyInfo['curTTL'];
538 }
539
540 $infoByKey[$key] = ( $info === self::PASS_BY_REF )
541 ? $keyInfo
542 : $keyInfo['asOf']; // b/c
543 }
544
545 $info = $infoByKey;
546
547 return $result;
548 }
549
557 private function processCheckKeys( array $timeKeys, array $wrappedValues, $now ) {
558 $purgeValues = [];
559 foreach ( $timeKeys as $timeKey ) {
560 $purge = isset( $wrappedValues[$timeKey] )
561 ? $this->parsePurgeValue( $wrappedValues[$timeKey] )
562 : false;
563 if ( $purge === false ) {
564 // Key is not set or malformed; regenerate
565 $newVal = $this->makePurgeValue( $now, self::HOLDOFF_TTL );
566 $this->cache->add( $timeKey, $newVal, self::$CHECK_KEY_TTL );
567 $purge = $this->parsePurgeValue( $newVal );
568 }
569 $purgeValues[] = $purge;
570 }
571
572 return $purgeValues;
573 }
574
651 final public function set( $key, $value, $ttl = self::TTL_INDEFINITE, array $opts = [] ) {
652 $now = $this->getCurrentTime();
653 $lag = $opts['lag'] ?? 0;
654 $age = isset( $opts['since'] ) ? max( 0, $now - $opts['since'] ) : 0;
655 $pending = $opts['pending'] ?? false;
656 $lockTSE = $opts['lockTSE'] ?? self::TSE_NONE;
657 $staleTTL = $opts['staleTTL'] ?? self::STALE_TTL_NONE;
658 $creating = $opts['creating'] ?? false;
659 $version = $opts['version'] ?? null;
660 $walltime = $opts['walltime'] ?? null;
661
662 if ( $ttl < 0 ) {
663 return true; // not cacheable
664 }
665
666 // Do not cache potentially uncommitted data as it might get rolled back
667 if ( $pending ) {
668 $this->logger->info(
669 'Rejected set() for {cachekey} due to pending writes.',
670 [ 'cachekey' => $key ]
671 );
672
673 return true; // no-op the write for being unsafe
674 }
675
676 // Check if there is a risk of caching (stale) data that predates the last delete()
677 // tombstone due to the tombstone having expired. If so, then the behavior should depend
678 // on whether the problem is specific to this regeneration attempt or systemically affects
679 // attempts to regenerate this key. For systemic cases, the cache writes should set a low
680 // TTL so that the value at least remains cacheable. For non-systemic cases, the cache
681 // write can simply be rejected.
682 if ( $age > self::MAX_READ_LAG ) {
683 // Case A: high snapshot lag
684 if ( $walltime === null ) {
685 // Case A0: high snapshot lag without regeneration wall time info.
686 // Probably systemic; use a low TTL to avoid stampedes/uncacheability.
687 $mitigated = 'snapshot lag';
688 $mitigationTTL = self::TTL_SECOND;
689 } elseif ( ( $age - $walltime ) > self::MAX_READ_LAG ) {
690 // Case A1: value regeneration during an already long-running transaction.
691 // Probably non-systemic; rely on a less problematic regeneration attempt.
692 $mitigated = 'snapshot lag (late regeneration)';
693 $mitigationTTL = self::TTL_UNCACHEABLE;
694 } else {
695 // Case A2: value regeneration takes a long time.
696 // Probably systemic; use a low TTL to avoid stampedes/uncacheability.
697 $mitigated = 'snapshot lag (high regeneration time)';
698 $mitigationTTL = self::TTL_SECOND;
699 }
700 } elseif ( $lag === false || $lag > self::MAX_READ_LAG ) {
701 // Case B: high replication lag without high snapshot lag
702 // Probably systemic; use a low TTL to avoid stampedes/uncacheability
703 $mitigated = 'replication lag';
704 $mitigationTTL = self::TTL_LAGGED;
705 } elseif ( ( $lag + $age ) > self::MAX_READ_LAG ) {
706 // Case C: medium length request with medium replication lag
707 // Probably non-systemic; rely on a less problematic regeneration attempt
708 $mitigated = 'read lag';
709 $mitigationTTL = self::TTL_UNCACHEABLE;
710 } else {
711 // New value generated with recent enough data
712 $mitigated = null;
713 $mitigationTTL = null;
714 }
715
716 if ( $mitigationTTL === self::TTL_UNCACHEABLE ) {
717 $this->logger->warning(
718 "Rejected set() for {cachekey} due to $mitigated.",
719 [ 'cachekey' => $key, 'lag' => $lag, 'age' => $age, 'walltime' => $walltime ]
720 );
721
722 return true; // no-op the write for being unsafe
723 }
724
725 // TTL to use in staleness checks (does not effect persistence layer TTL)
726 $logicalTTL = null;
727
728 if ( $mitigationTTL !== null ) {
729 // New value generated from data that is old enough to be risky
730 if ( $lockTSE >= 0 ) {
731 // Value will have the normal expiry but will be seen as stale sooner
732 $logicalTTL = min( $ttl ?: INF, $mitigationTTL );
733 } else {
734 // Value expires sooner (leaving enough TTL for preemptive refresh)
735 $ttl = min( $ttl ?: INF, max( $mitigationTTL, self::LOW_TTL ) );
736 }
737
738 $this->logger->warning(
739 "Lowered set() TTL for {cachekey} due to $mitigated.",
740 [ 'cachekey' => $key, 'lag' => $lag, 'age' => $age, 'walltime' => $walltime ]
741 );
742 }
743
744 // Wrap that value with time/TTL/version metadata
745 $wrapped = $this->wrap( $value, $logicalTTL ?: $ttl, $version, $now, $walltime );
746 $storeTTL = $ttl + $staleTTL;
747
748 if ( $creating ) {
749 $ok = $this->cache->add(
750 $this->makeSisterKey( $key, self::$TYPE_VALUE ),
751 $wrapped,
752 $storeTTL
753 );
754 } else {
755 $ok = $this->cache->merge(
756 $this->makeSisterKey( $key, self::$TYPE_VALUE ),
757 function ( $cache, $key, $cWrapped ) use ( $wrapped ) {
758 // A string value means that it is a tombstone; do nothing in that case
759 return ( is_string( $cWrapped ) ) ? false : $wrapped;
760 },
761 $storeTTL,
762 1 // 1 attempt
763 );
764 }
765
766 return $ok;
767 }
768
830 final public function delete( $key, $ttl = self::HOLDOFF_TTL ) {
831 if ( $ttl <= 0 ) {
832 // Publish the purge to all datacenters
833 $ok = $this->relayDelete( $this->makeSisterKey( $key, self::$TYPE_VALUE ) );
834 } else {
835 // Publish the purge to all datacenters
836 $ok = $this->relayPurge(
837 $this->makeSisterKey( $key, self::$TYPE_VALUE ),
838 $ttl,
839 self::HOLDOFF_TTL_NONE
840 );
841 }
842
843 $kClass = $this->determineKeyClassForStats( $key );
844 $this->stats->increment( "wanobjectcache.$kClass.delete." . ( $ok ? 'ok' : 'error' ) );
845
846 return $ok;
847 }
848
868 final public function getCheckKeyTime( $key ) {
869 return $this->getMultiCheckKeyTime( [ $key ] )[$key];
870 }
871
933 final public function getMultiCheckKeyTime( array $keys ) {
934 $rawKeys = [];
935 foreach ( $keys as $key ) {
936 $rawKeys[$key] = $this->makeSisterKey( $key, self::$TYPE_TIMESTAMP );
937 }
938
939 $rawValues = $this->cache->getMulti( $rawKeys );
940 $rawValues += array_fill_keys( $rawKeys, false );
941
942 $times = [];
943 foreach ( $rawKeys as $key => $rawKey ) {
944 $purge = $this->parsePurgeValue( $rawValues[$rawKey] );
945 if ( $purge !== false ) {
946 $time = $purge[self::$PURGE_TIME];
947 } else {
948 // Casting assures identical floats for the next getCheckKeyTime() calls
949 $now = (string)$this->getCurrentTime();
950 $this->cache->add(
951 $rawKey,
952 $this->makePurgeValue( $now, self::HOLDOFF_TTL ),
953 self::$CHECK_KEY_TTL
954 );
955 $time = (float)$now;
956 }
957
958 $times[$key] = $time;
959 }
960
961 return $times;
962 }
963
998 final public function touchCheckKey( $key, $holdoff = self::HOLDOFF_TTL ) {
999 // Publish the purge to all datacenters
1000 $ok = $this->relayPurge(
1001 $this->makeSisterKey( $key, self::$TYPE_TIMESTAMP ),
1002 self::$CHECK_KEY_TTL,
1003 $holdoff
1004 );
1005
1006 $kClass = $this->determineKeyClassForStats( $key );
1007 $this->stats->increment( "wanobjectcache.$kClass.ck_touch." . ( $ok ? 'ok' : 'error' ) );
1008
1009 return $ok;
1010 }
1011
1039 final public function resetCheckKey( $key ) {
1040 // Publish the purge to all datacenters
1041 $ok = $this->relayDelete( $this->makeSisterKey( $key, self::$TYPE_TIMESTAMP ) );
1042
1043 $kClass = $this->determineKeyClassForStats( $key );
1044 $this->stats->increment( "wanobjectcache.$kClass.ck_reset." . ( $ok ? 'ok' : 'error' ) );
1045
1046 return $ok;
1047 }
1048
1356 final public function getWithSetCallback(
1357 $key, $ttl, $callback, array $opts = [], array $cbParams = []
1358 ) {
1359 $version = $opts['version'] ?? null;
1360 $pcTTL = $opts['pcTTL'] ?? self::TTL_UNCACHEABLE;
1361 $pCache = ( $pcTTL >= 0 )
1362 ? $this->getProcessCache( $opts['pcGroup'] ?? self::PC_PRIMARY )
1363 : null;
1364
1365 // Use the process cache if requested as long as no outer cache callback is running.
1366 // Nested callback process cache use is not lag-safe with regard to HOLDOFF_TTL since
1367 // process cached values are more lagged than persistent ones as they are not purged.
1368 if ( $pCache && $this->callbackDepth == 0 ) {
1369 $cached = $pCache->get( $this->getProcessCacheKey( $key, $version ), $pcTTL, false );
1370 if ( $cached !== false ) {
1371 $this->logger->debug( "getWithSetCallback($key): process cache hit" );
1372 return $cached;
1373 }
1374 }
1375
1376 $res = $this->fetchOrRegenerate( $key, $ttl, $callback, $opts, $cbParams );
1377 list( $value, $valueVersion, $curAsOf ) = $res;
1378 if ( $valueVersion !== $version ) {
1379 // Current value has a different version; use the variant key for this version.
1380 // Regenerate the variant value if it is not newer than the main value at $key
1381 // so that purges to the main key propagate to the variant value.
1382 $this->logger->debug( "getWithSetCallback($key): using variant key" );
1383 list( $value ) = $this->fetchOrRegenerate(
1384 $this->makeGlobalKey( 'WANCache-key-variant', md5( $key ), $version ),
1385 $ttl,
1386 $callback,
1387 [ 'version' => null, 'minAsOf' => $curAsOf ] + $opts,
1388 $cbParams
1389 );
1390 }
1391
1392 // Update the process cache if enabled
1393 if ( $pCache && $value !== false ) {
1394 $pCache->set( $this->getProcessCacheKey( $key, $version ), $value );
1395 }
1396
1397 return $value;
1398 }
1399
1416 private function fetchOrRegenerate( $key, $ttl, $callback, array $opts, array $cbParams ) {
1417 $checkKeys = $opts['checkKeys'] ?? [];
1418 $graceTTL = $opts['graceTTL'] ?? self::GRACE_TTL_NONE;
1419 $minAsOf = $opts['minAsOf'] ?? self::MIN_TIMESTAMP_NONE;
1420 $hotTTR = $opts['hotTTR'] ?? self::HOT_TTR;
1421 $lowTTL = $opts['lowTTL'] ?? min( self::LOW_TTL, $ttl );
1422 $ageNew = $opts['ageNew'] ?? self::AGE_NEW;
1423 $touchedCb = $opts['touchedCallback'] ?? null;
1424 $initialTime = $this->getCurrentTime();
1425
1426 $kClass = $this->determineKeyClassForStats( $key );
1427
1428 // Get the current key value and its metadata
1429 $curTTL = self::PASS_BY_REF;
1430 $curInfo = self::PASS_BY_REF;
1431 $curValue = $this->get( $key, $curTTL, $checkKeys, $curInfo );
1433 '@phan-var array $curInfo';
1434 // Apply any $touchedCb invalidation timestamp to get the "last purge timestamp"
1435 list( $curTTL, $LPT ) = $this->resolveCTL( $curValue, $curTTL, $curInfo, $touchedCb );
1436 // Use the cached value if it exists and is not due for synchronous regeneration
1437 if (
1438 $this->isValid( $curValue, $curInfo['asOf'], $minAsOf ) &&
1439 $this->isAliveOrInGracePeriod( $curTTL, $graceTTL )
1440 ) {
1441 $preemptiveRefresh = (
1442 $this->worthRefreshExpiring( $curTTL, $lowTTL ) ||
1443 $this->worthRefreshPopular( $curInfo['asOf'], $ageNew, $hotTTR, $initialTime )
1444 );
1445 if ( !$preemptiveRefresh ) {
1446 $this->stats->increment( "wanobjectcache.$kClass.hit.good" );
1447
1448 return [ $curValue, $curInfo['version'], $curInfo['asOf'] ];
1449 } elseif ( $this->scheduleAsyncRefresh( $key, $ttl, $callback, $opts, $cbParams ) ) {
1450 $this->logger->debug( "fetchOrRegenerate($key): hit with async refresh" );
1451 $this->stats->increment( "wanobjectcache.$kClass.hit.refresh" );
1452
1453 return [ $curValue, $curInfo['version'], $curInfo['asOf'] ];
1454 } else {
1455 $this->logger->debug( "fetchOrRegenerate($key): hit with sync refresh" );
1456 }
1457 }
1458
1459 // Determine if there is stale or volatile cached value that is still usable
1460 $isKeyTombstoned = ( $curInfo['tombAsOf'] !== null );
1461 if ( $isKeyTombstoned ) {
1462 // Key is write-holed; use the (volatile) interim key as an alternative
1463 list( $possValue, $possInfo ) = $this->getInterimValue( $key, $minAsOf );
1464 // Update the "last purge time" since the $touchedCb timestamp depends on $value
1465 $LPT = $this->resolveTouched( $possValue, $LPT, $touchedCb );
1466 } else {
1467 $possValue = $curValue;
1468 $possInfo = $curInfo;
1469 }
1470
1471 // Avoid overhead from callback runs, regeneration locks, and cache sets during
1472 // hold-off periods for the key by reusing very recently generated cached values
1473 if (
1474 $this->isValid( $possValue, $possInfo['asOf'], $minAsOf, $LPT ) &&
1475 $this->isVolatileValueAgeNegligible( $initialTime - $possInfo['asOf'] )
1476 ) {
1477 $this->logger->debug( "fetchOrRegenerate($key): volatile hit" );
1478 $this->stats->increment( "wanobjectcache.$kClass.hit.volatile" );
1479
1480 return [ $possValue, $possInfo['version'], $curInfo['asOf'] ];
1481 }
1482
1483 $lockTSE = $opts['lockTSE'] ?? self::TSE_NONE;
1484 $busyValue = $opts['busyValue'] ?? null;
1485 $staleTTL = $opts['staleTTL'] ?? self::STALE_TTL_NONE;
1486 $version = $opts['version'] ?? null;
1487
1488 // Determine whether one thread per datacenter should handle regeneration at a time
1489 $useRegenerationLock =
1490 // Note that since tombstones no-op set(), $lockTSE and $curTTL cannot be used to
1491 // deduce the key hotness because |$curTTL| will always keep increasing until the
1492 // tombstone expires or is overwritten by a new tombstone. Also, even if $lockTSE
1493 // is not set, constant regeneration of a key for the tombstone lifetime might be
1494 // very expensive. Assume tombstoned keys are possibly hot in order to reduce
1495 // the risk of high regeneration load after the delete() method is called.
1496 $isKeyTombstoned ||
1497 // Assume a key is hot if requested soon ($lockTSE seconds) after invalidation.
1498 // This avoids stampedes when timestamps from $checkKeys/$touchedCb bump.
1499 ( $curTTL !== null && $curTTL <= 0 && abs( $curTTL ) <= $lockTSE ) ||
1500 // Assume a key is hot if there is no value and a busy fallback is given.
1501 // This avoids stampedes on eviction or preemptive regeneration taking too long.
1502 ( $busyValue !== null && $possValue === false );
1503
1504 // If a regeneration lock is required, threads that do not get the lock will try to use
1505 // the stale value, the interim value, or the $busyValue placeholder, in that order. If
1506 // none of those are set then all threads will bypass the lock and regenerate the value.
1507 $hasLock = $useRegenerationLock && $this->claimStampedeLock( $key );
1508 if ( $useRegenerationLock && !$hasLock ) {
1509 if ( $this->isValid( $possValue, $possInfo['asOf'], $minAsOf ) ) {
1510 $this->logger->debug( "fetchOrRegenerate($key): returning stale value" );
1511 $this->stats->increment( "wanobjectcache.$kClass.hit.stale" );
1512
1513 return [ $possValue, $possInfo['version'], $curInfo['asOf'] ];
1514 } elseif ( $busyValue !== null ) {
1515 $miss = is_infinite( $minAsOf ) ? 'renew' : 'miss';
1516 $this->logger->debug( "fetchOrRegenerate($key): busy $miss" );
1517 $this->stats->increment( "wanobjectcache.$kClass.$miss.busy" );
1518
1519 return [ $this->resolveBusyValue( $busyValue ), $version, $curInfo['asOf'] ];
1520 }
1521 }
1522
1523 // Generate the new value given any prior value with a matching version
1524 $setOpts = [];
1525 $preCallbackTime = $this->getCurrentTime();
1526 ++$this->callbackDepth;
1527 try {
1528 $value = $callback(
1529 ( $curInfo['version'] === $version ) ? $curValue : false,
1530 $ttl,
1531 $setOpts,
1532 ( $curInfo['version'] === $version ) ? $curInfo['asOf'] : null,
1533 $cbParams
1534 );
1535 } finally {
1536 --$this->callbackDepth;
1537 }
1538 $postCallbackTime = $this->getCurrentTime();
1539
1540 // How long it took to fetch, validate, and generate the value
1541 $elapsed = max( $postCallbackTime - $initialTime, 0.0 );
1542
1543 // Attempt to save the newly generated value if applicable
1544 if (
1545 // Callback yielded a cacheable value
1546 ( $value !== false && $ttl >= 0 ) &&
1547 // Current thread was not raced out of a regeneration lock or key is tombstoned
1548 ( !$useRegenerationLock || $hasLock || $isKeyTombstoned ) &&
1549 // Key does not appear to be undergoing a set() stampede
1550 $this->checkAndSetCooloff( $key, $kClass, $value, $elapsed, $hasLock )
1551 ) {
1552 // How long it took to generate the value
1553 $walltime = max( $postCallbackTime - $preCallbackTime, 0.0 );
1554 $this->stats->timing( "wanobjectcache.$kClass.regen_walltime", 1e3 * $walltime );
1555 // If the key is write-holed then use the (volatile) interim key as an alternative
1556 if ( $isKeyTombstoned ) {
1557 $this->setInterimValue( $key, $value, $lockTSE, $version, $walltime );
1558 } else {
1559 $finalSetOpts = [
1560 // @phan-suppress-next-line PhanUselessBinaryAddRight
1561 'since' => $setOpts['since'] ?? $preCallbackTime,
1562 'version' => $version,
1563 'staleTTL' => $staleTTL,
1564 'lockTSE' => $lockTSE, // informs lag vs performance trade-offs
1565 'creating' => ( $curValue === false ), // optimization
1566 'walltime' => $walltime
1567 ] + $setOpts;
1568 $this->set( $key, $value, $ttl, $finalSetOpts );
1569 }
1570 }
1571
1572 $this->yieldStampedeLock( $key, $hasLock );
1573
1574 $miss = is_infinite( $minAsOf ) ? 'renew' : 'miss';
1575 $this->logger->debug( "fetchOrRegenerate($key): $miss, new value computed" );
1576 $this->stats->increment( "wanobjectcache.$kClass.$miss.compute" );
1577
1578 return [ $value, $version, $curInfo['asOf'] ];
1579 }
1580
1585 private function claimStampedeLock( $key ) {
1586 // Note that locking is not bypassed due to I/O errors; this avoids stampedes
1587 return $this->cache->add(
1588 $this->makeSisterKey( $key, self::$TYPE_MUTEX ),
1589 1,
1590 self::$LOCK_TTL
1591 );
1592 }
1593
1598 private function yieldStampedeLock( $key, $hasLock ) {
1599 if ( $hasLock ) {
1600 // The backend might be a mcrouter proxy set to broadcast DELETE to *all* the local
1601 // datacenter cache servers via OperationSelectorRoute (for increased consistency).
1602 // Since that would be excessive for these locks, use TOUCH to expire the key.
1603 $this->cache->changeTTL(
1604 $this->makeSisterKey( $key, self::$TYPE_MUTEX ),
1605 $this->getCurrentTime() - 60
1606 );
1607 }
1608 }
1609
1617 private function makeSisterKeys( array $baseKeys, $type ) {
1618 $keys = [];
1619 foreach ( $baseKeys as $baseKey ) {
1620 $keys[] = $this->makeSisterKey( $baseKey, $type );
1621 }
1622
1623 return $keys;
1624 }
1625
1633 private function makeSisterKey( $baseKey, $typeChar ) {
1634 if ( $this->coalesceKeys === 'non-global' ) {
1635 $useColocationScheme = ( strncmp( $baseKey, "global:", 7 ) !== 0 );
1636 } else {
1637 $useColocationScheme = ( $this->coalesceKeys === true );
1638 }
1639
1640 if ( !$useColocationScheme ) {
1641 // Old key style: "WANCache:<character>:<base key>"
1642 $fullKey = 'WANCache:' . $typeChar . ':' . $baseKey;
1643 } elseif ( $this->coalesceScheme === self::SCHEME_HASH_STOP ) {
1644 // Key style: "WANCache:<base key>|#|<character>"
1645 $fullKey = 'WANCache:' . $baseKey . '|#|' . $typeChar;
1646 } else {
1647 // Key style: "WANCache:{<base key>}:<character>"
1648 $fullKey = 'WANCache:{' . $baseKey . '}:' . $typeChar;
1649 }
1650
1651 return $fullKey;
1652 }
1653
1658 private function isVolatileValueAgeNegligible( $age ) {
1659 return ( $age < mt_rand( self::$RECENT_SET_LOW_MS, self::$RECENT_SET_HIGH_MS ) / 1e3 );
1660 }
1661
1683 private function checkAndSetCooloff( $key, $kClass, $value, $elapsed, $hasLock ) {
1684 $valueKey = $this->makeSisterKey( $key, self::$TYPE_VALUE );
1685 list( $estimatedSize ) = $this->cache->setNewPreparedValues( [ $valueKey => $value ] );
1686
1687 if ( !$hasLock ) {
1688 // Suppose that this cache key is very popular (KEY_HIGH_QPS reads/second).
1689 // After eviction, there will be cache misses until it gets regenerated and saved.
1690 // If the time window when the key is missing lasts less than one second, then the
1691 // number of misses will not reach KEY_HIGH_QPS. This window largely corresponds to
1692 // the key regeneration time. Estimate the count/rate of cache misses, e.g.:
1693 // - 100 QPS, 20ms regeneration => ~2 misses (< 1s)
1694 // - 100 QPS, 100ms regeneration => ~10 misses (< 1s)
1695 // - 100 QPS, 3000ms regeneration => ~300 misses (100/s for 3s)
1696 $missesPerSecForHighQPS = ( min( $elapsed, 1 ) * $this->keyHighQps );
1697
1698 // Determine whether there is enough I/O stampede risk to justify throttling set().
1699 // Estimate unthrottled set() overhead, as bps, from miss count/rate and value size,
1700 // comparing it to the per-key uplink bps limit (KEY_HIGH_UPLINK_BPS), e.g.:
1701 // - 2 misses (< 1s), 10KB value, 1250000 bps limit => 160000 bits (low risk)
1702 // - 2 misses (< 1s), 100KB value, 1250000 bps limit => 1600000 bits (high risk)
1703 // - 10 misses (< 1s), 10KB value, 1250000 bps limit => 800000 bits (low risk)
1704 // - 10 misses (< 1s), 100KB value, 1250000 bps limit => 8000000 bits (high risk)
1705 // - 300 misses (100/s), 1KB value, 1250000 bps limit => 800000 bps (low risk)
1706 // - 300 misses (100/s), 10KB value, 1250000 bps limit => 8000000 bps (high risk)
1707 // - 300 misses (100/s), 100KB value, 1250000 bps limit => 80000000 bps (high risk)
1708 if ( ( $missesPerSecForHighQPS * $estimatedSize ) >= $this->keyHighUplinkBps ) {
1709 $this->cache->clearLastError();
1710 if (
1711 !$this->cache->add(
1712 $this->makeSisterKey( $key, self::$TYPE_COOLOFF ),
1713 1,
1714 self::$COOLOFF_TTL
1715 ) &&
1716 // Don't treat failures due to I/O errors as the key being in cooloff
1717 $this->cache->getLastError() === BagOStuff::ERR_NONE
1718 ) {
1719 $this->stats->increment( "wanobjectcache.$kClass.cooloff_bounce" );
1720
1721 return false;
1722 }
1723 }
1724 }
1725
1726 // Corresponding metrics for cache writes that actually get sent over the write
1727 $this->stats->timing( "wanobjectcache.$kClass.regen_set_delay", 1e3 * $elapsed );
1728 $this->stats->updateCount( "wanobjectcache.$kClass.regen_set_bytes", $estimatedSize );
1729
1730 return true;
1731 }
1732
1741 private function resolveCTL( $value, $curTTL, $curInfo, $touchedCallback ) {
1742 if ( $touchedCallback === null || $value === false ) {
1743 return [ $curTTL, max( $curInfo['tombAsOf'], $curInfo['lastCKPurge'] ) ];
1744 }
1745
1746 $touched = $touchedCallback( $value );
1747 if ( $touched !== null && $touched >= $curInfo['asOf'] ) {
1748 $curTTL = min( $curTTL, self::$TINY_NEGATIVE, $curInfo['asOf'] - $touched );
1749 }
1750
1751 return [ $curTTL, max( $curInfo['tombAsOf'], $curInfo['lastCKPurge'], $touched ) ];
1752 }
1753
1761 private function resolveTouched( $value, $lastPurge, $touchedCallback ) {
1762 return ( $touchedCallback === null || $value === false )
1763 ? $lastPurge // nothing to derive the "touched timestamp" from
1764 : max( $touchedCallback( $value ), $lastPurge );
1765 }
1766
1772 private function getInterimValue( $key, $minAsOf ) {
1773 $now = $this->getCurrentTime();
1774
1775 if ( $this->useInterimHoldOffCaching ) {
1776 $wrapped = $this->cache->get(
1777 $this->makeSisterKey( $key, self::$TYPE_INTERIM )
1778 );
1779
1780 list( $value, $keyInfo ) = $this->unwrap( $wrapped, $now );
1781 if ( $this->isValid( $value, $keyInfo['asOf'], $minAsOf ) ) {
1782 return [ $value, $keyInfo ];
1783 }
1784 }
1785
1786 return $this->unwrap( false, $now );
1787 }
1788
1796 private function setInterimValue( $key, $value, $ttl, $version, $walltime ) {
1797 $ttl = max( self::$INTERIM_KEY_TTL, (int)$ttl );
1798
1799 $wrapped = $this->wrap( $value, $ttl, $version, $this->getCurrentTime(), $walltime );
1800 $this->cache->merge(
1801 $this->makeSisterKey( $key, self::$TYPE_INTERIM ),
1802 function () use ( $wrapped ) {
1803 return $wrapped;
1804 },
1805 $ttl,
1806 1
1807 );
1808 }
1809
1814 private function resolveBusyValue( $busyValue ) {
1815 return ( $busyValue instanceof Closure ) ? $busyValue() : $busyValue;
1816 }
1817
1882 final public function getMultiWithSetCallback(
1883 ArrayIterator $keyedIds, $ttl, callable $callback, array $opts = []
1884 ) {
1885 // Load required keys into process cache in one go
1886 $this->warmupCache = $this->getRawKeysForWarmup(
1887 $this->getNonProcessCachedMultiKeys( $keyedIds, $opts ),
1888 $opts['checkKeys'] ?? []
1889 );
1890 $this->warmupKeyMisses = 0;
1891
1892 // The required callback signature includes $id as the first argument for convenience
1893 // to distinguish different items. To reuse the code in getWithSetCallback(), wrap the
1894 // callback with a proxy callback that has the standard getWithSetCallback() signature.
1895 // This is defined only once per batch to avoid closure creation overhead.
1896 $proxyCb = function ( $oldValue, &$ttl, &$setOpts, $oldAsOf, $params ) use ( $callback ) {
1897 return $callback( $params['id'], $oldValue, $ttl, $setOpts, $oldAsOf );
1898 };
1899
1900 $values = [];
1901 foreach ( $keyedIds as $key => $id ) { // preserve order
1902 $values[$key] = $this->getWithSetCallback(
1903 $key,
1904 $ttl,
1905 $proxyCb,
1906 $opts,
1907 [ 'id' => $id ]
1908 );
1909 }
1910
1911 $this->warmupCache = [];
1912
1913 return $values;
1914 }
1915
1981 final public function getMultiWithUnionSetCallback(
1982 ArrayIterator $keyedIds, $ttl, callable $callback, array $opts = []
1983 ) {
1984 $checkKeys = $opts['checkKeys'] ?? [];
1985 unset( $opts['lockTSE'] ); // incompatible
1986 unset( $opts['busyValue'] ); // incompatible
1987
1988 // Load required keys into process cache in one go
1989 $keysByIdGet = $this->getNonProcessCachedMultiKeys( $keyedIds, $opts );
1990 $this->warmupCache = $this->getRawKeysForWarmup( $keysByIdGet, $checkKeys );
1991 $this->warmupKeyMisses = 0;
1992
1993 // IDs of entities known to be in need of regeneration
1994 $idsRegen = [];
1995
1996 // Find out which keys are missing/deleted/stale
1997 $curTTLs = [];
1998 $asOfs = [];
1999 $curByKey = $this->getMulti( $keysByIdGet, $curTTLs, $checkKeys, $asOfs );
2000 foreach ( $keysByIdGet as $id => $key ) {
2001 if ( !array_key_exists( $key, $curByKey ) || $curTTLs[$key] < 0 ) {
2002 $idsRegen[] = $id;
2003 }
2004 }
2005
2006 // Run the callback to populate the regeneration value map for all required IDs
2007 $newSetOpts = [];
2008 $newTTLsById = array_fill_keys( $idsRegen, $ttl );
2009 $newValsById = $idsRegen ? $callback( $idsRegen, $newTTLsById, $newSetOpts ) : [];
2010
2011 // The required callback signature includes $id as the first argument for convenience
2012 // to distinguish different items. To reuse the code in getWithSetCallback(), wrap the
2013 // callback with a proxy callback that has the standard getWithSetCallback() signature.
2014 // This is defined only once per batch to avoid closure creation overhead.
2015 $proxyCb = function ( $oldValue, &$ttl, &$setOpts, $oldAsOf, $params )
2016 use ( $callback, $newValsById, $newTTLsById, $newSetOpts )
2017 {
2018 $id = $params['id'];
2019
2020 if ( array_key_exists( $id, $newValsById ) ) {
2021 // Value was already regerated as expected, so use the value in $newValsById
2022 $newValue = $newValsById[$id];
2023 $ttl = $newTTLsById[$id];
2024 $setOpts = $newSetOpts;
2025 } else {
2026 // Pre-emptive/popularity refresh and version mismatch cases are not detected
2027 // above and thus $newValsById has no entry. Run $callback on this single entity.
2028 $ttls = [ $id => $ttl ];
2029 $newValue = $callback( [ $id ], $ttls, $setOpts )[$id];
2030 $ttl = $ttls[$id];
2031 }
2032
2033 return $newValue;
2034 };
2035
2036 // Run the cache-aside logic using warmupCache instead of persistent cache queries
2037 $values = [];
2038 foreach ( $keyedIds as $key => $id ) { // preserve order
2039 $values[$key] = $this->getWithSetCallback(
2040 $key,
2041 $ttl,
2042 $proxyCb,
2043 $opts,
2044 [ 'id' => $id ]
2045 );
2046 }
2047
2048 $this->warmupCache = [];
2049
2050 return $values;
2051 }
2052
2065 final public function reap( $key, $purgeTimestamp, &$isStale = false ) {
2066 $minAsOf = $purgeTimestamp + self::HOLDOFF_TTL;
2067 $wrapped = $this->cache->get( $this->makeSisterKey( $key, self::$TYPE_VALUE ) );
2068 if ( is_array( $wrapped ) && $wrapped[self::$FLD_TIME] < $minAsOf ) {
2069 $isStale = true;
2070 $this->logger->warning( "Reaping stale value key '$key'." );
2071 $ttlReap = self::HOLDOFF_TTL; // avoids races with tombstone creation
2072 $ok = $this->cache->changeTTL(
2073 $this->makeSisterKey( $key, self::$TYPE_VALUE ),
2074 $ttlReap
2075 );
2076 if ( !$ok ) {
2077 $this->logger->error( "Could not complete reap of key '$key'." );
2078 }
2079
2080 return $ok;
2081 }
2082
2083 $isStale = false;
2084
2085 return true;
2086 }
2087
2097 final public function reapCheckKey( $key, $purgeTimestamp, &$isStale = false ) {
2098 $purge = $this->parsePurgeValue(
2099 $this->cache->get( $this->makeSisterKey( $key, self::$TYPE_TIMESTAMP ) )
2100 );
2101 if ( $purge && $purge[self::$PURGE_TIME] < $purgeTimestamp ) {
2102 $isStale = true;
2103 $this->logger->warning( "Reaping stale check key '$key'." );
2104 $ok = $this->cache->changeTTL(
2105 $this->makeSisterKey( $key, self::$TYPE_TIMESTAMP ),
2106 self::TTL_SECOND
2107 );
2108 if ( !$ok ) {
2109 $this->logger->error( "Could not complete reap of check key '$key'." );
2110 }
2111
2112 return $ok;
2113 }
2114
2115 $isStale = false;
2116
2117 return false;
2118 }
2119
2127 public function makeKey( $class, ...$components ) {
2128 return $this->cache->makeKey( ...func_get_args() );
2129 }
2130
2138 public function makeGlobalKey( $class, ...$components ) {
2139 return $this->cache->makeGlobalKey( ...func_get_args() );
2140 }
2141
2149 public function hash256( $component ) {
2150 return hash_hmac( 'sha256', $component, $this->secret );
2151 }
2152
2203 final public function makeMultiKeys( array $ids, $keyCallback ) {
2204 $idByKey = [];
2205 foreach ( $ids as $id ) {
2206 // Discourage triggering of automatic makeKey() hashing in some backends
2207 if ( strlen( $id ) > 64 ) {
2208 $this->logger->warning( __METHOD__ . ": long ID '$id'; use hash256()" );
2209 }
2210 $key = $keyCallback( $id, $this );
2211 // Edge case: ignore key collisions due to duplicate $ids like "42" and 42
2212 if ( !isset( $idByKey[$key] ) ) {
2213 $idByKey[$key] = $id;
2214 } elseif ( (string)$id !== (string)$idByKey[$key] ) {
2215 throw new UnexpectedValueException(
2216 "Cache key collision; IDs ('$id','{$idByKey[$key]}') map to '$key'"
2217 );
2218 }
2219 }
2220
2221 return new ArrayIterator( $idByKey );
2222 }
2223
2259 final public function multiRemap( array $ids, array $res ) {
2260 if ( count( $ids ) !== count( $res ) ) {
2261 // If makeMultiKeys() is called on a list of non-unique IDs, then the resulting
2262 // ArrayIterator will have less entries due to "first appearance" de-duplication
2263 $ids = array_keys( array_flip( $ids ) );
2264 if ( count( $ids ) !== count( $res ) ) {
2265 throw new UnexpectedValueException( "Multi-key result does not match ID list" );
2266 }
2267 }
2268
2269 return array_combine( $ids, $res );
2270 }
2271
2276 final public function getLastError() {
2277 $code = $this->cache->getLastError();
2278 switch ( $code ) {
2279 case BagOStuff::ERR_NONE:
2280 return self::ERR_NONE;
2281 case BagOStuff::ERR_NO_RESPONSE:
2282 return self::ERR_NO_RESPONSE;
2283 case BagOStuff::ERR_UNREACHABLE:
2284 return self::ERR_UNREACHABLE;
2285 default:
2286 return self::ERR_UNEXPECTED;
2287 }
2288 }
2289
2293 final public function clearLastError() {
2294 $this->cache->clearLastError();
2295 }
2296
2302 public function clearProcessCache() {
2303 $this->processCaches = [];
2304 }
2305
2326 final public function useInterimHoldOffCaching( $enabled ) {
2327 $this->useInterimHoldOffCaching = $enabled;
2328 }
2329
2335 public function getQoS( $flag ) {
2336 return $this->cache->getQoS( $flag );
2337 }
2338
2402 public function adaptiveTTL( $mtime, $maxTTL, $minTTL = 30, $factor = 0.2 ) {
2403 $mtime = (int)$mtime; // handle fractional seconds and string integers
2404 if ( $mtime <= 0 ) {
2405 return $minTTL; // no last-modified time provided
2406 }
2407
2408 $age = (int)$this->getCurrentTime() - $mtime;
2409
2410 return (int)min( $maxTTL, max( $minTTL, $factor * $age ) );
2411 }
2412
2417 final public function getWarmupKeyMisses() {
2418 return $this->warmupKeyMisses;
2419 }
2420
2431 protected function relayPurge( $key, $ttl, $holdoff ) {
2432 if ( $this->mcrouterAware ) {
2433 // See https://github.com/facebook/mcrouter/wiki/Multi-cluster-broadcast-setup
2434 // Wildcards select all matching routes, e.g. the WAN cluster on all DCs
2435 $ok = $this->cache->set(
2436 "/*/{$this->cluster}/{$key}",
2437 $this->makePurgeValue( $this->getCurrentTime(), $holdoff ),
2438 $ttl
2439 );
2440 } else {
2441 // Some other proxy handles broadcasting or there is only one datacenter
2442 $ok = $this->cache->set(
2443 $key,
2444 $this->makePurgeValue( $this->getCurrentTime(), $holdoff ),
2445 $ttl
2446 );
2447 }
2448
2449 return $ok;
2450 }
2451
2458 protected function relayDelete( $key ) {
2459 if ( $this->mcrouterAware ) {
2460 // See https://github.com/facebook/mcrouter/wiki/Multi-cluster-broadcast-setup
2461 // Wildcards select all matching routes, e.g. the WAN cluster on all DCs
2462 $ok = $this->cache->delete( "/*/{$this->cluster}/{$key}" );
2463 } else {
2464 // Some other proxy handles broadcasting or there is only one datacenter
2465 $ok = $this->cache->delete( $key );
2466 }
2467
2468 return $ok;
2469 }
2470
2482 private function scheduleAsyncRefresh( $key, $ttl, $callback, array $opts, array $cbParams ) {
2483 if ( !$this->asyncHandler ) {
2484 return false;
2485 }
2486 // Update the cache value later, such during post-send of an HTTP request. This forces
2487 // cache regeneration by setting "minAsOf" to infinity, meaning that no existing value
2488 // is considered valid. Furthermore, note that preemptive regeneration is not applicable
2489 // to invalid values, so there is no risk of infinite preemptive regeneration loops.
2490 $func = $this->asyncHandler;
2491 $func( function () use ( $key, $ttl, $callback, $opts, $cbParams ) {
2492 $opts['minAsOf'] = INF;
2493 $this->fetchOrRegenerate( $key, $ttl, $callback, $opts, $cbParams );
2494 } );
2495
2496 return true;
2497 }
2498
2512 private function isAliveOrInGracePeriod( $curTTL, $graceTTL ) {
2513 if ( $curTTL > 0 ) {
2514 return true;
2515 } elseif ( $graceTTL <= 0 ) {
2516 return false;
2517 }
2518
2519 $ageStale = abs( $curTTL ); // seconds of staleness
2520 $curGTTL = ( $graceTTL - $ageStale ); // current grace-time-to-live
2521 if ( $curGTTL <= 0 ) {
2522 return false; // already out of grace period
2523 }
2524
2525 // Chance of using a stale value is the complement of the chance of refreshing it
2526 return !$this->worthRefreshExpiring( $curGTTL, $graceTTL );
2527 }
2528
2542 protected function worthRefreshExpiring( $curTTL, $lowTTL ) {
2543 if ( $lowTTL <= 0 ) {
2544 return false;
2545 } elseif ( $curTTL >= $lowTTL ) {
2546 return false;
2547 } elseif ( $curTTL <= 0 ) {
2548 return false;
2549 }
2550
2551 $chance = ( 1 - $curTTL / $lowTTL );
2552
2553 // @phan-suppress-next-line PhanTypeMismatchArgumentInternal
2554 $decision = ( mt_rand( 1, 1e9 ) <= 1e9 * $chance );
2555
2556 $this->logger->debug(
2557 "worthRefreshExpiring($curTTL, $lowTTL): " .
2558 "p = $chance; refresh = " . ( $decision ? 'Y' : 'N' )
2559 );
2560
2561 return $decision;
2562 }
2563
2579 protected function worthRefreshPopular( $asOf, $ageNew, $timeTillRefresh, $now ) {
2580 if ( $ageNew < 0 || $timeTillRefresh <= 0 ) {
2581 return false;
2582 }
2583
2584 $age = $now - $asOf;
2585 $timeOld = $age - $ageNew;
2586 if ( $timeOld <= 0 ) {
2587 return false;
2588 }
2589
2590 $popularHitsPerSec = 1;
2591 // Lifecycle is: new, ramp-up refresh chance, full refresh chance.
2592 // Note that the "expected # of refreshes" for the ramp-up time range is half
2593 // of what it would be if P(refresh) was at its full value during that time range.
2594 $refreshWindowSec = max( $timeTillRefresh - $ageNew - self::$RAMPUP_TTL / 2, 1 );
2595 // P(refresh) * (# hits in $refreshWindowSec) = (expected # of refreshes)
2596 // P(refresh) * ($refreshWindowSec * $popularHitsPerSec) = 1 (by definition)
2597 // P(refresh) = 1/($refreshWindowSec * $popularHitsPerSec)
2598 $chance = 1 / ( $popularHitsPerSec * $refreshWindowSec );
2599 // Ramp up $chance from 0 to its nominal value over RAMPUP_TTL seconds to avoid stampedes
2600 $chance *= ( $timeOld <= self::$RAMPUP_TTL ) ? $timeOld / self::$RAMPUP_TTL : 1;
2601
2602 // @phan-suppress-next-line PhanTypeMismatchArgumentInternal
2603 $decision = ( mt_rand( 1, 1e9 ) <= 1e9 * $chance );
2604
2605 $this->logger->debug(
2606 "worthRefreshPopular($asOf, $ageNew, $timeTillRefresh, $now): " .
2607 "p = $chance; refresh = " . ( $decision ? 'Y' : 'N' )
2608 );
2609
2610 return $decision;
2611 }
2612
2622 protected function isValid( $value, $asOf, $minAsOf, $purgeTime = null ) {
2623 // Avoid reading any key not generated after the latest delete() or touch
2624 $safeMinAsOf = max( $minAsOf, $purgeTime + self::$TINY_POSTIVE );
2625
2626 if ( $value === false ) {
2627 return false;
2628 } elseif ( $safeMinAsOf > 0 && $asOf < $minAsOf ) {
2629 return false;
2630 }
2631
2632 return true;
2633 }
2634
2643 private function wrap( $value, $ttl, $version, $now, $walltime ) {
2644 // Returns keys in ascending integer order for PHP7 array packing:
2645 // https://nikic.github.io/2014/12/22/PHPs-new-hashtable-implementation.html
2646 $wrapped = [
2647 self::$FLD_FORMAT_VERSION => self::$VERSION,
2648 self::$FLD_VALUE => $value,
2649 self::$FLD_TTL => $ttl,
2650 self::$FLD_TIME => $now
2651 ];
2652 if ( $version !== null ) {
2653 $wrapped[self::$FLD_VALUE_VERSION] = $version;
2654 }
2655 if ( $walltime >= self::$GENERATION_SLOW_SEC ) {
2656 $wrapped[self::$FLD_GENERATION_TIME] = $walltime;
2657 }
2658
2659 return $wrapped;
2660 }
2661
2673 private function unwrap( $wrapped, $now ) {
2674 $value = false;
2675 $info = [ 'asOf' => null, 'curTTL' => null, 'version' => null, 'tombAsOf' => null ];
2676
2677 if ( is_array( $wrapped ) ) {
2678 // Entry expected to be a cached value; validate it
2679 if (
2680 ( $wrapped[self::$FLD_FORMAT_VERSION] ?? null ) === self::$VERSION &&
2681 $wrapped[self::$FLD_TIME] >= $this->epoch
2682 ) {
2683 if ( $wrapped[self::$FLD_TTL] > 0 ) {
2684 // Get the approximate time left on the key
2685 $age = $now - $wrapped[self::$FLD_TIME];
2686 $curTTL = max( $wrapped[self::$FLD_TTL] - $age, 0.0 );
2687 } else {
2688 // Key had no TTL, so the time left is unbounded
2689 $curTTL = INF;
2690 }
2691 $value = $wrapped[self::$FLD_VALUE];
2692 $info['version'] = $wrapped[self::$FLD_VALUE_VERSION] ?? null;
2693 $info['asOf'] = $wrapped[self::$FLD_TIME];
2694 $info['curTTL'] = $curTTL;
2695 }
2696 } else {
2697 // Entry expected to be a tombstone; parse it
2698 $purge = $this->parsePurgeValue( $wrapped );
2699 if ( $purge !== false ) {
2700 // Tombstoned keys should always have a negative current $ttl
2701 $info['curTTL'] = min( $purge[self::$PURGE_TIME] - $now, self::$TINY_NEGATIVE );
2702 $info['tombAsOf'] = $purge[self::$PURGE_TIME];
2703 }
2704 }
2705
2706 return [ $value, $info ];
2707 }
2708
2713 private function determineKeyClassForStats( $key ) {
2714 $parts = explode( ':', $key, 3 );
2715 // Sanity fallback in case the key was not made by makeKey.
2716 // Replace dots because they are special in StatsD (T232907)
2717 return strtr( $parts[1] ?? $parts[0], '.', '_' );
2718 }
2719
2725 private function parsePurgeValue( $value ) {
2726 if ( !is_string( $value ) ) {
2727 return false;
2728 }
2729
2730 $segments = explode( ':', $value, 3 );
2731 if (
2732 !isset( $segments[0] ) ||
2733 !isset( $segments[1] ) ||
2734 "{$segments[0]}:" !== self::$PURGE_VAL_PREFIX
2735 ) {
2736 return false;
2737 }
2738
2739 if ( !isset( $segments[2] ) ) {
2740 // Back-compat with old purge values without holdoff
2741 $segments[2] = self::HOLDOFF_TTL;
2742 }
2743
2744 if ( $segments[1] < $this->epoch ) {
2745 // Values this old are ignored
2746 return false;
2747 }
2748
2749 return [
2750 self::$PURGE_TIME => (float)$segments[1],
2751 self::$PURGE_HOLDOFF => (int)$segments[2],
2752 ];
2753 }
2754
2760 private function makePurgeValue( $timestamp, $holdoff ) {
2761 return self::$PURGE_VAL_PREFIX . (float)$timestamp . ':' . (int)$holdoff;
2762 }
2763
2768 private function getProcessCache( $group ) {
2769 if ( !isset( $this->processCaches[$group] ) ) {
2770 list( , $size ) = explode( ':', $group );
2771 $this->processCaches[$group] = new MapCacheLRU( (int)$size );
2772 if ( $this->wallClockOverride !== null ) {
2773 $this->processCaches[$group]->setMockTime( $this->wallClockOverride );
2774 }
2775 }
2776
2777 return $this->processCaches[$group];
2778 }
2779
2785 private function getProcessCacheKey( $key, $version ) {
2786 return $key . ' ' . (int)$version;
2787 }
2788
2794 private function getNonProcessCachedMultiKeys( ArrayIterator $keys, array $opts ) {
2795 $pcTTL = $opts['pcTTL'] ?? self::TTL_UNCACHEABLE;
2796
2797 $keysMissing = [];
2798 if ( $pcTTL > 0 && $this->callbackDepth == 0 ) {
2799 $version = $opts['version'] ?? null;
2800 $pCache = $this->getProcessCache( $opts['pcGroup'] ?? self::PC_PRIMARY );
2801 foreach ( $keys as $key => $id ) {
2802 if ( !$pCache->has( $this->getProcessCacheKey( $key, $version ), $pcTTL ) ) {
2803 $keysMissing[$id] = $key;
2804 }
2805 }
2806 }
2807
2808 return $keysMissing;
2809 }
2810
2816 private function getRawKeysForWarmup( array $keys, array $checkKeys ) {
2817 if ( !$keys ) {
2818 return [];
2819 }
2820
2821 // Get all the value keys to fetch...
2822 $keysWarmup = $this->makeSisterKeys( $keys, self::$TYPE_VALUE );
2823 // Get all the check keys to fetch...
2824 foreach ( $checkKeys as $i => $checkKeyOrKeyGroup ) {
2825 // Note: avoid array_merge() inside loop in case there are many keys
2826 if ( is_int( $i ) ) {
2827 // Single check key that applies to all value keys
2828 $keysWarmup[] = $this->makeSisterKey( $checkKeyOrKeyGroup, self::$TYPE_TIMESTAMP );
2829 } else {
2830 // List of check keys that apply to a specific value key
2831 foreach ( (array)$checkKeyOrKeyGroup as $checkKey ) {
2832 $keysWarmup[] = $this->makeSisterKey( $checkKey, self::$TYPE_TIMESTAMP );
2833 }
2834 }
2835 }
2836
2837 $warmupCache = $this->cache->getMulti( $keysWarmup );
2838 $warmupCache += array_fill_keys( $keysWarmup, false );
2839
2840 return $warmupCache;
2841 }
2842
2847 protected function getCurrentTime() {
2848 if ( $this->wallClockOverride ) {
2849 return $this->wallClockOverride;
2850 }
2851
2852 $clockTime = (float)time(); // call this first
2853 // microtime() uses an initial gettimeofday() call added to usage clocks.
2854 // This can severely drift from time() and the microtime() value of other threads
2855 // due to undercounting of the amount of time elapsed. Instead of seeing the current
2856 // time as being in the past, use the value of time(). This avoids setting cache values
2857 // that will immediately be seen as expired and possibly cause stampedes.
2858 return max( microtime( true ), $clockTime );
2859 }
2860
2865 public function setMockTime( &$time ) {
2866 $this->wallClockOverride =& $time;
2867 $this->cache->setMockTime( $time );
2868 foreach ( $this->processCaches as $pCache ) {
2869 $pCache->setMockTime( $time );
2870 }
2871 }
2872}
Class representing a cache/ephemeral data store.
Definition BagOStuff.php:71
A BagOStuff object with no objects in it.
Handles a simple LRU key/value map with a maximum number of entries.
Multi-datacenter aware caching interface.
int $callbackDepth
Callback stack depth for getWithSetCallback()
__construct(array $params)
static int $FLD_FLAGS
@noinspection PhpUnusedPrivateFieldInspection
resolveCTL( $value, $curTTL, $curInfo, $touchedCallback)
unwrap( $wrapped, $now)
static int $PURGE_TIME
Key to the tombstone entry timestamp.
worthRefreshPopular( $asOf, $ageNew, $timeTillRefresh, $now)
Check if a key is due for randomized regeneration due to its popularity.
static int $FLD_GENERATION_TIME
Key to how long it took to generate the value.
fetchOrRegenerate( $key, $ttl, $callback, array $opts, array $cbParams)
Do the actual I/O for getWithSetCallback() when needed.
multiRemap(array $ids, array $res)
Get an (ID => value) map from (i) a non-unique list of entity IDs, and (ii) the list of corresponding...
determineKeyClassForStats( $key)
static int $FLD_VALUE_VERSION
Key to collection cache version number.
touchCheckKey( $key, $holdoff=self::HOLDOFF_TTL)
Purge a "check" key from all datacenters, invalidating keys that use it.
makeSisterKeys(array $baseKeys, $type)
Get cache keys that should be collocated with their corresponding base keys.
isValid( $value, $asOf, $minAsOf, $purgeTime=null)
Check if $value is not false, versioned (if needed), and not older than $minTime (if set)
static string $TYPE_TIMESTAMP
Single character timestamp key component.
string bool $coalesceKeys
Whether "sister" keys should be coalesced to the same cache server.
adaptiveTTL( $mtime, $maxTTL, $minTTL=30, $factor=0.2)
Get a TTL that is higher for objects that have not changed recently.
string $cluster
Cache cluster name for mcrouter use.
static int $LOCK_TTL
Seconds to keep lock keys around.
isVolatileValueAgeNegligible( $age)
int $warmupKeyMisses
Key fetched.
getProcessCacheKey( $key, $version)
float null $wallClockOverride
static string $TYPE_INTERIM
Single character interium key component.
mixed[] $warmupCache
Temporary warm-up cache.
static int $RAMPUP_TTL
Seconds to ramp up the chance of regeneration due to expected time-till-refresh.
static int $PURGE_HOLDOFF
Key to the tombstone entry hold-off TTL.
getMulti(array $keys, &$curTTLs=[], array $checkKeys=[], &$info=null)
Fetch the value of several keys from cache.
relayPurge( $key, $ttl, $holdoff)
Do the actual async bus purge of a key.
getLastError()
Get the "last error" registered; clearLastError() should be called manually.
BagOStuff $cache
The local datacenter cache.
static int $FLD_TTL
Key to the original TTL.
scheduleAsyncRefresh( $key, $ttl, $callback, array $opts, array $cbParams)
Schedule a deferred cache regeneration if possible.
processCheckKeys(array $timeKeys, array $wrappedValues, $now)
getWithSetCallback( $key, $ttl, $callback, array $opts=[], array $cbParams=[])
Method to fetch/regenerate a cache key.
getCheckKeyTime( $key)
Fetch the value of a timestamp "check" key.
getNonProcessCachedMultiKeys(ArrayIterator $keys, array $opts)
relayDelete( $key)
Do the actual async bus delete of a key.
getMultiWithUnionSetCallback(ArrayIterator $keyedIds, $ttl, callable $callback, array $opts=[])
Method to fetch/regenerate multiple cache keys at once.
static string $TYPE_COOLOFF
Single character cool-off key component.
LoggerInterface $logger
getMultiWithSetCallback(ArrayIterator $keyedIds, $ttl, callable $callback, array $opts=[])
Method to fetch multiple cache keys at once with regeneration.
static string $TYPE_VALUE
Single character value mutex key component.
wrap( $value, $ttl, $version, $now, $walltime)
makeMultiKeys(array $ids, $keyCallback)
Get an iterator of (cache key => entity ID) for a list of entity IDs.
static newEmpty()
Get an instance that wraps EmptyBagOStuff.
worthRefreshExpiring( $curTTL, $lowTTL)
Check if a key is nearing expiration and thus due for randomized regeneration.
makeSisterKey( $baseKey, $typeChar)
Get a cache key that should be collocated with a base key.
int $coalesceScheme
Scheme to use for key coalescing (Hash Tags or Hash Stops)
bool $useInterimHoldOffCaching
Whether to use "interim" caching while keys are tombstoned.
static float $TINY_NEGATIVE
Tiny negative float to use when CTL comes up >= 0 due to clock skew.
makeGlobalKey( $class,... $components)
resolveTouched( $value, $lastPurge, $touchedCallback)
makeKey( $class,... $components)
setInterimValue( $key, $value, $ttl, $version, $walltime)
useInterimHoldOffCaching( $enabled)
Enable or disable the use of brief caching for tombstoned keys.
static int $CHECK_KEY_TTL
Seconds to keep dependency purge keys around.
static int $COOLOFF_TTL
Seconds to no-op key set() calls to avoid large blob I/O stampedes.
StatsdDataFactoryInterface $stats
clearProcessCache()
Clear the in-process caches; useful for testing.
string $region
Physical region for mcrouter use.
static int $INTERIM_KEY_TTL
Seconds to keep interim value keys for tombstoned keys around.
static string $TYPE_MUTEX
Single character mutex key component.
float $epoch
Unix timestamp of the oldest possible valid values.
callable null $asyncHandler
Function that takes a WAN cache callback and runs it later.
resolveBusyValue( $busyValue)
reap( $key, $purgeTimestamp, &$isStale=false)
Set a key to soon expire in the local cluster if it pre-dates $purgeTimestamp.
makePurgeValue( $timestamp, $holdoff)
getRawKeysForWarmup(array $keys, array $checkKeys)
setLogger(LoggerInterface $logger)
static string $PURGE_VAL_PREFIX
Prefix for tombstone key values.
static int $FLD_FORMAT_VERSION
Key to WAN cache version number.
reapCheckKey( $key, $purgeTimestamp, &$isStale=false)
Set a "check" key to soon expire in the local cluster if it pre-dates $purgeTimestamp.
static int $RECENT_SET_LOW_MS
Min millisecond set() backoff during hold-off (far less than INTERIM_KEY_TTL)
checkAndSetCooloff( $key, $kClass, $value, $elapsed, $hasLock)
Check whether set() is rate-limited to avoid concurrent I/O spikes.
float $keyHighUplinkBps
Max tolerable bytes/second to spend on a cache write stampede for a key.
static int $GENERATION_SLOW_SEC
Consider value generation slow if it takes more than this many seconds.
static int $FLD_TIME
Key to the cache timestamp.
clearLastError()
Clear the "last error" registry.
static int $RECENT_SET_HIGH_MS
Max millisecond set() backoff during hold-off (far less than INTERIM_KEY_TTL)
static int $FLD_VALUE
Key to the cached value.
MapCacheLRU[] $processCaches
Map of group PHP instance caches.
static float $TINY_POSTIVE
Tiny positive float to use when using "minTime" to assert an inequality.
string $secret
Stable secret used for hasing long strings into key components.
resetCheckKey( $key)
Delete a "check" key from all datacenters, invalidating keys that use it.
int $keyHighQps
Reads/second assumed during a hypothetical cache write stampede for a key.
static int $VERSION
Cache format version number.
getInterimValue( $key, $minAsOf)
yieldStampedeLock( $key, $hasLock)
isAliveOrInGracePeriod( $curTTL, $graceTTL)
Check if a key is fresh or in the grace window and thus due for randomized reuse.
hash256( $component)
Hash a possibly long string into a suitable component for makeKey()/makeGlobalKey()
getMultiCheckKeyTime(array $keys)
Fetch the values of each timestamp "check" key.
bool $mcrouterAware
Whether to use mcrouter key prefixing for routing.
Generic interface for object stores with key encoding methods.
Generic interface providing Time-To-Live constants for expirable object storage.
Generic interface providing error code and quality-of-service constants for object stores.
$cache
Definition mcc.php:33
return true
Definition router.php:92