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;
36
164class WANObjectCache implements
168 LoggerAwareInterface
169{
171 protected $cache;
173 protected $processCaches = [];
175 protected $logger;
177 protected $stats;
179 protected $asyncHandler;
180
192 protected $epoch;
194 protected $secret;
197
199 private $missLog;
200
202 private $callbackDepth = 0;
204 private $warmupCache = [];
206 private $warmupKeyMisses = 0;
207
209 private $wallClockOverride;
210
212 private const MAX_COMMIT_DELAY = 3;
214 private const MAX_READ_LAG = 7;
216 public const HOLDOFF_TTL = self::MAX_COMMIT_DELAY + self::MAX_READ_LAG + 1;
217
219 private const LOW_TTL = 60;
221 public const TTL_LAGGED = 30;
222
224 private const HOT_TTR = 900;
226 private const AGE_NEW = 60;
227
229 private const TSE_NONE = -1;
230
232 public const STALE_TTL_NONE = 0;
234 public const GRACE_TTL_NONE = 0;
236 public const HOLDOFF_TTL_NONE = 0;
237
239 public const MIN_TIMESTAMP_NONE = 0.0;
240
242 private const PC_PRIMARY = 'primary:1000';
243
245 public const PASS_BY_REF = [];
246
248 private const SCHEME_HASH_TAG = 1;
250 private const SCHEME_HASH_STOP = 2;
251
253 private const CHECK_KEY_TTL = self::TTL_YEAR;
255 private const INTERIM_KEY_TTL = 2;
256
258 private const LOCK_TTL = 10;
260 private const RAMPUP_TTL = 30;
261
263 private const TINY_NEGATIVE = -0.000001;
265 private const TINY_POSITIVE = 0.000001;
266
268 private const RECENT_SET_LOW_MS = 50;
270 private const RECENT_SET_HIGH_MS = 100;
271
273 private const GENERATION_HIGH_SEC = 0.2;
274
276 private const PURGE_TIME = 0;
278 private const PURGE_HOLDOFF = 1;
279
281 private const VERSION = 1;
282
284 public const KEY_VERSION = 'version';
286 public const KEY_AS_OF = 'asOf';
288 public const KEY_TTL = 'ttl';
290 public const KEY_CUR_TTL = 'curTTL';
292 public const KEY_TOMB_AS_OF = 'tombAsOf';
294 public const KEY_CHECK_AS_OF = 'lastCKPurge';
295
297 private const RES_VALUE = 0;
299 private const RES_VERSION = 1;
301 private const RES_AS_OF = 2;
303 private const RES_TTL = 3;
305 private const RES_TOMB_AS_OF = 4;
307 private const RES_CHECK_AS_OF = 5;
309 private const RES_TOUCH_AS_OF = 6;
311 private const RES_CUR_TTL = 7;
312
314 private const FLD_FORMAT_VERSION = 0;
316 private const FLD_VALUE = 1;
318 private const FLD_TTL = 2;
320 private const FLD_TIME = 3;
322 private const FLD_FLAGS = 4;
324 private const FLD_VALUE_VERSION = 5;
325 private const FLD_GENERATION_TIME = 6;
326
328 private const TYPE_VALUE = 'v';
330 private const TYPE_TIMESTAMP = 't';
332 private const TYPE_MUTEX = 'm';
334 private const TYPE_INTERIM = 'i';
335
337 private const PURGE_VAL_PREFIX = 'PURGED';
338
366 public function __construct( array $params ) {
367 $this->cache = $params['cache'];
368 $this->broadcastRoute = $params['broadcastRoutingPrefix'] ?? null;
369 $this->epoch = $params['epoch'] ?? 0;
370 $this->secret = $params['secret'] ?? (string)$this->epoch;
371 if ( ( $params['coalesceScheme'] ?? '' ) === 'hash_tag' ) {
372 // https://redis.io/topics/cluster-spec
373 // https://github.com/twitter/twemproxy/blob/v0.4.1/notes/recommendation.md#hash-tags
374 // https://github.com/Netflix/dynomite/blob/v0.7.0/notes/recommendation.md#hash-tags
375 $this->coalesceScheme = self::SCHEME_HASH_TAG;
376 } else {
377 // https://github.com/facebook/mcrouter/wiki/Key-syntax
378 $this->coalesceScheme = self::SCHEME_HASH_STOP;
379 }
380
381 $this->setLogger( $params['logger'] ?? new NullLogger() );
382
383 if ( isset( $params['stats'] ) && $params['stats'] instanceof IBufferingStatsdDataFactory ) {
385 __METHOD__,
386 'Use of StatsdDataFactory is deprecated in 1.43. Use StatsFactory instead.'
387 );
388 $params['stats'] = null;
389 }
390 $this->stats = $params['stats'] ?? StatsFactory::newNull();
391
392 $this->asyncHandler = $params['asyncHandler'] ?? null;
393 $this->missLog = array_fill( 0, 10, [ '', 0.0 ] );
394 }
395
399 public function setLogger( LoggerInterface $logger ) {
400 $this->logger = $logger;
401 }
402
408 public static function newEmpty() {
409 return new static( [ 'cache' => new EmptyBagOStuff() ] );
410 }
411
467 final public function get( $key, &$curTTL = null, array $checkKeys = [], &$info = [] ) {
468 // Note that an undeclared variable passed as $info starts as null (not the default).
469 // Also, if no $info parameter is provided, then it doesn't matter how it changes here.
470 $legacyInfo = ( $info !== self::PASS_BY_REF );
471
472 $now = $this->getCurrentTime();
473 $res = $this->fetchKeys( [ $key ], $checkKeys, $now )[$key];
474
475 $curTTL = $res[self::RES_CUR_TTL];
476 $info = $legacyInfo
477 ? $res[self::RES_AS_OF]
478 : [
479 self::KEY_VERSION => $res[self::RES_VERSION],
480 self::KEY_AS_OF => $res[self::RES_AS_OF],
481 self::KEY_TTL => $res[self::RES_TTL],
482 self::KEY_CUR_TTL => $res[self::RES_CUR_TTL],
483 self::KEY_TOMB_AS_OF => $res[self::RES_TOMB_AS_OF],
484 self::KEY_CHECK_AS_OF => $res[self::RES_CHECK_AS_OF]
485 ];
486
487 if ( $curTTL === null || $curTTL <= 0 ) {
488 // Log the timestamp in case a corresponding set() call does not provide "walltime"
489 unset( $this->missLog[array_key_first( $this->missLog )] );
490 $this->missLog[] = [ $key, $this->getCurrentTime() ];
491 }
492
493 return $res[self::RES_VALUE];
494 }
495
520 final public function getMulti(
521 array $keys,
522 &$curTTLs = [],
523 array $checkKeys = [],
524 &$info = []
525 ) {
526 // Note that an undeclared variable passed as $info starts as null (not the default).
527 // Also, if no $info parameter is provided, then it doesn't matter how it changes here.
528 $legacyInfo = ( $info !== self::PASS_BY_REF );
529
530 $curTTLs = [];
531 $info = [];
532 $valuesByKey = [];
533
534 $now = $this->getCurrentTime();
535 $resByKey = $this->fetchKeys( $keys, $checkKeys, $now );
536 foreach ( $resByKey as $key => $res ) {
537 if ( $res[self::RES_VALUE] !== false ) {
538 $valuesByKey[$key] = $res[self::RES_VALUE];
539 }
540
541 if ( $res[self::RES_CUR_TTL] !== null ) {
542 $curTTLs[$key] = $res[self::RES_CUR_TTL];
543 }
544 $info[$key] = $legacyInfo
545 ? $res[self::RES_AS_OF]
546 : [
547 self::KEY_VERSION => $res[self::RES_VERSION],
548 self::KEY_AS_OF => $res[self::RES_AS_OF],
549 self::KEY_TTL => $res[self::RES_TTL],
550 self::KEY_CUR_TTL => $res[self::RES_CUR_TTL],
551 self::KEY_TOMB_AS_OF => $res[self::RES_TOMB_AS_OF],
552 self::KEY_CHECK_AS_OF => $res[self::RES_CHECK_AS_OF]
553 ];
554 }
555
556 return $valuesByKey;
557 }
558
574 protected function fetchKeys( array $keys, array $checkKeys, float $now, $touchedCb = null ) {
575 $resByKey = [];
576
577 // List of all sister keys that need to be fetched from cache
578 $allSisterKeys = [];
579 // Order-corresponding value sister key list for the base key list ($keys)
580 $valueSisterKeys = [];
581 // List of "check" sister keys to compare all value sister keys against
582 $checkSisterKeysForAll = [];
583 // Map of (base key => additional "check" sister key(s) to compare against)
584 $checkSisterKeysByKey = [];
585
586 foreach ( $keys as $key ) {
587 $sisterKey = $this->makeSisterKey( $key, self::TYPE_VALUE );
588 $allSisterKeys[] = $sisterKey;
589 $valueSisterKeys[] = $sisterKey;
590 }
591
592 foreach ( $checkKeys as $i => $checkKeyOrKeyGroup ) {
593 // Note: avoid array_merge() inside loop in case there are many keys
594 if ( is_int( $i ) ) {
595 // Single "check" key that applies to all base keys
596 $sisterKey = $this->makeSisterKey( $checkKeyOrKeyGroup, self::TYPE_TIMESTAMP );
597 $allSisterKeys[] = $sisterKey;
598 $checkSisterKeysForAll[] = $sisterKey;
599 } else {
600 // List of "check" keys that apply to a specific base key
601 foreach ( (array)$checkKeyOrKeyGroup as $checkKey ) {
602 $sisterKey = $this->makeSisterKey( $checkKey, self::TYPE_TIMESTAMP );
603 $allSisterKeys[] = $sisterKey;
604 $checkSisterKeysByKey[$i][] = $sisterKey;
605 }
606 }
607 }
608
609 if ( $this->warmupCache ) {
610 // Get the wrapped values of the sister keys from the warmup cache
611 $wrappedBySisterKey = $this->warmupCache;
612 $sisterKeysMissing = array_diff( $allSisterKeys, array_keys( $wrappedBySisterKey ) );
613 if ( $sisterKeysMissing ) {
614 $this->warmupKeyMisses += count( $sisterKeysMissing );
615 $wrappedBySisterKey += $this->cache->getMulti( $sisterKeysMissing );
616 }
617 } else {
618 // Fetch the wrapped values of the sister keys from the backend
619 $wrappedBySisterKey = $this->cache->getMulti( $allSisterKeys );
620 }
621
622 // List of "check" sister key purge timestamps to compare all value sister keys against
623 $ckPurgesForAll = $this->processCheckKeys(
624 $checkSisterKeysForAll,
625 $wrappedBySisterKey,
626 $now
627 );
628 // Map of (base key => extra "check" sister key purge timestamp(s) to compare against)
629 $ckPurgesByKey = [];
630 foreach ( $checkSisterKeysByKey as $keyWithCheckKeys => $checkKeysForKey ) {
631 $ckPurgesByKey[$keyWithCheckKeys] = $this->processCheckKeys(
632 $checkKeysForKey,
633 $wrappedBySisterKey,
634 $now
635 );
636 }
637
638 // Unwrap and validate any value found for each base key (under the value sister key)
639 reset( $keys );
640 foreach ( $valueSisterKeys as $valueSisterKey ) {
641 // Get the corresponding base key for this value sister key
642 $key = current( $keys );
643 next( $keys );
644
645 if ( array_key_exists( $valueSisterKey, $wrappedBySisterKey ) ) {
646 // Key exists as either a live value or tombstone value
647 $wrapped = $wrappedBySisterKey[$valueSisterKey];
648 } else {
649 // Key does not exist
650 $wrapped = false;
651 }
652
653 $res = $this->unwrap( $wrapped, $now );
654 $value = $res[self::RES_VALUE];
655
656 foreach ( array_merge( $ckPurgesForAll, $ckPurgesByKey[$key] ?? [] ) as $ckPurge ) {
657 $res[self::RES_CHECK_AS_OF] = max(
658 $ckPurge[self::PURGE_TIME],
659 $res[self::RES_CHECK_AS_OF]
660 );
661 // Timestamp marking the end of the hold-off period for this purge
662 $holdoffDeadline = $ckPurge[self::PURGE_TIME] + $ckPurge[self::PURGE_HOLDOFF];
663 // Check if the value was generated during the hold-off period
664 if ( $value !== false && $holdoffDeadline >= $res[self::RES_AS_OF] ) {
665 // How long ago this value was purged by *this* "check" key
666 $ago = min( $ckPurge[self::PURGE_TIME] - $now, self::TINY_NEGATIVE );
667 // How long ago this value was purged by *any* known "check" key
668 $res[self::RES_CUR_TTL] = min( $res[self::RES_CUR_TTL], $ago );
669 }
670 }
671
672 if ( $touchedCb !== null && $value !== false ) {
673 $touched = $touchedCb( $value );
674 if ( $touched !== null && $touched >= $res[self::RES_AS_OF] ) {
675 $res[self::RES_CUR_TTL] = min(
676 $res[self::RES_CUR_TTL],
677 $res[self::RES_AS_OF] - $touched,
678 self::TINY_NEGATIVE
679 );
680 }
681 } else {
682 $touched = null;
683 }
684
685 $res[self::RES_TOUCH_AS_OF] = max( $res[self::RES_TOUCH_AS_OF], $touched );
686
687 $resByKey[$key] = $res;
688 }
689
690 return $resByKey;
691 }
692
699 private function processCheckKeys(
700 array $checkSisterKeys,
701 array $wrappedBySisterKey,
702 float $now
703 ) {
704 $purges = [];
705
706 foreach ( $checkSisterKeys as $timeKey ) {
707 $purge = isset( $wrappedBySisterKey[$timeKey] )
708 ? $this->parsePurgeValue( $wrappedBySisterKey[$timeKey] )
709 : null;
710
711 if ( $purge === null ) {
712 // No holdoff when lazy creating a check key, use cache right away (T344191)
713 $wrapped = $this->makeCheckPurgeValue( $now, self::HOLDOFF_TTL_NONE, $purge );
714 $this->cache->add(
715 $timeKey,
716 $wrapped,
717 self::CHECK_KEY_TTL,
718 $this->cache::WRITE_BACKGROUND
719 );
720 }
721
722 $purges[] = $purge;
723 }
724
725 return $purges;
726 }
727
811 final public function set( $key, $value, $ttl = self::TTL_INDEFINITE, array $opts = [] ) {
812 $keygroup = $this->determineKeyGroupForStats( $key );
813
814 $ok = $this->setMainValue(
815 $key,
816 $value,
817 $ttl,
818 $opts['version'] ?? null,
819 $opts['walltime'] ?? null,
820 $opts['lag'] ?? 0,
821 $opts['since'] ?? null,
822 $opts['pending'] ?? false,
823 $opts['lockTSE'] ?? self::TSE_NONE,
824 $opts['staleTTL'] ?? self::STALE_TTL_NONE,
825 $opts['segmentable'] ?? false,
826 $opts['creating'] ?? false
827 );
828
829 $this->stats->getCounter( 'wanobjectcache_set_total' )
830 ->setLabel( 'keygroup', $keygroup )
831 ->setLabel( 'result', ( $ok ? 'ok' : 'error' ) )
832 ->copyToStatsdAt( "wanobjectcache.$keygroup.set." . ( $ok ? 'ok' : 'error' ) )
833 ->increment();
834
835 return $ok;
836 }
837
853 private function setMainValue(
854 $key,
855 $value,
856 $ttl,
857 ?int $version,
858 ?float $walltime,
859 $dataReplicaLag,
860 $dataReadSince,
861 bool $dataPendingCommit,
862 int $lockTSE,
863 int $staleTTL,
864 bool $segmentable,
865 bool $creating
866 ) {
867 if ( $ttl < 0 ) {
868 // not cacheable
869 return true;
870 }
871
872 $now = $this->getCurrentTime();
873 $ttl = (int)$ttl;
874 $walltime ??= $this->timeSinceLoggedMiss( $key, $now );
875 $dataSnapshotLag = ( $dataReadSince !== null ) ? max( 0, $now - $dataReadSince ) : 0;
876 $dataCombinedLag = $dataReplicaLag + $dataSnapshotLag;
877
878 // Forbid caching data that only exists within an uncommitted transaction. Also, lower
879 // the TTL when the data has a "since" time so far in the past that a delete() tombstone,
880 // made after that time, could have already expired (the key is no longer write-holed).
881 // The mitigation TTL depends on whether this data lag is assumed to systemically effect
882 // regeneration attempts in the near future. The TTL also reflects regeneration wall time.
883 if ( $dataPendingCommit ) {
884 // Case A: data comes from an uncommitted write transaction
885 $mitigated = 'pending writes';
886 // Data might never be committed; rely on a less problematic regeneration attempt
887 $mitigationTTL = self::TTL_UNCACHEABLE;
888 } elseif ( $dataSnapshotLag > self::MAX_READ_LAG ) {
889 // Case B: high snapshot lag
890 $pregenSnapshotLag = ( $walltime !== null ) ? ( $dataSnapshotLag - $walltime ) : 0;
891 if ( ( $pregenSnapshotLag + self::GENERATION_HIGH_SEC ) > self::MAX_READ_LAG ) {
892 // Case B1: generation started when transaction duration was already long
893 $mitigated = 'snapshot lag (late generation)';
894 // Probably non-systemic; rely on a less problematic regeneration attempt
895 $mitigationTTL = self::TTL_UNCACHEABLE;
896 } else {
897 // Case B2: slow generation made transaction duration long
898 $mitigated = 'snapshot lag (high generation time)';
899 // Probably systemic; use a low TTL to avoid stampedes/uncacheability
900 $mitigationTTL = self::TTL_LAGGED;
901 }
902 } elseif ( $dataReplicaLag === false || $dataReplicaLag > self::MAX_READ_LAG ) {
903 // Case C: low/medium snapshot lag with high replication lag
904 $mitigated = 'replication lag';
905 // Probably systemic; use a low TTL to avoid stampedes/uncacheability
906 $mitigationTTL = self::TTL_LAGGED;
907 } elseif ( $dataCombinedLag > self::MAX_READ_LAG ) {
908 $pregenCombinedLag = ( $walltime !== null ) ? ( $dataCombinedLag - $walltime ) : 0;
909 // Case D: medium snapshot lag with medium replication lag
910 if ( ( $pregenCombinedLag + self::GENERATION_HIGH_SEC ) > self::MAX_READ_LAG ) {
911 // Case D1: generation started when read lag was too high
912 $mitigated = 'read lag (late generation)';
913 // Probably non-systemic; rely on a less problematic regeneration attempt
914 $mitigationTTL = self::TTL_UNCACHEABLE;
915 } else {
916 // Case D2: slow generation made read lag too high
917 $mitigated = 'read lag (high generation time)';
918 // Probably systemic; use a low TTL to avoid stampedes/uncacheability
919 $mitigationTTL = self::TTL_LAGGED;
920 }
921 } else {
922 // Case E: new value generated with recent data
923 $mitigated = null;
924 // Nothing to mitigate
925 $mitigationTTL = null;
926 }
927
928 if ( $mitigationTTL === self::TTL_UNCACHEABLE ) {
929 $this->logger->warning(
930 "Rejected set() for {cachekey} due to $mitigated.",
931 [
932 'cachekey' => $key,
933 'lag' => $dataReplicaLag,
934 'age' => $dataSnapshotLag,
935 'walltime' => $walltime
936 ]
937 );
938
939 // no-op the write for being unsafe
940 return true;
941 }
942
943 // TTL to use in staleness checks (does not effect persistence layer TTL)
944 $logicalTTL = null;
945
946 if ( $mitigationTTL !== null ) {
947 // New value was generated from data that is old enough to be risky
948 if ( $lockTSE >= 0 ) {
949 // Persist the value as long as normal, but make it count as stale sooner
950 $logicalTTL = min( $ttl ?: INF, $mitigationTTL );
951 } else {
952 // Persist the value for a shorter duration
953 $ttl = min( $ttl ?: INF, $mitigationTTL );
954 }
955
956 $this->logger->warning(
957 "Lowered set() TTL for {cachekey} due to $mitigated.",
958 [
959 'cachekey' => $key,
960 'lag' => $dataReplicaLag,
961 'age' => $dataSnapshotLag,
962 'walltime' => $walltime
963 ]
964 );
965 }
966
967 // Wrap that value with time/TTL/version metadata
968 $wrapped = $this->wrap( $value, $logicalTTL ?: $ttl, $version, $now );
969 $storeTTL = $ttl + $staleTTL;
970
971 $flags = $this->cache::WRITE_BACKGROUND;
972 if ( $segmentable ) {
973 $flags |= $this->cache::WRITE_ALLOW_SEGMENTS;
974 }
975
976 if ( $creating ) {
977 $ok = $this->cache->add(
978 $this->makeSisterKey( $key, self::TYPE_VALUE ),
979 $wrapped,
980 $storeTTL,
981 $flags
982 );
983 } else {
984 $ok = $this->cache->merge(
985 $this->makeSisterKey( $key, self::TYPE_VALUE ),
986 static function ( $cache, $key, $cWrapped ) use ( $wrapped ) {
987 // A string value means that it is a tombstone; do nothing in that case
988 return ( is_string( $cWrapped ) ) ? false : $wrapped;
989 },
990 $storeTTL,
991 $this->cache::MAX_CONFLICTS_ONE,
992 $flags
993 );
994 }
995
996 return $ok;
997 }
998
1061 final public function delete( $key, $ttl = self::HOLDOFF_TTL ) {
1062 // Purge values must be stored under the value key so that WANObjectCache::set()
1063 // can atomically merge values without accidentally undoing a recent purge and thus
1064 // violating the holdoff TTL restriction.
1065 $valueSisterKey = $this->makeSisterKey( $key, self::TYPE_VALUE );
1066
1067 if ( $ttl <= 0 ) {
1068 // A client or cache cleanup script is requesting a cache purge, so there is no
1069 // volatility period due to replica DB lag. Any recent change to an entity cached
1070 // in this key should have triggered an appropriate purge event.
1071 $ok = $this->relayNonVolatilePurge( $valueSisterKey );
1072 } else {
1073 // A cacheable entity recently changed, so there might be a volatility period due
1074 // to replica DB lag. Clients usually expect their actions to be reflected in any
1075 // of their subsequent web request. This is attainable if (a) purge relay lag is
1076 // lower than the time it takes for subsequent request by the client to arrive,
1077 // and, (b) DB replica queries have "read-your-writes" consistency due to DB lag
1078 // mitigation systems.
1079 $now = $this->getCurrentTime();
1080 // Set the key to the purge value in all datacenters
1081 $purge = $this->makeTombstonePurgeValue( $now );
1082 $ok = $this->relayVolatilePurge( $valueSisterKey, $purge, $ttl );
1083 }
1084
1085 $keygroup = $this->determineKeyGroupForStats( $key );
1086
1087 $this->stats->getCounter( 'wanobjectcache_delete_total' )
1088 ->setLabel( 'keygroup', $keygroup )
1089 ->setLabel( 'result', ( $ok ? 'ok' : 'error' ) )
1090 ->copyToStatsdAt( "wanobjectcache.$keygroup.delete." . ( $ok ? 'ok' : 'error' ) )
1091 ->increment();
1092
1093 return $ok;
1094 }
1095
1115 final public function getCheckKeyTime( $key ) {
1116 return $this->getMultiCheckKeyTime( [ $key ] )[$key];
1117 }
1118
1180 final public function getMultiCheckKeyTime( array $keys ) {
1181 $checkSisterKeysByKey = [];
1182 foreach ( $keys as $key ) {
1183 $checkSisterKeysByKey[$key] = $this->makeSisterKey( $key, self::TYPE_TIMESTAMP );
1184 }
1185
1186 $wrappedBySisterKey = $this->cache->getMulti( $checkSisterKeysByKey );
1187 $wrappedBySisterKey += array_fill_keys( $checkSisterKeysByKey, false );
1188
1189 $now = $this->getCurrentTime();
1190 $times = [];
1191 foreach ( $checkSisterKeysByKey as $key => $checkSisterKey ) {
1192 $purge = $this->parsePurgeValue( $wrappedBySisterKey[$checkSisterKey] );
1193 if ( $purge === null ) {
1194 $wrapped = $this->makeCheckPurgeValue( $now, self::HOLDOFF_TTL_NONE, $purge );
1195 $this->cache->add(
1196 $checkSisterKey,
1197 $wrapped,
1198 self::CHECK_KEY_TTL,
1199 $this->cache::WRITE_BACKGROUND
1200 );
1201 }
1202
1203 $times[$key] = $purge[self::PURGE_TIME];
1204 }
1205
1206 return $times;
1207 }
1208
1242 final public function touchCheckKey( $key, $holdoff = self::HOLDOFF_TTL ) {
1243 $checkSisterKey = $this->makeSisterKey( $key, self::TYPE_TIMESTAMP );
1244
1245 $now = $this->getCurrentTime();
1246 $purge = $this->makeCheckPurgeValue( $now, $holdoff );
1247 $ok = $this->relayVolatilePurge( $checkSisterKey, $purge, self::CHECK_KEY_TTL );
1248
1249 $keygroup = $this->determineKeyGroupForStats( $key );
1250
1251 $this->stats->getCounter( 'wanobjectcache_check_total' )
1252 ->setLabel( 'keygroup', $keygroup )
1253 ->setLabel( 'result', ( $ok ? 'ok' : 'error' ) )
1254 ->copyToStatsdAt( "wanobjectcache.$keygroup.ck_touch." . ( $ok ? 'ok' : 'error' ) )
1255 ->increment();
1256
1257 return $ok;
1258 }
1259
1287 final public function resetCheckKey( $key ) {
1288 $checkSisterKey = $this->makeSisterKey( $key, self::TYPE_TIMESTAMP );
1289 $ok = $this->relayNonVolatilePurge( $checkSisterKey );
1290
1291 $keygroup = $this->determineKeyGroupForStats( $key );
1292
1293 $this->stats->getCounter( 'wanobjectcache_reset_total' )
1294 ->setLabel( 'keygroup', $keygroup )
1295 ->setLabel( 'result', ( $ok ? 'ok' : 'error' ) )
1296 ->copyToStatsdAt( "wanobjectcache.$keygroup.ck_reset." . ( $ok ? 'ok' : 'error' ) )
1297 ->increment();
1298
1299 return $ok;
1300 }
1301
1603 final public function getWithSetCallback(
1604 $key, $ttl, $callback, array $opts = [], array $cbParams = []
1605 ) {
1606 $version = $opts['version'] ?? null;
1607 $pcTTL = $opts['pcTTL'] ?? self::TTL_UNCACHEABLE;
1608 $pCache = ( $pcTTL >= 0 )
1609 ? $this->getProcessCache( $opts['pcGroup'] ?? self::PC_PRIMARY )
1610 : null;
1611
1612 // Use the process cache if requested as long as no outer cache callback is running.
1613 // Nested callback process cache use is not lag-safe with regard to HOLDOFF_TTL since
1614 // process cached values are more lagged than persistent ones as they are not purged.
1615 if ( $pCache && $this->callbackDepth == 0 ) {
1616 $cached = $pCache->get( $key, $pcTTL, false );
1617 if ( $cached !== false ) {
1618 $this->logger->debug( "getWithSetCallback($key): process cache hit" );
1619 return $cached;
1620 }
1621 }
1622
1623 [ $value, $valueVersion, $curAsOf ] = $this->fetchOrRegenerate( $key, $ttl, $callback, $opts, $cbParams );
1624 if ( $valueVersion !== $version ) {
1625 // Current value has a different version; use the variant key for this version.
1626 // Regenerate the variant value if it is not newer than the main value at $key
1627 // so that purges to the main key propagate to the variant value.
1628 $this->logger->debug( "getWithSetCallback($key): using variant key" );
1629 [ $value ] = $this->fetchOrRegenerate(
1630 $this->makeGlobalKey( 'WANCache-key-variant', md5( $key ), (string)$version ),
1631 $ttl,
1632 $callback,
1633 [ 'version' => null, 'minAsOf' => $curAsOf ] + $opts,
1634 $cbParams
1635 );
1636 }
1637
1638 // Update the process cache if enabled
1639 if ( $pCache && $value !== false ) {
1640 $pCache->set( $key, $value );
1641 }
1642
1643 return $value;
1644 }
1645
1662 private function fetchOrRegenerate( $key, $ttl, $callback, array $opts, array $cbParams ) {
1663 $checkKeys = $opts['checkKeys'] ?? [];
1664 $graceTTL = $opts['graceTTL'] ?? self::GRACE_TTL_NONE;
1665 $minAsOf = $opts['minAsOf'] ?? self::MIN_TIMESTAMP_NONE;
1666 $hotTTR = $opts['hotTTR'] ?? self::HOT_TTR;
1667 $lowTTL = $opts['lowTTL'] ?? min( self::LOW_TTL, $ttl );
1668 $ageNew = $opts['ageNew'] ?? self::AGE_NEW;
1669 $touchedCb = $opts['touchedCallback'] ?? null;
1670 $startTime = $this->getCurrentTime();
1671
1672 $keygroup = $this->determineKeyGroupForStats( $key );
1673
1674 // Get the current key value and its metadata
1675 $curState = $this->fetchKeys( [ $key ], $checkKeys, $startTime, $touchedCb )[$key];
1676 $curValue = $curState[self::RES_VALUE];
1677
1678 // Use the cached value if it exists and is not due for synchronous regeneration
1679 if ( $this->isAcceptablyFreshValue( $curState, $graceTTL, $minAsOf ) ) {
1680 if ( !$this->isLotteryRefreshDue( $curState, $lowTTL, $ageNew, $hotTTR, $startTime ) ) {
1681 $this->stats->getTiming( 'wanobjectcache_getwithset_seconds' )
1682 ->setLabel( 'keygroup', $keygroup )
1683 ->setLabel( 'result', 'hit' )
1684 ->setLabel( 'reason', 'good' )
1685 ->copyToStatsdAt( "wanobjectcache.$keygroup.hit.good" )
1686 ->observe( 1e3 * ( $this->getCurrentTime() - $startTime ) );
1687
1688 return [ $curValue, $curState[self::RES_VERSION], $curState[self::RES_AS_OF] ];
1689 } elseif ( $this->scheduleAsyncRefresh( $key, $ttl, $callback, $opts, $cbParams ) ) {
1690 $this->logger->debug( "fetchOrRegenerate($key): hit with async refresh" );
1691
1692 $this->stats->getTiming( 'wanobjectcache_getwithset_seconds' )
1693 ->setLabel( 'keygroup', $keygroup )
1694 ->setLabel( 'result', 'hit' )
1695 ->setLabel( 'reason', 'refresh' )
1696 ->copyToStatsdAt( "wanobjectcache.$keygroup.hit.refresh" )
1697 ->observe( 1e3 * ( $this->getCurrentTime() - $startTime ) );
1698
1699 return [ $curValue, $curState[self::RES_VERSION], $curState[self::RES_AS_OF] ];
1700 } else {
1701 $this->logger->debug( "fetchOrRegenerate($key): hit with sync refresh" );
1702 }
1703 }
1704
1705 $isKeyTombstoned = ( $curState[self::RES_TOMB_AS_OF] !== null );
1706 // Use the interim key as a temporary alternative if the key is tombstoned
1707 if ( $isKeyTombstoned ) {
1708 $volState = $this->getInterimValue( $key, $minAsOf, $startTime, $touchedCb );
1709 $volValue = $volState[self::RES_VALUE];
1710 } else {
1711 $volState = $curState;
1712 $volValue = $curValue;
1713 }
1714
1715 // During the volatile "hold-off" period that follows a purge of the key, the value
1716 // will be regenerated many times if frequently accessed. This is done to mitigate
1717 // the effects of backend replication lag as soon as possible. However, throttle the
1718 // overhead of locking and regeneration by reusing values recently written to cache
1719 // tens of milliseconds ago. Verify the "as of" time against the last purge event.
1720 $lastPurgeTime = max(
1721 // RES_TOUCH_AS_OF depends on the value (possibly from the interim key)
1722 $volState[self::RES_TOUCH_AS_OF],
1723 $curState[self::RES_TOMB_AS_OF],
1724 $curState[self::RES_CHECK_AS_OF]
1725 );
1726 $safeMinAsOf = max( $minAsOf, $lastPurgeTime + self::TINY_POSITIVE );
1727 if ( $this->isExtremelyNewValue( $volState, $safeMinAsOf, $startTime ) ) {
1728 $this->logger->debug( "fetchOrRegenerate($key): volatile hit" );
1729
1730 $this->stats->getTiming( 'wanobjectcache_getwithset_seconds' )
1731 ->setLabel( 'keygroup', $keygroup )
1732 ->setLabel( 'result', 'hit' )
1733 ->setLabel( 'reason', 'volatile' )
1734 ->copyToStatsdAt( "wanobjectcache.$keygroup.hit.volatile" )
1735 ->observe( 1e3 * ( $this->getCurrentTime() - $startTime ) );
1736
1737 return [ $volValue, $volState[self::RES_VERSION], $curState[self::RES_AS_OF] ];
1738 }
1739
1740 $lockTSE = $opts['lockTSE'] ?? self::TSE_NONE;
1741 $busyValue = $opts['busyValue'] ?? null;
1742 $staleTTL = $opts['staleTTL'] ?? self::STALE_TTL_NONE;
1743 $segmentable = $opts['segmentable'] ?? false;
1744 $version = $opts['version'] ?? null;
1745
1746 // Determine whether one thread per datacenter should handle regeneration at a time
1747 $useRegenerationLock =
1748 // Note that since tombstones no-op set(), $lockTSE and $curTTL cannot be used to
1749 // deduce the key hotness because |$curTTL| will always keep increasing until the
1750 // tombstone expires or is overwritten by a new tombstone. Also, even if $lockTSE
1751 // is not set, constant regeneration of a key for the tombstone lifetime might be
1752 // very expensive. Assume tombstoned keys are possibly hot in order to reduce
1753 // the risk of high regeneration load after the delete() method is called.
1754 $isKeyTombstoned ||
1755 // Assume a key is hot if requested soon ($lockTSE seconds) after purge.
1756 // This avoids stampedes when timestamps from $checkKeys/$touchedCb bump.
1757 (
1758 $curState[self::RES_CUR_TTL] !== null &&
1759 $curState[self::RES_CUR_TTL] <= 0 &&
1760 abs( $curState[self::RES_CUR_TTL] ) <= $lockTSE
1761 ) ||
1762 // Assume a key is hot if there is no value and a busy fallback is given.
1763 // This avoids stampedes on eviction or preemptive regeneration taking too long.
1764 ( $busyValue !== null && $volValue === false );
1765
1766 // If a regeneration lock is required, threads that do not get the lock will try to use
1767 // the stale value, the interim value, or the $busyValue placeholder, in that order. If
1768 // none of those are set then all threads will bypass the lock and regenerate the value.
1769 $hasLock = $useRegenerationLock && $this->claimStampedeLock( $key );
1770 if ( $useRegenerationLock && !$hasLock ) {
1771 // Determine if there is stale or volatile cached value that is still usable
1772 // @phan-suppress-next-line PhanTypeMismatchArgumentNullable False positive
1773 if ( $this->isValid( $volValue, $volState[self::RES_AS_OF], $minAsOf ) ) {
1774 $this->logger->debug( "fetchOrRegenerate($key): returning stale value" );
1775
1776 $this->stats->getTiming( 'wanobjectcache_getwithset_seconds' )
1777 ->setLabel( 'keygroup', $keygroup )
1778 ->setLabel( 'result', 'hit' )
1779 ->setLabel( 'reason', 'stale' )
1780 ->copyToStatsdAt( "wanobjectcache.$keygroup.hit.stale" )
1781 ->observe( 1e3 * ( $this->getCurrentTime() - $startTime ) );
1782
1783 return [ $volValue, $volState[self::RES_VERSION], $curState[self::RES_AS_OF] ];
1784 } elseif ( $busyValue !== null ) {
1785 $miss = is_infinite( $minAsOf ) ? 'renew' : 'miss';
1786 $this->logger->debug( "fetchOrRegenerate($key): busy $miss" );
1787
1788 $this->stats->getTiming( 'wanobjectcache_getwithset_seconds' )
1789 ->setLabel( 'keygroup', $keygroup )
1790 ->setLabel( 'result', $miss )
1791 ->setLabel( 'reason', 'busy' )
1792 ->copyToStatsdAt( "wanobjectcache.$keygroup.$miss.busy" )
1793 ->observe( 1e3 * ( $this->getCurrentTime() - $startTime ) );
1794
1795 $placeholderValue = $this->resolveBusyValue( $busyValue );
1796
1797 return [ $placeholderValue, $version, $curState[self::RES_AS_OF] ];
1798 }
1799 }
1800
1801 // Generate the new value given any prior value with a matching version
1802 $setOpts = [];
1803 $preCallbackTime = $this->getCurrentTime();
1804 ++$this->callbackDepth;
1805 // https://github.com/phan/phan/issues/4419
1806 $value = null;
1807 try {
1808 $value = $callback(
1809 ( $curState[self::RES_VERSION] === $version ) ? $curValue : false,
1810 $ttl,
1811 $setOpts,
1812 ( $curState[self::RES_VERSION] === $version ) ? $curState[self::RES_AS_OF] : null,
1813 $cbParams
1814 );
1815 } finally {
1816 --$this->callbackDepth;
1817 }
1818 $postCallbackTime = $this->getCurrentTime();
1819
1820 // How long it took to generate the value
1821 $walltime = max( $postCallbackTime - $preCallbackTime, 0.0 );
1822
1823 $this->stats->getTiming( 'wanobjectcache_regen_seconds' )
1824 ->setLabel( 'keygroup', $keygroup )
1825 ->copyToStatsdAt( "wanobjectcache.$keygroup.regen_walltime" )
1826 ->observe( 1e3 * $walltime );
1827
1828 // Attempt to save the newly generated value if applicable
1829 if (
1830 // Callback yielded a cacheable value
1831 ( $value !== false && $ttl >= 0 ) &&
1832 // Current thread was not raced out of a regeneration lock or key is tombstoned
1833 ( !$useRegenerationLock || $hasLock || $isKeyTombstoned )
1834 ) {
1835 // If the key is write-holed then use the (volatile) interim key as an alternative
1836 if ( $isKeyTombstoned ) {
1837 $this->setInterimValue(
1838 $key,
1839 $value,
1840 $lockTSE,
1841 $version,
1842 $segmentable
1843 );
1844 } else {
1845 $this->setMainValue(
1846 $key,
1847 $value,
1848 $ttl,
1849 $version,
1850 $walltime,
1851 // @phan-suppress-next-line PhanCoalescingAlwaysNull
1852 $setOpts['lag'] ?? 0,
1853 // @phan-suppress-next-line PhanCoalescingAlwaysNull
1854 $setOpts['since'] ?? $preCallbackTime,
1855 // @phan-suppress-next-line PhanCoalescingAlwaysNull
1856 $setOpts['pending'] ?? false,
1857 $lockTSE,
1858 $staleTTL,
1859 $segmentable,
1860 ( $curValue === false )
1861 );
1862 }
1863 }
1864
1865 $this->yieldStampedeLock( $key, $hasLock );
1866
1867 $miss = is_infinite( $minAsOf ) ? 'renew' : 'miss';
1868 $this->logger->debug( "fetchOrRegenerate($key): $miss, new value computed" );
1869
1870 $this->stats->getTiming( 'wanobjectcache_getwithset_seconds' )
1871 ->setLabel( 'keygroup', $keygroup )
1872 ->setLabel( 'result', $miss )
1873 ->setLabel( 'reason', 'compute' )
1874 ->copyToStatsdAt( "wanobjectcache.$keygroup.$miss.compute" )
1875 ->observe( 1e3 * ( $this->getCurrentTime() - $startTime ) );
1876
1877 return [ $value, $version, $curState[self::RES_AS_OF] ];
1878 }
1879
1884 private function claimStampedeLock( $key ) {
1885 $checkSisterKey = $this->makeSisterKey( $key, self::TYPE_MUTEX );
1886 // Note that locking is not bypassed due to I/O errors; this avoids stampedes
1887 return $this->cache->add( $checkSisterKey, 1, self::LOCK_TTL );
1888 }
1889
1894 private function yieldStampedeLock( $key, $hasLock ) {
1895 if ( $hasLock ) {
1896 $checkSisterKey = $this->makeSisterKey( $key, self::TYPE_MUTEX );
1897 $this->cache->delete( $checkSisterKey, $this->cache::WRITE_BACKGROUND );
1898 }
1899 }
1900
1911 private function makeSisterKeys( array $baseKeys, string $type, string $route = null ) {
1912 $sisterKeys = [];
1913 foreach ( $baseKeys as $baseKey ) {
1914 $sisterKeys[] = $this->makeSisterKey( $baseKey, $type, $route );
1915 }
1916
1917 return $sisterKeys;
1918 }
1919
1930 private function makeSisterKey( string $baseKey, string $typeChar, string $route = null ) {
1931 if ( $this->coalesceScheme === self::SCHEME_HASH_STOP ) {
1932 // Key style: "WANCache:<base key>|#|<character>"
1933 $sisterKey = 'WANCache:' . $baseKey . '|#|' . $typeChar;
1934 } else {
1935 // Key style: "WANCache:{<base key>}:<character>"
1936 $sisterKey = 'WANCache:{' . $baseKey . '}:' . $typeChar;
1937 }
1938
1939 if ( $route !== null ) {
1940 $sisterKey = $this->prependRoute( $sisterKey, $route );
1941 }
1942
1943 return $sisterKey;
1944 }
1945
1958 private function isExtremelyNewValue( $res, $minAsOf, $now ) {
1959 if ( $res[self::RES_VALUE] === false || $res[self::RES_AS_OF] < $minAsOf ) {
1960 return false;
1961 }
1962
1963 $age = $now - $res[self::RES_AS_OF];
1964
1965 return ( $age < mt_rand( self::RECENT_SET_LOW_MS, self::RECENT_SET_HIGH_MS ) / 1e3 );
1966 }
1967
1977 private function getInterimValue( $key, $minAsOf, $now, $touchedCb ) {
1978 if ( $this->useInterimHoldOffCaching ) {
1979 $interimSisterKey = $this->makeSisterKey( $key, self::TYPE_INTERIM );
1980 $wrapped = $this->cache->get( $interimSisterKey );
1981 $res = $this->unwrap( $wrapped, $now );
1982 if ( $res[self::RES_VALUE] !== false && $res[self::RES_AS_OF] >= $minAsOf ) {
1983 if ( $touchedCb !== null ) {
1984 // Update "last purge time" since the $touchedCb timestamp depends on $value
1985 // Get the new "touched timestamp", accounting for callback-checked dependencies
1986 $res[self::RES_TOUCH_AS_OF] = max(
1987 $touchedCb( $res[self::RES_VALUE] ),
1988 $res[self::RES_TOUCH_AS_OF]
1989 );
1990 }
1991
1992 return $res;
1993 }
1994 }
1995
1996 return $this->unwrap( false, $now );
1997 }
1998
2007 private function setInterimValue(
2008 $key,
2009 $value,
2010 $ttl,
2011 ?int $version,
2012 bool $segmentable
2013 ) {
2014 $now = $this->getCurrentTime();
2015 $ttl = max( self::INTERIM_KEY_TTL, (int)$ttl );
2016
2017 // Wrap that value with time/TTL/version metadata
2018 $wrapped = $this->wrap( $value, $ttl, $version, $now );
2019
2020 $flags = $this->cache::WRITE_BACKGROUND;
2021 if ( $segmentable ) {
2022 $flags |= $this->cache::WRITE_ALLOW_SEGMENTS;
2023 }
2024
2025 return $this->cache->set(
2026 $this->makeSisterKey( $key, self::TYPE_INTERIM ),
2027 $wrapped,
2028 $ttl,
2029 $flags
2030 );
2031 }
2032
2037 private function resolveBusyValue( $busyValue ) {
2038 return ( $busyValue instanceof Closure ) ? $busyValue() : $busyValue;
2039 }
2040
2106 final public function getMultiWithSetCallback(
2107 ArrayIterator $keyedIds, $ttl, callable $callback, array $opts = []
2108 ) {
2109 // Batch load required keys into the in-process warmup cache
2110 $this->warmupCache = $this->fetchWrappedValuesForWarmupCache(
2111 $this->getNonProcessCachedMultiKeys( $keyedIds, $opts ),
2112 $opts['checkKeys'] ?? []
2113 );
2114 $this->warmupKeyMisses = 0;
2115
2116 // The required callback signature includes $id as the first argument for convenience
2117 // to distinguish different items. To reuse the code in getWithSetCallback(), wrap the
2118 // callback with a proxy callback that has the standard getWithSetCallback() signature.
2119 // This is defined only once per batch to avoid closure creation overhead.
2120 $proxyCb = static function ( $oldValue, &$ttl, &$setOpts, $oldAsOf, $params )
2121 use ( $callback )
2122 {
2123 return $callback( $params['id'], $oldValue, $ttl, $setOpts, $oldAsOf );
2124 };
2125
2126 // Get the order-preserved result map using the warm-up cache
2127 $values = [];
2128 foreach ( $keyedIds as $key => $id ) {
2129 $values[$key] = $this->getWithSetCallback(
2130 $key,
2131 $ttl,
2132 $proxyCb,
2133 $opts,
2134 [ 'id' => $id ]
2135 );
2136 }
2137
2138 $this->warmupCache = [];
2139
2140 return $values;
2141 }
2142
2209 final public function getMultiWithUnionSetCallback(
2210 ArrayIterator $keyedIds, $ttl, callable $callback, array $opts = []
2211 ) {
2212 $checkKeys = $opts['checkKeys'] ?? [];
2213 $minAsOf = $opts['minAsOf'] ?? self::MIN_TIMESTAMP_NONE;
2214
2215 // unset incompatible keys
2216 unset( $opts['lockTSE'] );
2217 unset( $opts['busyValue'] );
2218
2219 // Batch load required keys into the in-process warmup cache
2220 $keysByIdGet = $this->getNonProcessCachedMultiKeys( $keyedIds, $opts );
2221 $this->warmupCache = $this->fetchWrappedValuesForWarmupCache( $keysByIdGet, $checkKeys );
2222 $this->warmupKeyMisses = 0;
2223
2224 // IDs of entities known to be in need of generation
2225 $idsRegen = [];
2226
2227 // Find out which keys are missing/deleted/stale
2228 $now = $this->getCurrentTime();
2229 $resByKey = $this->fetchKeys( $keysByIdGet, $checkKeys, $now );
2230 foreach ( $keysByIdGet as $id => $key ) {
2231 $res = $resByKey[$key];
2232 if (
2233 $res[self::RES_VALUE] === false ||
2234 $res[self::RES_CUR_TTL] < 0 ||
2235 $res[self::RES_AS_OF] < $minAsOf
2236 ) {
2237 $idsRegen[] = $id;
2238 }
2239 }
2240
2241 // Run the callback to populate the generation value map for all required IDs
2242 $newSetOpts = [];
2243 $newTTLsById = array_fill_keys( $idsRegen, $ttl );
2244 $newValsById = $idsRegen ? $callback( $idsRegen, $newTTLsById, $newSetOpts ) : [];
2245
2246 $method = __METHOD__;
2247 // The required callback signature includes $id as the first argument for convenience
2248 // to distinguish different items. To reuse the code in getWithSetCallback(), wrap the
2249 // callback with a proxy callback that has the standard getWithSetCallback() signature.
2250 // This is defined only once per batch to avoid closure creation overhead.
2251 $proxyCb = function ( $oldValue, &$ttl, &$setOpts, $oldAsOf, $params )
2252 use ( $callback, $newValsById, $newTTLsById, $newSetOpts, $method )
2253 {
2254 $id = $params['id'];
2255
2256 if ( array_key_exists( $id, $newValsById ) ) {
2257 // Value was already regenerated as expected, so use the value in $newValsById
2258 $newValue = $newValsById[$id];
2259 $ttl = $newTTLsById[$id];
2260 $setOpts = $newSetOpts;
2261 } else {
2262 // Pre-emptive/popularity refresh and version mismatch cases are not detected
2263 // above and thus $newValsById has no entry. Run $callback on this single entity.
2264 $ttls = [ $id => $ttl ];
2265 $result = $callback( [ $id ], $ttls, $setOpts );
2266 if ( !isset( $result[$id] ) ) {
2267 // T303092
2268 $this->logger->warning(
2269 $method . ' failed due to {id} not set in result {result}', [
2270 'id' => $id,
2271 'result' => json_encode( $result )
2272 ] );
2273 }
2274 $newValue = $result[$id];
2275 $ttl = $ttls[$id];
2276 }
2277
2278 return $newValue;
2279 };
2280
2281 // Get the order-preserved result map using the warm-up cache
2282 $values = [];
2283 foreach ( $keyedIds as $key => $id ) {
2284 $values[$key] = $this->getWithSetCallback(
2285 $key,
2286 $ttl,
2287 $proxyCb,
2288 $opts,
2289 [ 'id' => $id ]
2290 );
2291 }
2292
2293 $this->warmupCache = [];
2294
2295 return $values;
2296 }
2297
2305 public function makeGlobalKey( $keygroup, ...$components ) {
2306 return $this->cache->makeGlobalKey( $keygroup, ...$components );
2307 }
2308
2316 public function makeKey( $keygroup, ...$components ) {
2317 return $this->cache->makeKey( $keygroup, ...$components );
2318 }
2319
2327 public function hash256( $component ) {
2328 return hash_hmac( 'sha256', $component, $this->secret );
2329 }
2330
2382 final public function makeMultiKeys( array $ids, $keyCallback ) {
2383 $idByKey = [];
2384 foreach ( $ids as $id ) {
2385 // Discourage triggering of automatic makeKey() hashing in some backends
2386 if ( strlen( $id ) > 64 ) {
2387 $this->logger->warning( __METHOD__ . ": long ID '$id'; use hash256()" );
2388 }
2389 $key = $keyCallback( $id, $this );
2390 // Edge case: ignore key collisions due to duplicate $ids like "42" and 42
2391 if ( !isset( $idByKey[$key] ) ) {
2392 $idByKey[$key] = $id;
2393 } elseif ( (string)$id !== (string)$idByKey[$key] ) {
2394 throw new UnexpectedValueException(
2395 "Cache key collision; IDs ('$id','{$idByKey[$key]}') map to '$key'"
2396 );
2397 }
2398 }
2399
2400 return new ArrayIterator( $idByKey );
2401 }
2402
2438 final public function multiRemap( array $ids, array $res ) {
2439 if ( count( $ids ) !== count( $res ) ) {
2440 // If makeMultiKeys() is called on a list of non-unique IDs, then the resulting
2441 // ArrayIterator will have less entries due to "first appearance" de-duplication
2442 $ids = array_keys( array_fill_keys( $ids, true ) );
2443 if ( count( $ids ) !== count( $res ) ) {
2444 throw new UnexpectedValueException( "Multi-key result does not match ID list" );
2445 }
2446 }
2447
2448 return array_combine( $ids, $res );
2449 }
2450
2457 public function watchErrors() {
2458 return $this->cache->watchErrors();
2459 }
2460
2478 final public function getLastError( $watchPoint = 0 ) {
2479 $code = $this->cache->getLastError( $watchPoint );
2480 switch ( $code ) {
2481 case self::ERR_NONE:
2482 return self::ERR_NONE;
2484 return self::ERR_NO_RESPONSE;
2486 return self::ERR_UNREACHABLE;
2487 default:
2488 return self::ERR_UNEXPECTED;
2489 }
2490 }
2491
2496 final public function clearLastError() {
2497 wfDeprecated( __METHOD__, '1.38' );
2498 $this->cache->clearLastError();
2499 }
2500
2506 public function clearProcessCache() {
2507 $this->processCaches = [];
2508 }
2509
2530 final public function useInterimHoldOffCaching( $enabled ) {
2531 $this->useInterimHoldOffCaching = $enabled;
2532 }
2533
2539 public function getQoS( $flag ) {
2540 return $this->cache->getQoS( $flag );
2541 }
2542
2606 public function adaptiveTTL( $mtime, $maxTTL, $minTTL = 30, $factor = 0.2 ) {
2607 // handle fractional seconds and string integers
2608 $mtime = (int)$mtime;
2609 if ( $mtime <= 0 ) {
2610 // no last-modified time provided
2611 return $minTTL;
2612 }
2613
2614 $age = (int)$this->getCurrentTime() - $mtime;
2615
2616 return (int)min( $maxTTL, max( $minTTL, $factor * $age ) );
2617 }
2618
2624 final public function getWarmupKeyMisses() {
2625 // Number of misses in $this->warmupCache during the last call to certain methods
2626 return $this->warmupKeyMisses;
2627 }
2628
2643 protected function relayVolatilePurge( string $sisterKey, string $purgeValue, int $ttl ) {
2644 if ( $this->broadcastRoute !== null ) {
2645 $routeKey = $this->prependRoute( $sisterKey, $this->broadcastRoute );
2646 } else {
2647 $routeKey = $sisterKey;
2648 }
2649
2650 return $this->cache->set(
2651 $routeKey,
2652 $purgeValue,
2653 $ttl,
2654 $this->cache::WRITE_BACKGROUND
2655 );
2656 }
2657
2666 protected function relayNonVolatilePurge( string $sisterKey ) {
2667 if ( $this->broadcastRoute !== null ) {
2668 $routeKey = $this->prependRoute( $sisterKey, $this->broadcastRoute );
2669 } else {
2670 $routeKey = $sisterKey;
2671 }
2672
2673 return $this->cache->delete( $routeKey, $this->cache::WRITE_BACKGROUND );
2674 }
2675
2681 protected function prependRoute( string $sisterKey, string $route ) {
2682 if ( $sisterKey[0] === '/' ) {
2683 throw new RuntimeException( "Sister key '$sisterKey' already contains a route." );
2684 }
2685
2686 return $route . $sisterKey;
2687 }
2688
2700 private function scheduleAsyncRefresh( $key, $ttl, $callback, array $opts, array $cbParams ) {
2701 if ( !$this->asyncHandler ) {
2702 return false;
2703 }
2704 // Update the cache value later, such during post-send of an HTTP request. This forces
2705 // cache regeneration by setting "minAsOf" to infinity, meaning that no existing value
2706 // is considered valid. Furthermore, note that preemptive regeneration is not applicable
2707 // to invalid values, so there is no risk of infinite preemptive regeneration loops.
2708 $func = $this->asyncHandler;
2709 $func( function () use ( $key, $ttl, $callback, $opts, $cbParams ) {
2710 $opts['minAsOf'] = INF;
2711 try {
2712 $this->fetchOrRegenerate( $key, $ttl, $callback, $opts, $cbParams );
2713 } catch ( Exception $e ) {
2714 // Log some context for easier debugging
2715 $this->logger->error( 'Async refresh failed for {key}', [
2716 'key' => $key,
2717 'ttl' => $ttl,
2718 'exception' => $e
2719 ] );
2720 throw $e;
2721 }
2722 } );
2723
2724 return true;
2725 }
2726
2735 private function isAcceptablyFreshValue( $res, $graceTTL, $minAsOf ) {
2736 if ( !$this->isValid( $res[self::RES_VALUE], $res[self::RES_AS_OF], $minAsOf ) ) {
2737 // Value does not exists or is too old
2738 return false;
2739 }
2740
2741 $curTTL = $res[self::RES_CUR_TTL];
2742 if ( $curTTL > 0 ) {
2743 // Value is definitely still fresh
2744 return true;
2745 }
2746
2747 // Remaining seconds during which this stale value can be used
2748 $curGraceTTL = $graceTTL + $curTTL;
2749
2750 return ( $curGraceTTL > 0 )
2751 // Chance of using the value decreases as $curTTL goes from 0 to -$graceTTL
2752 ? !$this->worthRefreshExpiring( $curGraceTTL, $graceTTL, $graceTTL )
2753 // Value is too stale to fall in the grace period
2754 : false;
2755 }
2756
2767 protected function isLotteryRefreshDue( $res, $lowTTL, $ageNew, $hotTTR, $now ) {
2768 $curTTL = $res[self::RES_CUR_TTL];
2769 $logicalTTL = $res[self::RES_TTL];
2770 $asOf = $res[self::RES_AS_OF];
2771
2772 return (
2773 $this->worthRefreshExpiring( $curTTL, $logicalTTL, $lowTTL ) ||
2774 $this->worthRefreshPopular( $asOf, $ageNew, $hotTTR, $now )
2775 );
2776 }
2777
2793 protected function worthRefreshPopular( $asOf, $ageNew, $timeTillRefresh, $now ) {
2794 if ( $ageNew < 0 || $timeTillRefresh <= 0 ) {
2795 return false;
2796 }
2797
2798 $age = $now - $asOf;
2799 $timeOld = $age - $ageNew;
2800 if ( $timeOld <= 0 ) {
2801 return false;
2802 }
2803
2804 $popularHitsPerSec = 1;
2805 // Lifecycle is: new, ramp-up refresh chance, full refresh chance.
2806 // Note that the "expected # of refreshes" for the ramp-up time range is half
2807 // of what it would be if P(refresh) was at its full value during that time range.
2808 $refreshWindowSec = max( $timeTillRefresh - $ageNew - self::RAMPUP_TTL / 2, 1 );
2809 // P(refresh) * (# hits in $refreshWindowSec) = (expected # of refreshes)
2810 // P(refresh) * ($refreshWindowSec * $popularHitsPerSec) = 1 (by definition)
2811 // P(refresh) = 1/($refreshWindowSec * $popularHitsPerSec)
2812 $chance = 1 / ( $popularHitsPerSec * $refreshWindowSec );
2813 // Ramp up $chance from 0 to its nominal value over RAMPUP_TTL seconds to avoid stampedes
2814 $chance *= ( $timeOld <= self::RAMPUP_TTL ) ? $timeOld / self::RAMPUP_TTL : 1;
2815
2816 return ( mt_rand( 1, 1_000_000_000 ) <= 1_000_000_000 * $chance );
2817 }
2818
2837 protected function worthRefreshExpiring( $curTTL, $logicalTTL, $lowTTL ) {
2838 if ( $lowTTL <= 0 ) {
2839 return false;
2840 }
2841 // T264787: avoid having keys start off with a high chance of being refreshed;
2842 // the point where refreshing becomes possible cannot precede the key lifetime.
2843 $effectiveLowTTL = min( $lowTTL, $logicalTTL ?: INF );
2844
2845 // How long the value was in the "low TTL" phase
2846 $timeOld = $effectiveLowTTL - $curTTL;
2847 if ( $timeOld <= 0 || $timeOld >= $effectiveLowTTL ) {
2848 return false;
2849 }
2850
2851 // Ratio of the low TTL phase that has elapsed (r)
2852 $ttrRatio = $timeOld / $effectiveLowTTL;
2853 // Use p(r) as the monotonically increasing "chance of refresh" function,
2854 // having p(0)=0 and p(1)=1. The value expires at the nominal expiry.
2855 $chance = $ttrRatio ** 4;
2856
2857 return ( mt_rand( 1, 1_000_000_000 ) <= 1_000_000_000 * $chance );
2858 }
2859
2868 protected function isValid( $value, $asOf, $minAsOf ) {
2869 return ( $value !== false && $asOf >= $minAsOf );
2870 }
2871
2879 private function wrap( $value, $ttl, $version, $now ) {
2880 // Returns keys in ascending integer order for PHP7 array packing:
2881 // https://nikic.github.io/2014/12/22/PHPs-new-hashtable-implementation.html
2882 $wrapped = [
2883 self::FLD_FORMAT_VERSION => self::VERSION,
2884 self::FLD_VALUE => $value,
2885 self::FLD_TTL => $ttl,
2886 self::FLD_TIME => $now
2887 ];
2888 if ( $version !== null ) {
2889 $wrapped[self::FLD_VALUE_VERSION] = $version;
2890 }
2891
2892 return $wrapped;
2893 }
2894
2909 private function unwrap( $wrapped, $now ) {
2910 // https://nikic.github.io/2014/12/22/PHPs-new-hashtable-implementation.html
2911 $res = [
2912 // Attributes that only depend on the fetched key value
2913 self::RES_VALUE => false,
2914 self::RES_VERSION => null,
2915 self::RES_AS_OF => null,
2916 self::RES_TTL => null,
2917 self::RES_TOMB_AS_OF => null,
2918 // Attributes that depend on caller-specific "check" keys or "touched callbacks"
2919 self::RES_CHECK_AS_OF => null,
2920 self::RES_TOUCH_AS_OF => null,
2921 self::RES_CUR_TTL => null
2922 ];
2923
2924 if ( is_array( $wrapped ) ) {
2925 // Entry expected to be a cached value; validate it
2926 if (
2927 ( $wrapped[self::FLD_FORMAT_VERSION] ?? null ) === self::VERSION &&
2928 $wrapped[self::FLD_TIME] >= $this->epoch
2929 ) {
2930 if ( $wrapped[self::FLD_TTL] > 0 ) {
2931 // Get the approximate time left on the key
2932 $age = $now - $wrapped[self::FLD_TIME];
2933 $curTTL = max( $wrapped[self::FLD_TTL] - $age, 0.0 );
2934 } else {
2935 // Key had no TTL, so the time left is unbounded
2936 $curTTL = INF;
2937 }
2938 $res[self::RES_VALUE] = $wrapped[self::FLD_VALUE];
2939 $res[self::RES_VERSION] = $wrapped[self::FLD_VALUE_VERSION] ?? null;
2940 $res[self::RES_AS_OF] = $wrapped[self::FLD_TIME];
2941 $res[self::RES_CUR_TTL] = $curTTL;
2942 $res[self::RES_TTL] = $wrapped[self::FLD_TTL];
2943 }
2944 } else {
2945 // Entry expected to be a tombstone; parse it
2946 $purge = $this->parsePurgeValue( $wrapped );
2947 if ( $purge !== null ) {
2948 // Tombstoned keys should always have a negative "current TTL"
2949 $curTTL = min( $purge[self::PURGE_TIME] - $now, self::TINY_NEGATIVE );
2950 $res[self::RES_CUR_TTL] = $curTTL;
2951 $res[self::RES_TOMB_AS_OF] = $purge[self::PURGE_TIME];
2952 }
2953 }
2954
2955 return $res;
2956 }
2957
2963 private function determineKeyGroupForStats( $key ) {
2964 $parts = explode( ':', $key, 3 );
2965 // Fallback in case the key was not made by makeKey.
2966 // Replace dots because they are special in StatsD (T232907)
2967 return strtr( $parts[1] ?? $parts[0], '.', '_' );
2968 }
2969
2978 private function parsePurgeValue( $value ) {
2979 if ( !is_string( $value ) ) {
2980 return null;
2981 }
2982
2983 $segments = explode( ':', $value, 3 );
2984 $prefix = $segments[0];
2985 if ( $prefix !== self::PURGE_VAL_PREFIX ) {
2986 // Not a purge value
2987 return null;
2988 }
2989
2990 $timestamp = (float)$segments[1];
2991 // makeTombstonePurgeValue() doesn't store hold-off TTLs
2992 $holdoff = isset( $segments[2] ) ? (int)$segments[2] : self::HOLDOFF_TTL;
2993
2994 if ( $timestamp < $this->epoch ) {
2995 // Purge value is too old
2996 return null;
2997 }
2998
2999 return [ self::PURGE_TIME => $timestamp, self::PURGE_HOLDOFF => $holdoff ];
3000 }
3001
3006 private function makeTombstonePurgeValue( float $timestamp ) {
3007 return self::PURGE_VAL_PREFIX . ':' . (int)$timestamp;
3008 }
3009
3016 private function makeCheckPurgeValue( float $timestamp, int $holdoff, array &$purge = null ) {
3017 $normalizedTime = (int)$timestamp;
3018 // Purge array that matches what parsePurgeValue() would have returned
3019 $purge = [ self::PURGE_TIME => (float)$normalizedTime, self::PURGE_HOLDOFF => $holdoff ];
3020
3021 return self::PURGE_VAL_PREFIX . ":$normalizedTime:$holdoff";
3022 }
3023
3028 private function getProcessCache( $group ) {
3029 if ( !isset( $this->processCaches[$group] ) ) {
3030 [ , $size ] = explode( ':', $group );
3031 $this->processCaches[$group] = new MapCacheLRU( (int)$size );
3032 if ( $this->wallClockOverride !== null ) {
3033 $this->processCaches[$group]->setMockTime( $this->wallClockOverride );
3034 }
3035 }
3036
3037 return $this->processCaches[$group];
3038 }
3039
3045 private function getNonProcessCachedMultiKeys( ArrayIterator $keys, array $opts ) {
3046 $pcTTL = $opts['pcTTL'] ?? self::TTL_UNCACHEABLE;
3047
3048 $keysMissing = [];
3049 if ( $pcTTL > 0 && $this->callbackDepth == 0 ) {
3050 $pCache = $this->getProcessCache( $opts['pcGroup'] ?? self::PC_PRIMARY );
3051 foreach ( $keys as $key => $id ) {
3052 if ( !$pCache->has( $key, $pcTTL ) ) {
3053 $keysMissing[$id] = $key;
3054 }
3055 }
3056 }
3057
3058 return $keysMissing;
3059 }
3060
3067 private function fetchWrappedValuesForWarmupCache( array $keys, array $checkKeys ) {
3068 if ( !$keys ) {
3069 return [];
3070 }
3071
3072 // Get all the value keys to fetch...
3073 $sisterKeys = $this->makeSisterKeys( $keys, self::TYPE_VALUE );
3074 // Get all the "check" keys to fetch...
3075 foreach ( $checkKeys as $i => $checkKeyOrKeyGroup ) {
3076 // Note: avoid array_merge() inside loop in case there are many keys
3077 if ( is_int( $i ) ) {
3078 // Single "check" key that applies to all value keys
3079 $sisterKeys[] = $this->makeSisterKey( $checkKeyOrKeyGroup, self::TYPE_TIMESTAMP );
3080 } else {
3081 // List of "check" keys that apply to a specific value key
3082 foreach ( (array)$checkKeyOrKeyGroup as $checkKey ) {
3083 $sisterKeys[] = $this->makeSisterKey( $checkKey, self::TYPE_TIMESTAMP );
3084 }
3085 }
3086 }
3087
3088 $wrappedBySisterKey = $this->cache->getMulti( $sisterKeys );
3089 $wrappedBySisterKey += array_fill_keys( $sisterKeys, false );
3090
3091 return $wrappedBySisterKey;
3092 }
3093
3099 private function timeSinceLoggedMiss( $key, $now ) {
3100 // phpcs:ignore Generic.CodeAnalysis.AssignmentInCondition.Found
3101 for ( end( $this->missLog ); $miss = current( $this->missLog ); prev( $this->missLog ) ) {
3102 if ( $miss[0] === $key ) {
3103 return ( $now - $miss[1] );
3104 }
3105 }
3106
3107 return null;
3108 }
3109
3114 protected function getCurrentTime() {
3115 return $this->wallClockOverride ?: microtime( true );
3116 }
3117
3122 public function setMockTime( &$time ) {
3123 $this->wallClockOverride =& $time;
3124 $this->cache->setMockTime( $time );
3125 foreach ( $this->processCaches as $pCache ) {
3126 $pCache->setMockTime( $time );
3127 }
3128 }
3129}
3130
3132class_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:89
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".
StatsFactory Implementation.
Generic interface providing Time-To-Live constants for expirable object storage.
Generic interface providing error code and quality-of-service constants for object stores.
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.
Key-encoding methods for object caching (BagOStuff and WANObjectCache)
MediaWiki adaptation of StatsdDataFactory that provides buffering functionality.