MediaWiki master
WANObjectCache.php
Go to the documentation of this file.
1<?php
21namespace Wikimedia\ObjectCache;
22
23use ArrayIterator;
24use Closure;
25use Exception;
26use MapCacheLRU;
27use Psr\Log\LoggerAwareInterface;
28use Psr\Log\LoggerInterface;
29use Psr\Log\NullLogger;
30use RuntimeException;
31use UnexpectedValueException;
35
161class WANObjectCache implements
165 LoggerAwareInterface
166{
168 protected $cache;
170 protected $processCaches = [];
172 protected $logger;
174 protected $stats;
176 protected $asyncHandler;
177
189 protected $epoch;
191 protected $secret;
194
196 private $missLog;
197
199 private $callbackDepth = 0;
201 private $warmupCache = [];
203 private $warmupKeyMisses = 0;
204
206 private $wallClockOverride;
207
209 private const MAX_COMMIT_DELAY = 3;
211 private const MAX_READ_LAG = 7;
213 public const HOLDOFF_TTL = self::MAX_COMMIT_DELAY + self::MAX_READ_LAG + 1;
214
216 private const LOW_TTL = 60;
218 public const TTL_LAGGED = 30;
219
221 private const HOT_TTR = 900;
223 private const AGE_NEW = 60;
224
226 private const TSE_NONE = -1;
227
229 public const STALE_TTL_NONE = 0;
231 public const GRACE_TTL_NONE = 0;
233 public const HOLDOFF_TTL_NONE = 0;
234
236 public const MIN_TIMESTAMP_NONE = 0.0;
237
239 private const PC_PRIMARY = 'primary:1000';
240
242 public const PASS_BY_REF = [];
243
245 private const SCHEME_HASH_TAG = 1;
247 private const SCHEME_HASH_STOP = 2;
248
250 private const CHECK_KEY_TTL = self::TTL_YEAR;
252 private const INTERIM_KEY_TTL = 2;
253
255 private const LOCK_TTL = 10;
257 private const RAMPUP_TTL = 30;
258
260 private const TINY_NEGATIVE = -0.000001;
262 private const TINY_POSITIVE = 0.000001;
263
265 private const RECENT_SET_LOW_MS = 50;
267 private const RECENT_SET_HIGH_MS = 100;
268
270 private const GENERATION_HIGH_SEC = 0.2;
271
273 private const PURGE_TIME = 0;
275 private const PURGE_HOLDOFF = 1;
276
278 private const VERSION = 1;
279
281 public const KEY_VERSION = 'version';
283 public const KEY_AS_OF = 'asOf';
285 public const KEY_TTL = 'ttl';
287 public const KEY_CUR_TTL = 'curTTL';
289 public const KEY_TOMB_AS_OF = 'tombAsOf';
291 public const KEY_CHECK_AS_OF = 'lastCKPurge';
292
294 private const RES_VALUE = 0;
296 private const RES_VERSION = 1;
298 private const RES_AS_OF = 2;
300 private const RES_TTL = 3;
302 private const RES_TOMB_AS_OF = 4;
304 private const RES_CHECK_AS_OF = 5;
306 private const RES_TOUCH_AS_OF = 6;
308 private const RES_CUR_TTL = 7;
309
311 private const FLD_FORMAT_VERSION = 0;
313 private const FLD_VALUE = 1;
315 private const FLD_TTL = 2;
317 private const FLD_TIME = 3;
319 private const FLD_FLAGS = 4;
321 private const FLD_VALUE_VERSION = 5;
322 private const FLD_GENERATION_TIME = 6;
323
325 private const TYPE_VALUE = 'v';
327 private const TYPE_TIMESTAMP = 't';
329 private const TYPE_MUTEX = 'm';
331 private const TYPE_INTERIM = 'i';
332
334 private const PURGE_VAL_PREFIX = 'PURGED';
335
363 public function __construct( array $params ) {
364 $this->cache = $params['cache'];
365 $this->broadcastRoute = $params['broadcastRoutingPrefix'] ?? null;
366 $this->epoch = $params['epoch'] ?? 0;
367 $this->secret = $params['secret'] ?? (string)$this->epoch;
368 if ( ( $params['coalesceScheme'] ?? '' ) === 'hash_tag' ) {
369 // https://redis.io/topics/cluster-spec
370 // https://github.com/twitter/twemproxy/blob/v0.4.1/notes/recommendation.md#hash-tags
371 // https://github.com/Netflix/dynomite/blob/v0.7.0/notes/recommendation.md#hash-tags
372 $this->coalesceScheme = self::SCHEME_HASH_TAG;
373 } else {
374 // https://github.com/facebook/mcrouter/wiki/Key-syntax
375 $this->coalesceScheme = self::SCHEME_HASH_STOP;
376 }
377
378 $this->setLogger( $params['logger'] ?? new NullLogger() );
379
380 if ( isset( $params['stats'] ) && $params['stats'] instanceof IBufferingStatsdDataFactory ) {
382 __METHOD__,
383 'Use of StatsdDataFactory is deprecated in 1.43. Use StatsFactory instead.'
384 );
385 $params['stats'] = null;
386 }
387 $this->stats = $params['stats'] ?? StatsFactory::newNull();
388
389 $this->asyncHandler = $params['asyncHandler'] ?? null;
390 $this->missLog = array_fill( 0, 10, [ '', 0.0 ] );
391 }
392
393 public function setLogger( LoggerInterface $logger ) {
394 $this->logger = $logger;
395 }
396
402 public static function newEmpty() {
403 return new static( [ 'cache' => new EmptyBagOStuff() ] );
404 }
405
461 final public function get( $key, &$curTTL = null, array $checkKeys = [], &$info = [] ) {
462 // Note that an undeclared variable passed as $info starts as null (not the default).
463 // Also, if no $info parameter is provided, then it doesn't matter how it changes here.
464 $legacyInfo = ( $info !== self::PASS_BY_REF );
465
466 $now = $this->getCurrentTime();
467 $res = $this->fetchKeys( [ $key ], $checkKeys, $now )[$key];
468
469 $curTTL = $res[self::RES_CUR_TTL];
470 $info = $legacyInfo
471 ? $res[self::RES_AS_OF]
472 : [
473 self::KEY_VERSION => $res[self::RES_VERSION],
474 self::KEY_AS_OF => $res[self::RES_AS_OF],
475 self::KEY_TTL => $res[self::RES_TTL],
476 self::KEY_CUR_TTL => $res[self::RES_CUR_TTL],
477 self::KEY_TOMB_AS_OF => $res[self::RES_TOMB_AS_OF],
478 self::KEY_CHECK_AS_OF => $res[self::RES_CHECK_AS_OF]
479 ];
480
481 if ( $curTTL === null || $curTTL <= 0 ) {
482 // Log the timestamp in case a corresponding set() call does not provide "walltime"
483 unset( $this->missLog[array_key_first( $this->missLog )] );
484 $this->missLog[] = [ $key, $this->getCurrentTime() ];
485 }
486
487 return $res[self::RES_VALUE];
488 }
489
514 final public function getMulti(
515 array $keys,
516 &$curTTLs = [],
517 array $checkKeys = [],
518 &$info = []
519 ) {
520 // Note that an undeclared variable passed as $info starts as null (not the default).
521 // Also, if no $info parameter is provided, then it doesn't matter how it changes here.
522 $legacyInfo = ( $info !== self::PASS_BY_REF );
523
524 $curTTLs = [];
525 $info = [];
526 $valuesByKey = [];
527
528 $now = $this->getCurrentTime();
529 $resByKey = $this->fetchKeys( $keys, $checkKeys, $now );
530 foreach ( $resByKey as $key => $res ) {
531 if ( $res[self::RES_VALUE] !== false ) {
532 $valuesByKey[$key] = $res[self::RES_VALUE];
533 }
534
535 if ( $res[self::RES_CUR_TTL] !== null ) {
536 $curTTLs[$key] = $res[self::RES_CUR_TTL];
537 }
538 $info[$key] = $legacyInfo
539 ? $res[self::RES_AS_OF]
540 : [
541 self::KEY_VERSION => $res[self::RES_VERSION],
542 self::KEY_AS_OF => $res[self::RES_AS_OF],
543 self::KEY_TTL => $res[self::RES_TTL],
544 self::KEY_CUR_TTL => $res[self::RES_CUR_TTL],
545 self::KEY_TOMB_AS_OF => $res[self::RES_TOMB_AS_OF],
546 self::KEY_CHECK_AS_OF => $res[self::RES_CHECK_AS_OF]
547 ];
548 }
549
550 return $valuesByKey;
551 }
552
568 protected function fetchKeys( array $keys, array $checkKeys, float $now, $touchedCb = null ) {
569 $resByKey = [];
570
571 // List of all sister keys that need to be fetched from cache
572 $allSisterKeys = [];
573 // Order-corresponding value sister key list for the base key list ($keys)
574 $valueSisterKeys = [];
575 // List of "check" sister keys to compare all value sister keys against
576 $checkSisterKeysForAll = [];
577 // Map of (base key => additional "check" sister key(s) to compare against)
578 $checkSisterKeysByKey = [];
579
580 foreach ( $keys as $key ) {
581 $sisterKey = $this->makeSisterKey( $key, self::TYPE_VALUE );
582 $allSisterKeys[] = $sisterKey;
583 $valueSisterKeys[] = $sisterKey;
584 }
585
586 foreach ( $checkKeys as $i => $checkKeyOrKeyGroup ) {
587 // Note: avoid array_merge() inside loop in case there are many keys
588 if ( is_int( $i ) ) {
589 // Single "check" key that applies to all base keys
590 $sisterKey = $this->makeSisterKey( $checkKeyOrKeyGroup, self::TYPE_TIMESTAMP );
591 $allSisterKeys[] = $sisterKey;
592 $checkSisterKeysForAll[] = $sisterKey;
593 } else {
594 // List of "check" keys that apply to a specific base key
595 foreach ( (array)$checkKeyOrKeyGroup as $checkKey ) {
596 $sisterKey = $this->makeSisterKey( $checkKey, self::TYPE_TIMESTAMP );
597 $allSisterKeys[] = $sisterKey;
598 $checkSisterKeysByKey[$i][] = $sisterKey;
599 }
600 }
601 }
602
603 if ( $this->warmupCache ) {
604 // Get the wrapped values of the sister keys from the warmup cache
605 $wrappedBySisterKey = $this->warmupCache;
606 $sisterKeysMissing = array_diff( $allSisterKeys, array_keys( $wrappedBySisterKey ) );
607 if ( $sisterKeysMissing ) {
608 $this->warmupKeyMisses += count( $sisterKeysMissing );
609 $wrappedBySisterKey += $this->cache->getMulti( $sisterKeysMissing );
610 }
611 } else {
612 // Fetch the wrapped values of the sister keys from the backend
613 $wrappedBySisterKey = $this->cache->getMulti( $allSisterKeys );
614 }
615
616 // List of "check" sister key purge timestamps to compare all value sister keys against
617 $ckPurgesForAll = $this->processCheckKeys(
618 $checkSisterKeysForAll,
619 $wrappedBySisterKey,
620 $now
621 );
622 // Map of (base key => extra "check" sister key purge timestamp(s) to compare against)
623 $ckPurgesByKey = [];
624 foreach ( $checkSisterKeysByKey as $keyWithCheckKeys => $checkKeysForKey ) {
625 $ckPurgesByKey[$keyWithCheckKeys] = $this->processCheckKeys(
626 $checkKeysForKey,
627 $wrappedBySisterKey,
628 $now
629 );
630 }
631
632 // Unwrap and validate any value found for each base key (under the value sister key)
633 foreach (
634 array_map( null, $valueSisterKeys, $keys )
635 as [ $valueSisterKey, $key ]
636 ) {
637 if ( array_key_exists( $valueSisterKey, $wrappedBySisterKey ) ) {
638 // Key exists as either a live value or tombstone value
639 $wrapped = $wrappedBySisterKey[$valueSisterKey];
640 } else {
641 // Key does not exist
642 $wrapped = false;
643 }
644
645 $res = $this->unwrap( $wrapped, $now );
646 $value = $res[self::RES_VALUE];
647
648 foreach ( array_merge( $ckPurgesForAll, $ckPurgesByKey[$key] ?? [] ) as $ckPurge ) {
649 $res[self::RES_CHECK_AS_OF] = max(
650 $ckPurge[self::PURGE_TIME],
651 $res[self::RES_CHECK_AS_OF]
652 );
653 // Timestamp marking the end of the hold-off period for this purge
654 $holdoffDeadline = $ckPurge[self::PURGE_TIME] + $ckPurge[self::PURGE_HOLDOFF];
655 // Check if the value was generated during the hold-off period
656 if ( $value !== false && $holdoffDeadline >= $res[self::RES_AS_OF] ) {
657 // How long ago this value was purged by *this* "check" key
658 $ago = min( $ckPurge[self::PURGE_TIME] - $now, self::TINY_NEGATIVE );
659 // How long ago this value was purged by *any* known "check" key
660 $res[self::RES_CUR_TTL] = min( $res[self::RES_CUR_TTL], $ago );
661 }
662 }
663
664 if ( $touchedCb !== null && $value !== false ) {
665 $touched = $touchedCb( $value );
666 if ( $touched !== null && $touched >= $res[self::RES_AS_OF] ) {
667 $res[self::RES_CUR_TTL] = min(
668 $res[self::RES_CUR_TTL],
669 $res[self::RES_AS_OF] - $touched,
670 self::TINY_NEGATIVE
671 );
672 }
673 } else {
674 $touched = null;
675 }
676
677 $res[self::RES_TOUCH_AS_OF] = max( $res[self::RES_TOUCH_AS_OF], $touched );
678
679 $resByKey[$key] = $res;
680 }
681
682 return $resByKey;
683 }
684
691 private function processCheckKeys(
692 array $checkSisterKeys,
693 array $wrappedBySisterKey,
694 float $now
695 ) {
696 $purges = [];
697
698 foreach ( $checkSisterKeys as $timeKey ) {
699 $purge = isset( $wrappedBySisterKey[$timeKey] )
700 ? $this->parsePurgeValue( $wrappedBySisterKey[$timeKey] )
701 : null;
702
703 if ( $purge === null ) {
704 // No holdoff when lazy creating a check key, use cache right away (T344191)
705 $wrapped = $this->makeCheckPurgeValue( $now, self::HOLDOFF_TTL_NONE, $purge );
706 $this->cache->add(
707 $timeKey,
708 $wrapped,
709 self::CHECK_KEY_TTL,
710 $this->cache::WRITE_BACKGROUND
711 );
712 }
713
714 $purges[] = $purge;
715 }
716
717 return $purges;
718 }
719
803 final public function set( $key, $value, $ttl = self::TTL_INDEFINITE, array $opts = [] ) {
804 $keygroup = $this->determineKeyGroupForStats( $key );
805
806 $ok = $this->setMainValue(
807 $key,
808 $value,
809 $ttl,
810 $opts['version'] ?? null,
811 $opts['walltime'] ?? null,
812 $opts['lag'] ?? 0,
813 $opts['since'] ?? null,
814 $opts['pending'] ?? false,
815 $opts['lockTSE'] ?? self::TSE_NONE,
816 $opts['staleTTL'] ?? self::STALE_TTL_NONE,
817 $opts['segmentable'] ?? false,
818 $opts['creating'] ?? false
819 );
820
821 $this->stats->getCounter( 'wanobjectcache_set_total' )
822 ->setLabel( 'keygroup', $keygroup )
823 ->setLabel( 'result', ( $ok ? 'ok' : 'error' ) )
824 ->copyToStatsdAt( "wanobjectcache.$keygroup.set." . ( $ok ? 'ok' : 'error' ) )
825 ->increment();
826
827 return $ok;
828 }
829
845 private function setMainValue(
846 $key,
847 $value,
848 $ttl,
849 ?int $version,
850 ?float $walltime,
851 $dataReplicaLag,
852 $dataReadSince,
853 bool $dataPendingCommit,
854 int $lockTSE,
855 int $staleTTL,
856 bool $segmentable,
857 bool $creating
858 ) {
859 if ( $ttl < 0 ) {
860 // not cacheable
861 return true;
862 }
863
864 $now = $this->getCurrentTime();
865 $ttl = (int)$ttl;
866 $walltime ??= $this->timeSinceLoggedMiss( $key, $now );
867 $dataSnapshotLag = ( $dataReadSince !== null ) ? max( 0, $now - $dataReadSince ) : 0;
868 $dataCombinedLag = $dataReplicaLag + $dataSnapshotLag;
869
870 // Forbid caching data that only exists within an uncommitted transaction. Also, lower
871 // the TTL when the data has a "since" time so far in the past that a delete() tombstone,
872 // made after that time, could have already expired (the key is no longer write-holed).
873 // The mitigation TTL depends on whether this data lag is assumed to systemically effect
874 // regeneration attempts in the near future. The TTL also reflects regeneration wall time.
875 if ( $dataPendingCommit ) {
876 // Case A: data comes from an uncommitted write transaction
877 $mitigated = 'pending writes';
878 // Data might never be committed; rely on a less problematic regeneration attempt
879 $mitigationTTL = self::TTL_UNCACHEABLE;
880 } elseif ( $dataSnapshotLag > self::MAX_READ_LAG ) {
881 // Case B: high snapshot lag
882 $pregenSnapshotLag = ( $walltime !== null ) ? ( $dataSnapshotLag - $walltime ) : 0;
883 if ( ( $pregenSnapshotLag + self::GENERATION_HIGH_SEC ) > self::MAX_READ_LAG ) {
884 // Case B1: generation started when transaction duration was already long
885 $mitigated = 'snapshot lag (late generation)';
886 // Probably non-systemic; rely on a less problematic regeneration attempt
887 $mitigationTTL = self::TTL_UNCACHEABLE;
888 } else {
889 // Case B2: slow generation made transaction duration long
890 $mitigated = 'snapshot lag (high generation time)';
891 // Probably systemic; use a low TTL to avoid stampedes/uncacheability
892 $mitigationTTL = self::TTL_LAGGED;
893 }
894 } elseif ( $dataReplicaLag === false || $dataReplicaLag > self::MAX_READ_LAG ) {
895 // Case C: low/medium snapshot lag with high replication lag
896 $mitigated = 'replication lag';
897 // Probably systemic; use a low TTL to avoid stampedes/uncacheability
898 $mitigationTTL = self::TTL_LAGGED;
899 } elseif ( $dataCombinedLag > self::MAX_READ_LAG ) {
900 $pregenCombinedLag = ( $walltime !== null ) ? ( $dataCombinedLag - $walltime ) : 0;
901 // Case D: medium snapshot lag with medium replication lag
902 if ( ( $pregenCombinedLag + self::GENERATION_HIGH_SEC ) > self::MAX_READ_LAG ) {
903 // Case D1: generation started when read lag was too high
904 $mitigated = 'read lag (late generation)';
905 // Probably non-systemic; rely on a less problematic regeneration attempt
906 $mitigationTTL = self::TTL_UNCACHEABLE;
907 } else {
908 // Case D2: slow generation made read lag too high
909 $mitigated = 'read lag (high generation time)';
910 // Probably systemic; use a low TTL to avoid stampedes/uncacheability
911 $mitigationTTL = self::TTL_LAGGED;
912 }
913 } else {
914 // Case E: new value generated with recent data
915 $mitigated = null;
916 // Nothing to mitigate
917 $mitigationTTL = null;
918 }
919
920 if ( $mitigationTTL === self::TTL_UNCACHEABLE ) {
921 $this->logger->warning(
922 "Rejected set() for {cachekey} due to $mitigated.",
923 [
924 'cachekey' => $key,
925 'lag' => $dataReplicaLag,
926 'age' => $dataSnapshotLag,
927 'walltime' => $walltime
928 ]
929 );
930
931 // no-op the write for being unsafe
932 return true;
933 }
934
935 // TTL to use in staleness checks (does not effect persistence layer TTL)
936 $logicalTTL = null;
937
938 if ( $mitigationTTL !== null ) {
939 // New value was generated from data that is old enough to be risky
940 if ( $lockTSE >= 0 ) {
941 // Persist the value as long as normal, but make it count as stale sooner
942 $logicalTTL = min( $ttl ?: INF, $mitigationTTL );
943 } else {
944 // Persist the value for a shorter duration
945 $ttl = min( $ttl ?: INF, $mitigationTTL );
946 }
947
948 $this->logger->warning(
949 "Lowered set() TTL for {cachekey} due to $mitigated.",
950 [
951 'cachekey' => $key,
952 'lag' => $dataReplicaLag,
953 'age' => $dataSnapshotLag,
954 'walltime' => $walltime
955 ]
956 );
957 }
958
959 // Wrap that value with time/TTL/version metadata
960 $wrapped = $this->wrap( $value, $logicalTTL ?: $ttl, $version, $now );
961 $storeTTL = $ttl + $staleTTL;
962
963 $flags = $this->cache::WRITE_BACKGROUND;
964 if ( $segmentable ) {
965 $flags |= $this->cache::WRITE_ALLOW_SEGMENTS;
966 }
967
968 if ( $creating ) {
969 $ok = $this->cache->add(
970 $this->makeSisterKey( $key, self::TYPE_VALUE ),
971 $wrapped,
972 $storeTTL,
973 $flags
974 );
975 } else {
976 $ok = $this->cache->merge(
977 $this->makeSisterKey( $key, self::TYPE_VALUE ),
978 static function ( $cache, $key, $cWrapped ) use ( $wrapped ) {
979 // A string value means that it is a tombstone; do nothing in that case
980 return ( is_string( $cWrapped ) ) ? false : $wrapped;
981 },
982 $storeTTL,
983 $this->cache::MAX_CONFLICTS_ONE,
984 $flags
985 );
986 }
987
988 return $ok;
989 }
990
1053 final public function delete( $key, $ttl = self::HOLDOFF_TTL ) {
1054 // Purge values must be stored under the value key so that WANObjectCache::set()
1055 // can atomically merge values without accidentally undoing a recent purge and thus
1056 // violating the holdoff TTL restriction.
1057 $valueSisterKey = $this->makeSisterKey( $key, self::TYPE_VALUE );
1058
1059 if ( $ttl <= 0 ) {
1060 // A client or cache cleanup script is requesting a cache purge, so there is no
1061 // volatility period due to replica DB lag. Any recent change to an entity cached
1062 // in this key should have triggered an appropriate purge event.
1063 $ok = $this->relayNonVolatilePurge( $valueSisterKey );
1064 } else {
1065 // A cacheable entity recently changed, so there might be a volatility period due
1066 // to replica DB lag. Clients usually expect their actions to be reflected in any
1067 // of their subsequent web request. This is attainable if (a) purge relay lag is
1068 // lower than the time it takes for subsequent request by the client to arrive,
1069 // and, (b) DB replica queries have "read-your-writes" consistency due to DB lag
1070 // mitigation systems.
1071 $now = $this->getCurrentTime();
1072 // Set the key to the purge value in all datacenters
1073 $purge = $this->makeTombstonePurgeValue( $now );
1074 $ok = $this->relayVolatilePurge( $valueSisterKey, $purge, $ttl );
1075 }
1076
1077 $keygroup = $this->determineKeyGroupForStats( $key );
1078
1079 $this->stats->getCounter( 'wanobjectcache_delete_total' )
1080 ->setLabel( 'keygroup', $keygroup )
1081 ->setLabel( 'result', ( $ok ? 'ok' : 'error' ) )
1082 ->copyToStatsdAt( "wanobjectcache.$keygroup.delete." . ( $ok ? 'ok' : 'error' ) )
1083 ->increment();
1084
1085 return $ok;
1086 }
1087
1107 final public function getCheckKeyTime( $key ) {
1108 return $this->getMultiCheckKeyTime( [ $key ] )[$key];
1109 }
1110
1172 final public function getMultiCheckKeyTime( array $keys ) {
1173 $checkSisterKeysByKey = [];
1174 foreach ( $keys as $key ) {
1175 $checkSisterKeysByKey[$key] = $this->makeSisterKey( $key, self::TYPE_TIMESTAMP );
1176 }
1177
1178 $wrappedBySisterKey = $this->cache->getMulti( $checkSisterKeysByKey );
1179 $wrappedBySisterKey += array_fill_keys( $checkSisterKeysByKey, false );
1180
1181 $now = $this->getCurrentTime();
1182 $times = [];
1183 foreach ( $checkSisterKeysByKey as $key => $checkSisterKey ) {
1184 $purge = $this->parsePurgeValue( $wrappedBySisterKey[$checkSisterKey] );
1185 if ( $purge === null ) {
1186 $wrapped = $this->makeCheckPurgeValue( $now, self::HOLDOFF_TTL_NONE, $purge );
1187 $this->cache->add(
1188 $checkSisterKey,
1189 $wrapped,
1190 self::CHECK_KEY_TTL,
1191 $this->cache::WRITE_BACKGROUND
1192 );
1193 }
1194
1195 $times[$key] = $purge[self::PURGE_TIME];
1196 }
1197
1198 return $times;
1199 }
1200
1234 final public function touchCheckKey( $key, $holdoff = self::HOLDOFF_TTL ) {
1235 $checkSisterKey = $this->makeSisterKey( $key, self::TYPE_TIMESTAMP );
1236
1237 $now = $this->getCurrentTime();
1238 $purge = $this->makeCheckPurgeValue( $now, $holdoff );
1239 $ok = $this->relayVolatilePurge( $checkSisterKey, $purge, self::CHECK_KEY_TTL );
1240
1241 $keygroup = $this->determineKeyGroupForStats( $key );
1242
1243 $this->stats->getCounter( 'wanobjectcache_check_total' )
1244 ->setLabel( 'keygroup', $keygroup )
1245 ->setLabel( 'result', ( $ok ? 'ok' : 'error' ) )
1246 ->copyToStatsdAt( "wanobjectcache.$keygroup.ck_touch." . ( $ok ? 'ok' : 'error' ) )
1247 ->increment();
1248
1249 return $ok;
1250 }
1251
1279 final public function resetCheckKey( $key ) {
1280 $checkSisterKey = $this->makeSisterKey( $key, self::TYPE_TIMESTAMP );
1281 $ok = $this->relayNonVolatilePurge( $checkSisterKey );
1282
1283 $keygroup = $this->determineKeyGroupForStats( $key );
1284
1285 $this->stats->getCounter( 'wanobjectcache_reset_total' )
1286 ->setLabel( 'keygroup', $keygroup )
1287 ->setLabel( 'result', ( $ok ? 'ok' : 'error' ) )
1288 ->copyToStatsdAt( "wanobjectcache.$keygroup.ck_reset." . ( $ok ? 'ok' : 'error' ) )
1289 ->increment();
1290
1291 return $ok;
1292 }
1293
1595 final public function getWithSetCallback(
1596 $key, $ttl, $callback, array $opts = [], array $cbParams = []
1597 ) {
1598 $version = $opts['version'] ?? null;
1599 $pcTTL = $opts['pcTTL'] ?? self::TTL_UNCACHEABLE;
1600 $pCache = ( $pcTTL >= 0 )
1601 ? $this->getProcessCache( $opts['pcGroup'] ?? self::PC_PRIMARY )
1602 : null;
1603
1604 // Use the process cache if requested as long as no outer cache callback is running.
1605 // Nested callback process cache use is not lag-safe with regard to HOLDOFF_TTL since
1606 // process cached values are more lagged than persistent ones as they are not purged.
1607 if ( $pCache && $this->callbackDepth == 0 ) {
1608 $cached = $pCache->get( $key, $pcTTL, false );
1609 if ( $cached !== false ) {
1610 $this->logger->debug( "getWithSetCallback($key): process cache hit" );
1611 return $cached;
1612 }
1613 }
1614
1615 [ $value, $valueVersion, $curAsOf ] = $this->fetchOrRegenerate( $key, $ttl, $callback, $opts, $cbParams );
1616 if ( $valueVersion !== $version ) {
1617 // Current value has a different version; use the variant key for this version.
1618 // Regenerate the variant value if it is not newer than the main value at $key
1619 // so that purges to the main key propagate to the variant value.
1620 $this->logger->debug( "getWithSetCallback($key): using variant key" );
1621 [ $value ] = $this->fetchOrRegenerate(
1622 $this->makeGlobalKey( 'WANCache-key-variant', md5( $key ), (string)$version ),
1623 $ttl,
1624 $callback,
1625 [ 'version' => null, 'minAsOf' => $curAsOf ] + $opts,
1626 $cbParams
1627 );
1628 }
1629
1630 // Update the process cache if enabled
1631 if ( $pCache && $value !== false ) {
1632 $pCache->set( $key, $value );
1633 }
1634
1635 return $value;
1636 }
1637
1654 private function fetchOrRegenerate( $key, $ttl, $callback, array $opts, array $cbParams ) {
1655 $checkKeys = $opts['checkKeys'] ?? [];
1656 $graceTTL = $opts['graceTTL'] ?? self::GRACE_TTL_NONE;
1657 $minAsOf = $opts['minAsOf'] ?? self::MIN_TIMESTAMP_NONE;
1658 $hotTTR = $opts['hotTTR'] ?? self::HOT_TTR;
1659 $lowTTL = $opts['lowTTL'] ?? min( self::LOW_TTL, $ttl );
1660 $ageNew = $opts['ageNew'] ?? self::AGE_NEW;
1661 $touchedCb = $opts['touchedCallback'] ?? null;
1662 $startTime = $this->getCurrentTime();
1663
1664 $keygroup = $this->determineKeyGroupForStats( $key );
1665
1666 // Get the current key value and its metadata
1667 $curState = $this->fetchKeys( [ $key ], $checkKeys, $startTime, $touchedCb )[$key];
1668 $curValue = $curState[self::RES_VALUE];
1669
1670 // Use the cached value if it exists and is not due for synchronous regeneration
1671 if ( $this->isAcceptablyFreshValue( $curState, $graceTTL, $minAsOf ) ) {
1672 if ( !$this->isLotteryRefreshDue( $curState, $lowTTL, $ageNew, $hotTTR, $startTime ) ) {
1673 $this->stats->getTiming( 'wanobjectcache_getwithset_seconds' )
1674 ->setLabel( 'keygroup', $keygroup )
1675 ->setLabel( 'result', 'hit' )
1676 ->setLabel( 'reason', 'good' )
1677 ->copyToStatsdAt( "wanobjectcache.$keygroup.hit.good" )
1678 ->observe( 1e3 * ( $this->getCurrentTime() - $startTime ) );
1679
1680 return [ $curValue, $curState[self::RES_VERSION], $curState[self::RES_AS_OF] ];
1681 } elseif ( $this->scheduleAsyncRefresh( $key, $ttl, $callback, $opts, $cbParams ) ) {
1682 $this->logger->debug( "fetchOrRegenerate($key): hit with async refresh" );
1683
1684 $this->stats->getTiming( 'wanobjectcache_getwithset_seconds' )
1685 ->setLabel( 'keygroup', $keygroup )
1686 ->setLabel( 'result', 'hit' )
1687 ->setLabel( 'reason', 'refresh' )
1688 ->copyToStatsdAt( "wanobjectcache.$keygroup.hit.refresh" )
1689 ->observe( 1e3 * ( $this->getCurrentTime() - $startTime ) );
1690
1691 return [ $curValue, $curState[self::RES_VERSION], $curState[self::RES_AS_OF] ];
1692 } else {
1693 $this->logger->debug( "fetchOrRegenerate($key): hit with sync refresh" );
1694 }
1695 }
1696
1697 $isKeyTombstoned = ( $curState[self::RES_TOMB_AS_OF] !== null );
1698 // Use the interim key as a temporary alternative if the key is tombstoned
1699 if ( $isKeyTombstoned ) {
1700 $volState = $this->getInterimValue( $key, $minAsOf, $startTime, $touchedCb );
1701 $volValue = $volState[self::RES_VALUE];
1702 } else {
1703 $volState = $curState;
1704 $volValue = $curValue;
1705 }
1706
1707 // During the volatile "hold-off" period that follows a purge of the key, the value
1708 // will be regenerated many times if frequently accessed. This is done to mitigate
1709 // the effects of backend replication lag as soon as possible. However, throttle the
1710 // overhead of locking and regeneration by reusing values recently written to cache
1711 // tens of milliseconds ago. Verify the "as of" time against the last purge event.
1712 $lastPurgeTime = max(
1713 // RES_TOUCH_AS_OF depends on the value (possibly from the interim key)
1714 $volState[self::RES_TOUCH_AS_OF],
1715 $curState[self::RES_TOMB_AS_OF],
1716 $curState[self::RES_CHECK_AS_OF]
1717 );
1718 $safeMinAsOf = max( $minAsOf, $lastPurgeTime + self::TINY_POSITIVE );
1719 if ( $this->isExtremelyNewValue( $volState, $safeMinAsOf, $startTime ) ) {
1720 $this->logger->debug( "fetchOrRegenerate($key): volatile hit" );
1721
1722 $this->stats->getTiming( 'wanobjectcache_getwithset_seconds' )
1723 ->setLabel( 'keygroup', $keygroup )
1724 ->setLabel( 'result', 'hit' )
1725 ->setLabel( 'reason', 'volatile' )
1726 ->copyToStatsdAt( "wanobjectcache.$keygroup.hit.volatile" )
1727 ->observe( 1e3 * ( $this->getCurrentTime() - $startTime ) );
1728
1729 return [ $volValue, $volState[self::RES_VERSION], $curState[self::RES_AS_OF] ];
1730 }
1731
1732 $lockTSE = $opts['lockTSE'] ?? self::TSE_NONE;
1733 $busyValue = $opts['busyValue'] ?? null;
1734 $staleTTL = $opts['staleTTL'] ?? self::STALE_TTL_NONE;
1735 $segmentable = $opts['segmentable'] ?? false;
1736 $version = $opts['version'] ?? null;
1737
1738 // Determine whether one thread per datacenter should handle regeneration at a time
1739 $useRegenerationLock =
1740 // Note that since tombstones no-op set(), $lockTSE and $curTTL cannot be used to
1741 // deduce the key hotness because |$curTTL| will always keep increasing until the
1742 // tombstone expires or is overwritten by a new tombstone. Also, even if $lockTSE
1743 // is not set, constant regeneration of a key for the tombstone lifetime might be
1744 // very expensive. Assume tombstoned keys are possibly hot in order to reduce
1745 // the risk of high regeneration load after the delete() method is called.
1746 $isKeyTombstoned ||
1747 // Assume a key is hot if requested soon ($lockTSE seconds) after purge.
1748 // This avoids stampedes when timestamps from $checkKeys/$touchedCb bump.
1749 (
1750 $curState[self::RES_CUR_TTL] !== null &&
1751 $curState[self::RES_CUR_TTL] <= 0 &&
1752 abs( $curState[self::RES_CUR_TTL] ) <= $lockTSE
1753 ) ||
1754 // Assume a key is hot if there is no value and a busy fallback is given.
1755 // This avoids stampedes on eviction or preemptive regeneration taking too long.
1756 ( $busyValue !== null && $volValue === false );
1757
1758 // If a regeneration lock is required, threads that do not get the lock will try to use
1759 // the stale value, the interim value, or the $busyValue placeholder, in that order. If
1760 // none of those are set then all threads will bypass the lock and regenerate the value.
1761 $hasLock = $useRegenerationLock && $this->claimStampedeLock( $key );
1762 if ( $useRegenerationLock && !$hasLock ) {
1763 // Determine if there is stale or volatile cached value that is still usable
1764 // @phan-suppress-next-line PhanTypeMismatchArgumentNullable False positive
1765 if ( $this->isValid( $volValue, $volState[self::RES_AS_OF], $minAsOf ) ) {
1766 $this->logger->debug( "fetchOrRegenerate($key): returning stale value" );
1767
1768 $this->stats->getTiming( 'wanobjectcache_getwithset_seconds' )
1769 ->setLabel( 'keygroup', $keygroup )
1770 ->setLabel( 'result', 'hit' )
1771 ->setLabel( 'reason', 'stale' )
1772 ->copyToStatsdAt( "wanobjectcache.$keygroup.hit.stale" )
1773 ->observe( 1e3 * ( $this->getCurrentTime() - $startTime ) );
1774
1775 return [ $volValue, $volState[self::RES_VERSION], $curState[self::RES_AS_OF] ];
1776 } elseif ( $busyValue !== null ) {
1777 $miss = is_infinite( $minAsOf ) ? 'renew' : 'miss';
1778 $this->logger->debug( "fetchOrRegenerate($key): busy $miss" );
1779
1780 $this->stats->getTiming( 'wanobjectcache_getwithset_seconds' )
1781 ->setLabel( 'keygroup', $keygroup )
1782 ->setLabel( 'result', $miss )
1783 ->setLabel( 'reason', 'busy' )
1784 ->copyToStatsdAt( "wanobjectcache.$keygroup.$miss.busy" )
1785 ->observe( 1e3 * ( $this->getCurrentTime() - $startTime ) );
1786
1787 $placeholderValue = $this->resolveBusyValue( $busyValue );
1788
1789 return [ $placeholderValue, $version, $curState[self::RES_AS_OF] ];
1790 }
1791 }
1792
1793 // Generate the new value given any prior value with a matching version
1794 $setOpts = [];
1795 $preCallbackTime = $this->getCurrentTime();
1796 ++$this->callbackDepth;
1797 // https://github.com/phan/phan/issues/4419
1798 $value = null;
1799 try {
1800 $value = $callback(
1801 ( $curState[self::RES_VERSION] === $version ) ? $curValue : false,
1802 $ttl,
1803 $setOpts,
1804 ( $curState[self::RES_VERSION] === $version ) ? $curState[self::RES_AS_OF] : null,
1805 $cbParams
1806 );
1807 } finally {
1808 --$this->callbackDepth;
1809 }
1810 $postCallbackTime = $this->getCurrentTime();
1811
1812 // How long it took to generate the value
1813 $walltime = max( $postCallbackTime - $preCallbackTime, 0.0 );
1814
1815 $this->stats->getTiming( 'wanobjectcache_regen_seconds' )
1816 ->setLabel( 'keygroup', $keygroup )
1817 ->copyToStatsdAt( "wanobjectcache.$keygroup.regen_walltime" )
1818 ->observe( 1e3 * $walltime );
1819
1820 // Attempt to save the newly generated value if applicable
1821 if (
1822 // Callback yielded a cacheable value
1823 ( $value !== false && $ttl >= 0 ) &&
1824 // Current thread was not raced out of a regeneration lock or key is tombstoned
1825 ( !$useRegenerationLock || $hasLock || $isKeyTombstoned )
1826 ) {
1827 // If the key is write-holed then use the (volatile) interim key as an alternative
1828 if ( $isKeyTombstoned ) {
1829 $this->setInterimValue(
1830 $key,
1831 $value,
1832 $lockTSE,
1833 $version,
1834 $segmentable
1835 );
1836 } else {
1837 $this->setMainValue(
1838 $key,
1839 $value,
1840 $ttl,
1841 $version,
1842 $walltime,
1843 // @phan-suppress-next-line PhanCoalescingAlwaysNull
1844 $setOpts['lag'] ?? 0,
1845 // @phan-suppress-next-line PhanCoalescingAlwaysNull
1846 $setOpts['since'] ?? $preCallbackTime,
1847 // @phan-suppress-next-line PhanCoalescingAlwaysNull
1848 $setOpts['pending'] ?? false,
1849 $lockTSE,
1850 $staleTTL,
1851 $segmentable,
1852 ( $curValue === false )
1853 );
1854 }
1855 }
1856
1857 $this->yieldStampedeLock( $key, $hasLock );
1858
1859 $miss = is_infinite( $minAsOf ) ? 'renew' : 'miss';
1860 $this->logger->debug( "fetchOrRegenerate($key): $miss, new value computed" );
1861
1862 $this->stats->getTiming( 'wanobjectcache_getwithset_seconds' )
1863 ->setLabel( 'keygroup', $keygroup )
1864 ->setLabel( 'result', $miss )
1865 ->setLabel( 'reason', 'compute' )
1866 ->copyToStatsdAt( "wanobjectcache.$keygroup.$miss.compute" )
1867 ->observe( 1e3 * ( $this->getCurrentTime() - $startTime ) );
1868
1869 return [ $value, $version, $curState[self::RES_AS_OF] ];
1870 }
1871
1876 private function claimStampedeLock( $key ) {
1877 $checkSisterKey = $this->makeSisterKey( $key, self::TYPE_MUTEX );
1878 // Note that locking is not bypassed due to I/O errors; this avoids stampedes
1879 return $this->cache->add( $checkSisterKey, 1, self::LOCK_TTL );
1880 }
1881
1886 private function yieldStampedeLock( $key, $hasLock ) {
1887 if ( $hasLock ) {
1888 $checkSisterKey = $this->makeSisterKey( $key, self::TYPE_MUTEX );
1889 $this->cache->delete( $checkSisterKey, $this->cache::WRITE_BACKGROUND );
1890 }
1891 }
1892
1903 private function makeSisterKeys( array $baseKeys, string $type, ?string $route = null ) {
1904 $sisterKeys = [];
1905 foreach ( $baseKeys as $baseKey ) {
1906 $sisterKeys[] = $this->makeSisterKey( $baseKey, $type, $route );
1907 }
1908
1909 return $sisterKeys;
1910 }
1911
1922 private function makeSisterKey( string $baseKey, string $typeChar, ?string $route = null ) {
1923 if ( $this->coalesceScheme === self::SCHEME_HASH_STOP ) {
1924 // Key style: "WANCache:<base key>|#|<character>"
1925 $sisterKey = 'WANCache:' . $baseKey . '|#|' . $typeChar;
1926 } else {
1927 // Key style: "WANCache:{<base key>}:<character>"
1928 $sisterKey = 'WANCache:{' . $baseKey . '}:' . $typeChar;
1929 }
1930
1931 if ( $route !== null ) {
1932 $sisterKey = $this->prependRoute( $sisterKey, $route );
1933 }
1934
1935 return $sisterKey;
1936 }
1937
1950 private function isExtremelyNewValue( $res, $minAsOf, $now ) {
1951 if ( $res[self::RES_VALUE] === false || $res[self::RES_AS_OF] < $minAsOf ) {
1952 return false;
1953 }
1954
1955 $age = $now - $res[self::RES_AS_OF];
1956
1957 return ( $age < mt_rand( self::RECENT_SET_LOW_MS, self::RECENT_SET_HIGH_MS ) / 1e3 );
1958 }
1959
1969 private function getInterimValue( $key, $minAsOf, $now, $touchedCb ) {
1970 if ( $this->useInterimHoldOffCaching ) {
1971 $interimSisterKey = $this->makeSisterKey( $key, self::TYPE_INTERIM );
1972 $wrapped = $this->cache->get( $interimSisterKey );
1973 $res = $this->unwrap( $wrapped, $now );
1974 if ( $res[self::RES_VALUE] !== false && $res[self::RES_AS_OF] >= $minAsOf ) {
1975 if ( $touchedCb !== null ) {
1976 // Update "last purge time" since the $touchedCb timestamp depends on $value
1977 // Get the new "touched timestamp", accounting for callback-checked dependencies
1978 $res[self::RES_TOUCH_AS_OF] = max(
1979 $touchedCb( $res[self::RES_VALUE] ),
1980 $res[self::RES_TOUCH_AS_OF]
1981 );
1982 }
1983
1984 return $res;
1985 }
1986 }
1987
1988 return $this->unwrap( false, $now );
1989 }
1990
1999 private function setInterimValue(
2000 $key,
2001 $value,
2002 $ttl,
2003 ?int $version,
2004 bool $segmentable
2005 ) {
2006 $now = $this->getCurrentTime();
2007 $ttl = max( self::INTERIM_KEY_TTL, (int)$ttl );
2008
2009 // Wrap that value with time/TTL/version metadata
2010 $wrapped = $this->wrap( $value, $ttl, $version, $now );
2011
2012 $flags = $this->cache::WRITE_BACKGROUND;
2013 if ( $segmentable ) {
2014 $flags |= $this->cache::WRITE_ALLOW_SEGMENTS;
2015 }
2016
2017 return $this->cache->set(
2018 $this->makeSisterKey( $key, self::TYPE_INTERIM ),
2019 $wrapped,
2020 $ttl,
2021 $flags
2022 );
2023 }
2024
2029 private function resolveBusyValue( $busyValue ) {
2030 return ( $busyValue instanceof Closure ) ? $busyValue() : $busyValue;
2031 }
2032
2098 final public function getMultiWithSetCallback(
2099 ArrayIterator $keyedIds, $ttl, callable $callback, array $opts = []
2100 ) {
2101 // Batch load required keys into the in-process warmup cache
2102 $this->warmupCache = $this->fetchWrappedValuesForWarmupCache(
2103 $this->getNonProcessCachedMultiKeys( $keyedIds, $opts ),
2104 $opts['checkKeys'] ?? []
2105 );
2106 $this->warmupKeyMisses = 0;
2107
2108 // The required callback signature includes $id as the first argument for convenience
2109 // to distinguish different items. To reuse the code in getWithSetCallback(), wrap the
2110 // callback with a proxy callback that has the standard getWithSetCallback() signature.
2111 // This is defined only once per batch to avoid closure creation overhead.
2112 $proxyCb = static function ( $oldValue, &$ttl, &$setOpts, $oldAsOf, $params )
2113 use ( $callback )
2114 {
2115 return $callback( $params['id'], $oldValue, $ttl, $setOpts, $oldAsOf );
2116 };
2117
2118 // Get the order-preserved result map using the warm-up cache
2119 $values = [];
2120 foreach ( $keyedIds as $key => $id ) {
2121 $values[$key] = $this->getWithSetCallback(
2122 $key,
2123 $ttl,
2124 $proxyCb,
2125 $opts,
2126 [ 'id' => $id ]
2127 );
2128 }
2129
2130 $this->warmupCache = [];
2131
2132 return $values;
2133 }
2134
2201 final public function getMultiWithUnionSetCallback(
2202 ArrayIterator $keyedIds, $ttl, callable $callback, array $opts = []
2203 ) {
2204 $checkKeys = $opts['checkKeys'] ?? [];
2205 $minAsOf = $opts['minAsOf'] ?? self::MIN_TIMESTAMP_NONE;
2206
2207 // unset incompatible keys
2208 unset( $opts['lockTSE'] );
2209 unset( $opts['busyValue'] );
2210
2211 // Batch load required keys into the in-process warmup cache
2212 $keysByIdGet = $this->getNonProcessCachedMultiKeys( $keyedIds, $opts );
2213 $this->warmupCache = $this->fetchWrappedValuesForWarmupCache( $keysByIdGet, $checkKeys );
2214 $this->warmupKeyMisses = 0;
2215
2216 // IDs of entities known to be in need of generation
2217 $idsRegen = [];
2218
2219 // Find out which keys are missing/deleted/stale
2220 $now = $this->getCurrentTime();
2221 $resByKey = $this->fetchKeys( $keysByIdGet, $checkKeys, $now );
2222 foreach ( $keysByIdGet as $id => $key ) {
2223 $res = $resByKey[$key];
2224 if (
2225 $res[self::RES_VALUE] === false ||
2226 $res[self::RES_CUR_TTL] < 0 ||
2227 $res[self::RES_AS_OF] < $minAsOf
2228 ) {
2229 $idsRegen[] = $id;
2230 }
2231 }
2232
2233 // Run the callback to populate the generation value map for all required IDs
2234 $newSetOpts = [];
2235 $newTTLsById = array_fill_keys( $idsRegen, $ttl );
2236 $newValsById = $idsRegen ? $callback( $idsRegen, $newTTLsById, $newSetOpts ) : [];
2237
2238 $method = __METHOD__;
2239 // The required callback signature includes $id as the first argument for convenience
2240 // to distinguish different items. To reuse the code in getWithSetCallback(), wrap the
2241 // callback with a proxy callback that has the standard getWithSetCallback() signature.
2242 // This is defined only once per batch to avoid closure creation overhead.
2243 $proxyCb = function ( $oldValue, &$ttl, &$setOpts, $oldAsOf, $params )
2244 use ( $callback, $newValsById, $newTTLsById, $newSetOpts, $method )
2245 {
2246 $id = $params['id'];
2247
2248 if ( array_key_exists( $id, $newValsById ) ) {
2249 // Value was already regenerated as expected, so use the value in $newValsById
2250 $newValue = $newValsById[$id];
2251 $ttl = $newTTLsById[$id];
2252 $setOpts = $newSetOpts;
2253 } else {
2254 // Pre-emptive/popularity refresh and version mismatch cases are not detected
2255 // above and thus $newValsById has no entry. Run $callback on this single entity.
2256 $ttls = [ $id => $ttl ];
2257 $result = $callback( [ $id ], $ttls, $setOpts );
2258 if ( !isset( $result[$id] ) ) {
2259 // T303092
2260 $this->logger->warning(
2261 $method . ' failed due to {id} not set in result {result}', [
2262 'id' => $id,
2263 'result' => json_encode( $result )
2264 ] );
2265 }
2266 $newValue = $result[$id];
2267 $ttl = $ttls[$id];
2268 }
2269
2270 return $newValue;
2271 };
2272
2273 // Get the order-preserved result map using the warm-up cache
2274 $values = [];
2275 foreach ( $keyedIds as $key => $id ) {
2276 $values[$key] = $this->getWithSetCallback(
2277 $key,
2278 $ttl,
2279 $proxyCb,
2280 $opts,
2281 [ 'id' => $id ]
2282 );
2283 }
2284
2285 $this->warmupCache = [];
2286
2287 return $values;
2288 }
2289
2297 public function makeGlobalKey( $keygroup, ...$components ) {
2298 return $this->cache->makeGlobalKey( $keygroup, ...$components );
2299 }
2300
2308 public function makeKey( $keygroup, ...$components ) {
2309 return $this->cache->makeKey( $keygroup, ...$components );
2310 }
2311
2319 public function hash256( $component ) {
2320 return hash_hmac( 'sha256', $component, $this->secret );
2321 }
2322
2374 final public function makeMultiKeys( array $ids, $keyCallback ) {
2375 $idByKey = [];
2376 foreach ( $ids as $id ) {
2377 // Discourage triggering of automatic makeKey() hashing in some backends
2378 if ( strlen( $id ) > 64 ) {
2379 $this->logger->warning( __METHOD__ . ": long ID '$id'; use hash256()" );
2380 }
2381 $key = $keyCallback( $id, $this );
2382 // Edge case: ignore key collisions due to duplicate $ids like "42" and 42
2383 if ( !isset( $idByKey[$key] ) ) {
2384 $idByKey[$key] = $id;
2385 } elseif ( (string)$id !== (string)$idByKey[$key] ) {
2386 throw new UnexpectedValueException(
2387 "Cache key collision; IDs ('$id','{$idByKey[$key]}') map to '$key'"
2388 );
2389 }
2390 }
2391
2392 return new ArrayIterator( $idByKey );
2393 }
2394
2430 final public function multiRemap( array $ids, array $res ) {
2431 if ( count( $ids ) !== count( $res ) ) {
2432 // If makeMultiKeys() is called on a list of non-unique IDs, then the resulting
2433 // ArrayIterator will have less entries due to "first appearance" de-duplication
2434 $ids = array_keys( array_fill_keys( $ids, true ) );
2435 if ( count( $ids ) !== count( $res ) ) {
2436 throw new UnexpectedValueException( "Multi-key result does not match ID list" );
2437 }
2438 }
2439
2440 return array_combine( $ids, $res );
2441 }
2442
2449 public function watchErrors() {
2450 return $this->cache->watchErrors();
2451 }
2452
2470 final public function getLastError( $watchPoint = 0 ) {
2471 $code = $this->cache->getLastError( $watchPoint );
2472 switch ( $code ) {
2473 case self::ERR_NONE:
2474 return self::ERR_NONE;
2476 return self::ERR_NO_RESPONSE;
2478 return self::ERR_UNREACHABLE;
2479 default:
2480 return self::ERR_UNEXPECTED;
2481 }
2482 }
2483
2488 final public function clearLastError() {
2489 wfDeprecated( __METHOD__, '1.38' );
2490 $this->cache->clearLastError();
2491 }
2492
2498 public function clearProcessCache() {
2499 $this->processCaches = [];
2500 }
2501
2522 final public function useInterimHoldOffCaching( $enabled ) {
2523 $this->useInterimHoldOffCaching = $enabled;
2524 }
2525
2531 public function getQoS( $flag ) {
2532 return $this->cache->getQoS( $flag );
2533 }
2534
2598 public function adaptiveTTL( $mtime, $maxTTL, $minTTL = 30, $factor = 0.2 ) {
2599 // handle fractional seconds and string integers
2600 $mtime = (int)$mtime;
2601 if ( $mtime <= 0 ) {
2602 // no last-modified time provided
2603 return $minTTL;
2604 }
2605
2606 $age = (int)$this->getCurrentTime() - $mtime;
2607
2608 return (int)min( $maxTTL, max( $minTTL, $factor * $age ) );
2609 }
2610
2616 final public function getWarmupKeyMisses() {
2617 // Number of misses in $this->warmupCache during the last call to certain methods
2618 return $this->warmupKeyMisses;
2619 }
2620
2635 protected function relayVolatilePurge( string $sisterKey, string $purgeValue, int $ttl ) {
2636 if ( $this->broadcastRoute !== null ) {
2637 $routeKey = $this->prependRoute( $sisterKey, $this->broadcastRoute );
2638 } else {
2639 $routeKey = $sisterKey;
2640 }
2641
2642 return $this->cache->set(
2643 $routeKey,
2644 $purgeValue,
2645 $ttl,
2646 $this->cache::WRITE_BACKGROUND
2647 );
2648 }
2649
2658 protected function relayNonVolatilePurge( string $sisterKey ) {
2659 if ( $this->broadcastRoute !== null ) {
2660 $routeKey = $this->prependRoute( $sisterKey, $this->broadcastRoute );
2661 } else {
2662 $routeKey = $sisterKey;
2663 }
2664
2665 return $this->cache->delete( $routeKey, $this->cache::WRITE_BACKGROUND );
2666 }
2667
2673 protected function prependRoute( string $sisterKey, string $route ) {
2674 if ( $sisterKey[0] === '/' ) {
2675 throw new RuntimeException( "Sister key '$sisterKey' already contains a route." );
2676 }
2677
2678 return $route . $sisterKey;
2679 }
2680
2692 private function scheduleAsyncRefresh( $key, $ttl, $callback, array $opts, array $cbParams ) {
2693 if ( !$this->asyncHandler ) {
2694 return false;
2695 }
2696 // Update the cache value later, such during post-send of an HTTP request. This forces
2697 // cache regeneration by setting "minAsOf" to infinity, meaning that no existing value
2698 // is considered valid. Furthermore, note that preemptive regeneration is not applicable
2699 // to invalid values, so there is no risk of infinite preemptive regeneration loops.
2700 $func = $this->asyncHandler;
2701 $func( function () use ( $key, $ttl, $callback, $opts, $cbParams ) {
2702 $opts['minAsOf'] = INF;
2703 try {
2704 $this->fetchOrRegenerate( $key, $ttl, $callback, $opts, $cbParams );
2705 } catch ( Exception $e ) {
2706 // Log some context for easier debugging
2707 $this->logger->error( 'Async refresh failed for {key}', [
2708 'key' => $key,
2709 'ttl' => $ttl,
2710 'exception' => $e
2711 ] );
2712 throw $e;
2713 }
2714 } );
2715
2716 return true;
2717 }
2718
2727 private function isAcceptablyFreshValue( $res, $graceTTL, $minAsOf ) {
2728 if ( !$this->isValid( $res[self::RES_VALUE], $res[self::RES_AS_OF], $minAsOf ) ) {
2729 // Value does not exists or is too old
2730 return false;
2731 }
2732
2733 $curTTL = $res[self::RES_CUR_TTL];
2734 if ( $curTTL > 0 ) {
2735 // Value is definitely still fresh
2736 return true;
2737 }
2738
2739 // Remaining seconds during which this stale value can be used
2740 $curGraceTTL = $graceTTL + $curTTL;
2741
2742 return ( $curGraceTTL > 0 )
2743 // Chance of using the value decreases as $curTTL goes from 0 to -$graceTTL
2744 ? !$this->worthRefreshExpiring( $curGraceTTL, $graceTTL, $graceTTL )
2745 // Value is too stale to fall in the grace period
2746 : false;
2747 }
2748
2759 protected function isLotteryRefreshDue( $res, $lowTTL, $ageNew, $hotTTR, $now ) {
2760 $curTTL = $res[self::RES_CUR_TTL];
2761 $logicalTTL = $res[self::RES_TTL];
2762 $asOf = $res[self::RES_AS_OF];
2763
2764 return (
2765 $this->worthRefreshExpiring( $curTTL, $logicalTTL, $lowTTL ) ||
2766 $this->worthRefreshPopular( $asOf, $ageNew, $hotTTR, $now )
2767 );
2768 }
2769
2785 protected function worthRefreshPopular( $asOf, $ageNew, $timeTillRefresh, $now ) {
2786 if ( $ageNew < 0 || $timeTillRefresh <= 0 ) {
2787 return false;
2788 }
2789
2790 $age = $now - $asOf;
2791 $timeOld = $age - $ageNew;
2792 if ( $timeOld <= 0 ) {
2793 return false;
2794 }
2795
2796 $popularHitsPerSec = 1;
2797 // Lifecycle is: new, ramp-up refresh chance, full refresh chance.
2798 // Note that the "expected # of refreshes" for the ramp-up time range is half
2799 // of what it would be if P(refresh) was at its full value during that time range.
2800 $refreshWindowSec = max( $timeTillRefresh - $ageNew - self::RAMPUP_TTL / 2, 1 );
2801 // P(refresh) * (# hits in $refreshWindowSec) = (expected # of refreshes)
2802 // P(refresh) * ($refreshWindowSec * $popularHitsPerSec) = 1 (by definition)
2803 // P(refresh) = 1/($refreshWindowSec * $popularHitsPerSec)
2804 $chance = 1 / ( $popularHitsPerSec * $refreshWindowSec );
2805 // Ramp up $chance from 0 to its nominal value over RAMPUP_TTL seconds to avoid stampedes
2806 $chance *= ( $timeOld <= self::RAMPUP_TTL ) ? $timeOld / self::RAMPUP_TTL : 1;
2807
2808 return ( mt_rand( 1, 1_000_000_000 ) <= 1_000_000_000 * $chance );
2809 }
2810
2829 protected function worthRefreshExpiring( $curTTL, $logicalTTL, $lowTTL ) {
2830 if ( $lowTTL <= 0 ) {
2831 return false;
2832 }
2833 // T264787: avoid having keys start off with a high chance of being refreshed;
2834 // the point where refreshing becomes possible cannot precede the key lifetime.
2835 $effectiveLowTTL = min( $lowTTL, $logicalTTL ?: INF );
2836
2837 // How long the value was in the "low TTL" phase
2838 $timeOld = $effectiveLowTTL - $curTTL;
2839 if ( $timeOld <= 0 || $timeOld >= $effectiveLowTTL ) {
2840 return false;
2841 }
2842
2843 // Ratio of the low TTL phase that has elapsed (r)
2844 $ttrRatio = $timeOld / $effectiveLowTTL;
2845 // Use p(r) as the monotonically increasing "chance of refresh" function,
2846 // having p(0)=0 and p(1)=1. The value expires at the nominal expiry.
2847 $chance = $ttrRatio ** 4;
2848
2849 return ( mt_rand( 1, 1_000_000_000 ) <= 1_000_000_000 * $chance );
2850 }
2851
2860 protected function isValid( $value, $asOf, $minAsOf ) {
2861 return ( $value !== false && $asOf >= $minAsOf );
2862 }
2863
2871 private function wrap( $value, $ttl, $version, $now ) {
2872 // Returns keys in ascending integer order for PHP7 array packing:
2873 // https://nikic.github.io/2014/12/22/PHPs-new-hashtable-implementation.html
2874 $wrapped = [
2875 self::FLD_FORMAT_VERSION => self::VERSION,
2876 self::FLD_VALUE => $value,
2877 self::FLD_TTL => $ttl,
2878 self::FLD_TIME => $now
2879 ];
2880 if ( $version !== null ) {
2881 $wrapped[self::FLD_VALUE_VERSION] = $version;
2882 }
2883
2884 return $wrapped;
2885 }
2886
2901 private function unwrap( $wrapped, $now ) {
2902 // https://nikic.github.io/2014/12/22/PHPs-new-hashtable-implementation.html
2903 $res = [
2904 // Attributes that only depend on the fetched key value
2905 self::RES_VALUE => false,
2906 self::RES_VERSION => null,
2907 self::RES_AS_OF => null,
2908 self::RES_TTL => null,
2909 self::RES_TOMB_AS_OF => null,
2910 // Attributes that depend on caller-specific "check" keys or "touched callbacks"
2911 self::RES_CHECK_AS_OF => null,
2912 self::RES_TOUCH_AS_OF => null,
2913 self::RES_CUR_TTL => null
2914 ];
2915
2916 if ( is_array( $wrapped ) ) {
2917 // Entry expected to be a cached value; validate it
2918 if (
2919 ( $wrapped[self::FLD_FORMAT_VERSION] ?? null ) === self::VERSION &&
2920 $wrapped[self::FLD_TIME] >= $this->epoch
2921 ) {
2922 if ( $wrapped[self::FLD_TTL] > 0 ) {
2923 // Get the approximate time left on the key
2924 $age = $now - $wrapped[self::FLD_TIME];
2925 $curTTL = max( $wrapped[self::FLD_TTL] - $age, 0.0 );
2926 } else {
2927 // Key had no TTL, so the time left is unbounded
2928 $curTTL = INF;
2929 }
2930 $res[self::RES_VALUE] = $wrapped[self::FLD_VALUE];
2931 $res[self::RES_VERSION] = $wrapped[self::FLD_VALUE_VERSION] ?? null;
2932 $res[self::RES_AS_OF] = $wrapped[self::FLD_TIME];
2933 $res[self::RES_CUR_TTL] = $curTTL;
2934 $res[self::RES_TTL] = $wrapped[self::FLD_TTL];
2935 }
2936 } else {
2937 // Entry expected to be a tombstone; parse it
2938 $purge = $this->parsePurgeValue( $wrapped );
2939 if ( $purge !== null ) {
2940 // Tombstoned keys should always have a negative "current TTL"
2941 $curTTL = min( $purge[self::PURGE_TIME] - $now, self::TINY_NEGATIVE );
2942 $res[self::RES_CUR_TTL] = $curTTL;
2943 $res[self::RES_TOMB_AS_OF] = $purge[self::PURGE_TIME];
2944 }
2945 }
2946
2947 return $res;
2948 }
2949
2955 private function determineKeyGroupForStats( $key ) {
2956 $parts = explode( ':', $key, 3 );
2957 // Fallback in case the key was not made by makeKey.
2958 // Replace dots because they are special in StatsD (T232907)
2959 return strtr( $parts[1] ?? $parts[0], '.', '_' );
2960 }
2961
2970 private function parsePurgeValue( $value ) {
2971 if ( !is_string( $value ) ) {
2972 return null;
2973 }
2974
2975 $segments = explode( ':', $value, 3 );
2976 $prefix = $segments[0];
2977 if ( $prefix !== self::PURGE_VAL_PREFIX ) {
2978 // Not a purge value
2979 return null;
2980 }
2981
2982 $timestamp = (float)$segments[1];
2983 // makeTombstonePurgeValue() doesn't store hold-off TTLs
2984 $holdoff = isset( $segments[2] ) ? (int)$segments[2] : self::HOLDOFF_TTL;
2985
2986 if ( $timestamp < $this->epoch ) {
2987 // Purge value is too old
2988 return null;
2989 }
2990
2991 return [ self::PURGE_TIME => $timestamp, self::PURGE_HOLDOFF => $holdoff ];
2992 }
2993
2998 private function makeTombstonePurgeValue( float $timestamp ) {
2999 return self::PURGE_VAL_PREFIX . ':' . (int)$timestamp;
3000 }
3001
3008 private function makeCheckPurgeValue( float $timestamp, int $holdoff, ?array &$purge = null ) {
3009 $normalizedTime = (int)$timestamp;
3010 // Purge array that matches what parsePurgeValue() would have returned
3011 $purge = [ self::PURGE_TIME => (float)$normalizedTime, self::PURGE_HOLDOFF => $holdoff ];
3012
3013 return self::PURGE_VAL_PREFIX . ":$normalizedTime:$holdoff";
3014 }
3015
3020 private function getProcessCache( $group ) {
3021 if ( !isset( $this->processCaches[$group] ) ) {
3022 [ , $size ] = explode( ':', $group );
3023 $this->processCaches[$group] = new MapCacheLRU( (int)$size );
3024 if ( $this->wallClockOverride !== null ) {
3025 $this->processCaches[$group]->setMockTime( $this->wallClockOverride );
3026 }
3027 }
3028
3029 return $this->processCaches[$group];
3030 }
3031
3037 private function getNonProcessCachedMultiKeys( ArrayIterator $keys, array $opts ) {
3038 $pcTTL = $opts['pcTTL'] ?? self::TTL_UNCACHEABLE;
3039
3040 $keysMissing = [];
3041 if ( $pcTTL > 0 && $this->callbackDepth == 0 ) {
3042 $pCache = $this->getProcessCache( $opts['pcGroup'] ?? self::PC_PRIMARY );
3043 foreach ( $keys as $key => $id ) {
3044 if ( !$pCache->has( $key, $pcTTL ) ) {
3045 $keysMissing[$id] = $key;
3046 }
3047 }
3048 }
3049
3050 return $keysMissing;
3051 }
3052
3059 private function fetchWrappedValuesForWarmupCache( array $keys, array $checkKeys ) {
3060 if ( !$keys ) {
3061 return [];
3062 }
3063
3064 // Get all the value keys to fetch...
3065 $sisterKeys = $this->makeSisterKeys( $keys, self::TYPE_VALUE );
3066 // Get all the "check" keys to fetch...
3067 foreach ( $checkKeys as $i => $checkKeyOrKeyGroup ) {
3068 // Note: avoid array_merge() inside loop in case there are many keys
3069 if ( is_int( $i ) ) {
3070 // Single "check" key that applies to all value keys
3071 $sisterKeys[] = $this->makeSisterKey( $checkKeyOrKeyGroup, self::TYPE_TIMESTAMP );
3072 } else {
3073 // List of "check" keys that apply to a specific value key
3074 foreach ( (array)$checkKeyOrKeyGroup as $checkKey ) {
3075 $sisterKeys[] = $this->makeSisterKey( $checkKey, self::TYPE_TIMESTAMP );
3076 }
3077 }
3078 }
3079
3080 $wrappedBySisterKey = $this->cache->getMulti( $sisterKeys );
3081 $wrappedBySisterKey += array_fill_keys( $sisterKeys, false );
3082
3083 return $wrappedBySisterKey;
3084 }
3085
3091 private function timeSinceLoggedMiss( $key, $now ) {
3092 // phpcs:ignore Generic.CodeAnalysis.AssignmentInCondition.Found
3093 for ( end( $this->missLog ); $miss = current( $this->missLog ); prev( $this->missLog ) ) {
3094 if ( $miss[0] === $key ) {
3095 return ( $now - $miss[1] );
3096 }
3097 }
3098
3099 return null;
3100 }
3101
3106 protected function getCurrentTime() {
3107 return $this->wallClockOverride ?: microtime( true );
3108 }
3109
3114 public function setMockTime( &$time ) {
3115 $this->wallClockOverride =& $time;
3116 $this->cache->setMockTime( $time );
3117 foreach ( $this->processCaches as $pCache ) {
3118 $pCache->setMockTime( $time );
3119 }
3120 }
3121}
3122
3124class_alias( WANObjectCache::class, 'WANObjectCache' );
wfDeprecated( $function, $version=false, $component=false, $callerOffset=2)
Logs a warning that a deprecated feature was used.
array $params
The job parameters.
Store key-value entries in a size-limited in-memory LRU cache.
Abstract class for any ephemeral data store.
Definition BagOStuff.php:88
No-op implementation that stores nothing.
Multi-datacenter aware caching interface.
makeMultiKeys(array $ids, $keyCallback)
Get an iterator of (cache key => entity ID) for a list of entity IDs.
adaptiveTTL( $mtime, $maxTTL, $minTTL=30, $factor=0.2)
Get a TTL that is higher for objects that have not changed recently.
prependRoute(string $sisterKey, string $route)
watchErrors()
Get a "watch point" token that can be used to get the "last error" to occur after now.
bool $useInterimHoldOffCaching
Whether to use "interim" caching while keys are tombstoned.
static newEmpty()
Get an instance that wraps EmptyBagOStuff.
const HOLDOFF_TTL_NONE
Idiom for delete()/touchCheckKey() meaning "no hold-off period".
float $epoch
Unix timestamp of the oldest possible valid values.
const KEY_VERSION
Version number attribute for a key; keep value for b/c (< 1.36)
fetchKeys(array $keys, array $checkKeys, float $now, $touchedCb=null)
Fetch the value and key metadata of several keys from cache.
isLotteryRefreshDue( $res, $lowTTL, $ageNew, $hotTTR, $now)
Check if a key is due for randomized regeneration due to near-expiration/popularity.
resetCheckKey( $key)
Clear the last-purge timestamp of a "check" key in all datacenters.
int $coalesceScheme
Scheme to use for key coalescing (Hash Tags or Hash Stops)
worthRefreshExpiring( $curTTL, $logicalTTL, $lowTTL)
Check if a key is nearing expiration and thus due for randomized regeneration.
makeGlobalKey( $keygroup,... $components)
const STALE_TTL_NONE
Idiom for set()/getWithSetCallback() meaning "no post-expiration persistence".
relayVolatilePurge(string $sisterKey, string $purgeValue, int $ttl)
Set a sister key to a purge value in all datacenters.
isValid( $value, $asOf, $minAsOf)
Check that a wrapper value exists and has an acceptable age.
const TTL_LAGGED
Max TTL, in seconds, to store keys when a data source has high replication lag.
getMultiCheckKeyTime(array $keys)
Fetch the values of each timestamp "check" key.
getWithSetCallback( $key, $ttl, $callback, array $opts=[], array $cbParams=[])
Method to fetch/regenerate a cache key.
getMultiWithSetCallback(ArrayIterator $keyedIds, $ttl, callable $callback, array $opts=[])
Method to fetch multiple cache keys at once with regeneration.
const KEY_CUR_TTL
Remaining TTL attribute for a key; keep value for b/c (< 1.36)
BagOStuff $cache
The local datacenter cache.
const HOLDOFF_TTL
Seconds to tombstone keys on delete() and to treat keys as volatile after purges.
worthRefreshPopular( $asOf, $ageNew, $timeTillRefresh, $now)
Check if a key is due for randomized regeneration due to its popularity.
string null $broadcastRoute
Routing prefix for operations that should be broadcasted to all data centers.
touchCheckKey( $key, $holdoff=self::HOLDOFF_TTL)
Increase the last-purge timestamp of a "check" key in all datacenters.
clearLastError()
Clear the "last error" registry.
getLastError( $watchPoint=0)
Get the "last error" registry.
const KEY_TTL
Logical TTL attribute for a key.
const KEY_AS_OF
Generation completion timestamp attribute for a key; keep value for b/c (< 1.36)
const KEY_CHECK_AS_OF
Highest "check" key timestamp for a key; keep value for b/c (< 1.36)
string $secret
Stable secret used for hashing long strings into key components.
callable null $asyncHandler
Function that takes a WAN cache callback and runs it later.
getCheckKeyTime( $key)
Fetch the value of a timestamp "check" key.
const KEY_TOMB_AS_OF
Tomstone timestamp attribute for a key; keep value for b/c (< 1.36)
MapCacheLRU[] $processCaches
Map of group PHP instance caches.
makeKey( $keygroup,... $components)
getMulti(array $keys, &$curTTLs=[], array $checkKeys=[], &$info=[])
Fetch the value of several keys from cache.
getMultiWithUnionSetCallback(ArrayIterator $keyedIds, $ttl, callable $callback, array $opts=[])
Method to fetch/regenerate multiple cache keys at once.
hash256( $component)
Hash a possibly long string into a suitable component for makeKey()/makeGlobalKey()
const PASS_BY_REF
Idiom for get()/getMulti() to return extra information by reference.
useInterimHoldOffCaching( $enabled)
Enable or disable the use of brief caching for tombstoned keys.
relayNonVolatilePurge(string $sisterKey)
Remove a sister key from all datacenters.
clearProcessCache()
Clear the in-process caches; useful for testing.
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...
const GRACE_TTL_NONE
Idiom for set()/getWithSetCallback() meaning "no post-expiration grace period".
This is the primary interface for validating metrics definitions, caching defined metrics,...
Generic interface providing Time-To-Live constants for expirable object storage.
Key-encoding methods for object caching (BagOStuff and WANObjectCache)
Generic interface providing error code and quality-of-service constants for object stores.
const ERR_NONE
No storage medium error.
const ERR_UNREACHABLE
Storage medium could not be reached to establish a connection.
const ERR_UNEXPECTED
Storage medium operation failed due to usage limitations or an I/O error.
const ERR_NO_RESPONSE
Storage medium failed to yield a complete response to an operation.
MediaWiki adaptation of StatsdDataFactory that provides buffering functionality.