MediaWiki master
WANObjectCache.php
Go to the documentation of this file.
1<?php
21namespace Wikimedia\ObjectCache;
22
23use ArrayIterator;
24use Closure;
25use Exception;
26use Psr\Log\LoggerAwareInterface;
27use Psr\Log\LoggerInterface;
28use Psr\Log\NullLogger;
29use RuntimeException;
30use UnexpectedValueException;
37
163class WANObjectCache implements
166 LoggerAwareInterface
167{
169 protected $cache;
171 protected $processCaches = [];
173 protected $logger;
175 protected $stats;
177 protected $asyncHandler;
178
190 protected $epoch;
192 protected $secret;
195
197 private $tracer;
198
200 private $missLog;
201
203 private $callbackDepth = 0;
205 private $warmupCache = [];
207 private $warmupKeyMisses = 0;
208
210 private $wallClockOverride;
211
213 private const MAX_COMMIT_DELAY = 3;
215 private const MAX_READ_LAG = 7;
217 public const HOLDOFF_TTL = self::MAX_COMMIT_DELAY + self::MAX_READ_LAG + 1;
218
220 private const LOW_TTL = 60;
222 public const TTL_LAGGED = 30;
223
225 private const HOT_TTR = 900;
227 private const AGE_NEW = 60;
228
230 private const TSE_NONE = -1;
231
233 public const STALE_TTL_NONE = 0;
235 public const GRACE_TTL_NONE = 0;
237 public const HOLDOFF_TTL_NONE = 0;
238
240 public const MIN_TIMESTAMP_NONE = 0.0;
241
243 private const PC_PRIMARY = 'primary:1000';
244
246 public const PASS_BY_REF = [];
247
249 private const SCHEME_HASH_TAG = 1;
251 private const SCHEME_HASH_STOP = 2;
252
254 private const CHECK_KEY_TTL = self::TTL_YEAR;
256 private const INTERIM_KEY_TTL = 2;
257
259 private const LOCK_TTL = 10;
261 private const RAMPUP_TTL = 30;
262
264 private const TINY_NEGATIVE = -0.000001;
266 private const TINY_POSITIVE = 0.000001;
267
269 private const RECENT_SET_LOW_MS = 50;
271 private const RECENT_SET_HIGH_MS = 100;
272
274 private const GENERATION_HIGH_SEC = 0.2;
275
277 private const PURGE_TIME = 0;
279 private const PURGE_HOLDOFF = 1;
280
282 private const VERSION = 1;
283
285 public const KEY_VERSION = 'version';
287 public const KEY_AS_OF = 'asOf';
289 public const KEY_TTL = 'ttl';
291 public const KEY_CUR_TTL = 'curTTL';
293 public const KEY_TOMB_AS_OF = 'tombAsOf';
295 public const KEY_CHECK_AS_OF = 'lastCKPurge';
296
298 private const RES_VALUE = 0;
300 private const RES_VERSION = 1;
302 private const RES_AS_OF = 2;
304 private const RES_TTL = 3;
306 private const RES_TOMB_AS_OF = 4;
308 private const RES_CHECK_AS_OF = 5;
310 private const RES_TOUCH_AS_OF = 6;
312 private const RES_CUR_TTL = 7;
313
315 private const FLD_FORMAT_VERSION = 0;
317 private const FLD_VALUE = 1;
319 private const FLD_TTL = 2;
321 private const FLD_TIME = 3;
323 private const FLD_FLAGS = 4;
325 private const FLD_VALUE_VERSION = 5;
326 private const FLD_GENERATION_TIME = 6;
327
329 private const TYPE_VALUE = 'v';
331 private const TYPE_TIMESTAMP = 't';
333 private const TYPE_MUTEX = 'm';
335 private const TYPE_INTERIM = 'i';
336
338 private const PURGE_VAL_PREFIX = 'PURGED';
339
368 public function __construct( array $params ) {
369 $this->cache = $params['cache'];
370 $this->broadcastRoute = $params['broadcastRoutingPrefix'] ?? null;
371 $this->epoch = $params['epoch'] ?? 0;
372 $this->secret = $params['secret'] ?? (string)$this->epoch;
373 if ( ( $params['coalesceScheme'] ?? '' ) === 'hash_tag' ) {
374 // https://redis.io/topics/cluster-spec
375 // https://github.com/twitter/twemproxy/blob/v0.4.1/notes/recommendation.md#hash-tags
376 // https://github.com/Netflix/dynomite/blob/v0.7.0/notes/recommendation.md#hash-tags
377 $this->coalesceScheme = self::SCHEME_HASH_TAG;
378 } else {
379 // https://github.com/facebook/mcrouter/wiki/Key-syntax
380 $this->coalesceScheme = self::SCHEME_HASH_STOP;
381 }
382
383 $this->setLogger( $params['logger'] ?? new NullLogger() );
384 $this->tracer = $params['tracer'] ?? new NoopTracer();
385 $this->stats = $params['stats'] ?? StatsFactory::newNull();
386
387 $this->asyncHandler = $params['asyncHandler'] ?? null;
388 $this->missLog = array_fill( 0, 10, [ '', 0.0 ] );
389 }
390
391 public function setLogger( LoggerInterface $logger ) {
392 $this->logger = $logger;
393 }
394
400 public static function newEmpty() {
401 return new static( [ 'cache' => new EmptyBagOStuff() ] );
402 }
403
459 final public function get( $key, &$curTTL = null, array $checkKeys = [], &$info = [] ) {
460 // Note that an undeclared variable passed as $info starts as null (not the default).
461 // Also, if no $info parameter is provided, then it doesn't matter how it changes here.
462 $legacyInfo = ( $info !== self::PASS_BY_REF );
463
465 $span = $this->startOperationSpan( __FUNCTION__, $key, $checkKeys );
466
467 $now = $this->getCurrentTime();
468 $res = $this->fetchKeys( [ $key ], $checkKeys, $now )[$key];
469
470 $curTTL = $res[self::RES_CUR_TTL];
471 $info = $legacyInfo
472 ? $res[self::RES_AS_OF]
473 : [
474 self::KEY_VERSION => $res[self::RES_VERSION],
475 self::KEY_AS_OF => $res[self::RES_AS_OF],
476 self::KEY_TTL => $res[self::RES_TTL],
477 self::KEY_CUR_TTL => $res[self::RES_CUR_TTL],
478 self::KEY_TOMB_AS_OF => $res[self::RES_TOMB_AS_OF],
479 self::KEY_CHECK_AS_OF => $res[self::RES_CHECK_AS_OF]
480 ];
481
482 if ( $curTTL === null || $curTTL <= 0 ) {
483 // Log the timestamp in case a corresponding set() call does not provide "walltime"
484 unset( $this->missLog[array_key_first( $this->missLog )] );
485 $this->missLog[] = [ $key, $this->getCurrentTime() ];
486 }
487
488 return $res[self::RES_VALUE];
489 }
490
515 final public function getMulti(
516 array $keys,
517 &$curTTLs = [],
518 array $checkKeys = [],
519 &$info = []
520 ) {
521 // Note that an undeclared variable passed as $info starts as null (not the default).
522 // Also, if no $info parameter is provided, then it doesn't matter how it changes here.
523 $legacyInfo = ( $info !== self::PASS_BY_REF );
524
526 $span = $this->startOperationSpan( __FUNCTION__, $keys, $checkKeys );
527
528 $curTTLs = [];
529 $info = [];
530 $valuesByKey = [];
531
532 $now = $this->getCurrentTime();
533 $resByKey = $this->fetchKeys( $keys, $checkKeys, $now );
534 foreach ( $resByKey as $key => $res ) {
535 if ( $res[self::RES_VALUE] !== false ) {
536 $valuesByKey[$key] = $res[self::RES_VALUE];
537 }
538
539 if ( $res[self::RES_CUR_TTL] !== null ) {
540 $curTTLs[$key] = $res[self::RES_CUR_TTL];
541 }
542 $info[$key] = $legacyInfo
543 ? $res[self::RES_AS_OF]
544 : [
545 self::KEY_VERSION => $res[self::RES_VERSION],
546 self::KEY_AS_OF => $res[self::RES_AS_OF],
547 self::KEY_TTL => $res[self::RES_TTL],
548 self::KEY_CUR_TTL => $res[self::RES_CUR_TTL],
549 self::KEY_TOMB_AS_OF => $res[self::RES_TOMB_AS_OF],
550 self::KEY_CHECK_AS_OF => $res[self::RES_CHECK_AS_OF]
551 ];
552 }
553
554 return $valuesByKey;
555 }
556
572 protected function fetchKeys( array $keys, array $checkKeys, float $now, $touchedCb = null ) {
573 $resByKey = [];
574
575 // List of all sister keys that need to be fetched from cache
576 $allSisterKeys = [];
577 // Order-corresponding value sister key list for the base key list ($keys)
578 $valueSisterKeys = [];
579 // List of "check" sister keys to compare all value sister keys against
580 $checkSisterKeysForAll = [];
581 // Map of (base key => additional "check" sister key(s) to compare against)
582 $checkSisterKeysByKey = [];
583
584 foreach ( $keys as $key ) {
585 $sisterKey = $this->makeSisterKey( $key, self::TYPE_VALUE );
586 $allSisterKeys[] = $sisterKey;
587 $valueSisterKeys[] = $sisterKey;
588 }
589
590 foreach ( $checkKeys as $i => $checkKeyOrKeyGroup ) {
591 // Note: avoid array_merge() inside loop in case there are many keys
592 if ( is_int( $i ) ) {
593 // Single "check" key that applies to all base keys
594 $sisterKey = $this->makeSisterKey( $checkKeyOrKeyGroup, self::TYPE_TIMESTAMP );
595 $allSisterKeys[] = $sisterKey;
596 $checkSisterKeysForAll[] = $sisterKey;
597 } else {
598 // List of "check" keys that apply to a specific base key
599 foreach ( (array)$checkKeyOrKeyGroup as $checkKey ) {
600 $sisterKey = $this->makeSisterKey( $checkKey, self::TYPE_TIMESTAMP );
601 $allSisterKeys[] = $sisterKey;
602 $checkSisterKeysByKey[$i][] = $sisterKey;
603 }
604 }
605 }
606
607 if ( $this->warmupCache ) {
608 // Get the wrapped values of the sister keys from the warmup cache
609 $wrappedBySisterKey = $this->warmupCache;
610 $sisterKeysMissing = array_diff( $allSisterKeys, array_keys( $wrappedBySisterKey ) );
611 if ( $sisterKeysMissing ) {
612 $this->warmupKeyMisses += count( $sisterKeysMissing );
613 $wrappedBySisterKey += $this->cache->getMulti( $sisterKeysMissing );
614 }
615 } else {
616 // Fetch the wrapped values of the sister keys from the backend
617 $wrappedBySisterKey = $this->cache->getMulti( $allSisterKeys );
618 }
619
620 // List of "check" sister key purge timestamps to compare all value sister keys against
621 $ckPurgesForAll = $this->processCheckKeys(
622 $checkSisterKeysForAll,
623 $wrappedBySisterKey,
624 $now
625 );
626 // Map of (base key => extra "check" sister key purge timestamp(s) to compare against)
627 $ckPurgesByKey = [];
628 foreach ( $checkSisterKeysByKey as $keyWithCheckKeys => $checkKeysForKey ) {
629 $ckPurgesByKey[$keyWithCheckKeys] = $this->processCheckKeys(
630 $checkKeysForKey,
631 $wrappedBySisterKey,
632 $now
633 );
634 }
635
636 // Unwrap and validate any value found for each base key (under the value sister key)
637 foreach (
638 array_map( null, $valueSisterKeys, $keys )
639 as [ $valueSisterKey, $key ]
640 ) {
641 if ( array_key_exists( $valueSisterKey, $wrappedBySisterKey ) ) {
642 // Key exists as either a live value or tombstone value
643 $wrapped = $wrappedBySisterKey[$valueSisterKey];
644 } else {
645 // Key does not exist
646 $wrapped = false;
647 }
648
649 $res = $this->unwrap( $wrapped, $now );
650 $value = $res[self::RES_VALUE];
651
652 foreach ( array_merge( $ckPurgesForAll, $ckPurgesByKey[$key] ?? [] ) as $ckPurge ) {
653 $res[self::RES_CHECK_AS_OF] = max(
654 $ckPurge[self::PURGE_TIME],
655 $res[self::RES_CHECK_AS_OF]
656 );
657 // Timestamp marking the end of the hold-off period for this purge
658 $holdoffDeadline = $ckPurge[self::PURGE_TIME] + $ckPurge[self::PURGE_HOLDOFF];
659 // Check if the value was generated during the hold-off period
660 if ( $value !== false && $holdoffDeadline >= $res[self::RES_AS_OF] ) {
661 // How long ago this value was purged by *this* "check" key
662 $ago = min( $ckPurge[self::PURGE_TIME] - $now, self::TINY_NEGATIVE );
663 // How long ago this value was purged by *any* known "check" key
664 $res[self::RES_CUR_TTL] = min( $res[self::RES_CUR_TTL], $ago );
665 }
666 }
667
668 if ( $touchedCb !== null && $value !== false ) {
669 $touched = $touchedCb( $value );
670 if ( $touched !== null && $touched >= $res[self::RES_AS_OF] ) {
671 $res[self::RES_CUR_TTL] = min(
672 $res[self::RES_CUR_TTL],
673 $res[self::RES_AS_OF] - $touched,
674 self::TINY_NEGATIVE
675 );
676 }
677 } else {
678 $touched = null;
679 }
680
681 $res[self::RES_TOUCH_AS_OF] = max( $res[self::RES_TOUCH_AS_OF], $touched );
682
683 $resByKey[$key] = $res;
684 }
685
686 return $resByKey;
687 }
688
695 private function processCheckKeys(
696 array $checkSisterKeys,
697 array $wrappedBySisterKey,
698 float $now
699 ) {
700 $purges = [];
701
702 foreach ( $checkSisterKeys as $timeKey ) {
703 $purge = isset( $wrappedBySisterKey[$timeKey] )
704 ? $this->parsePurgeValue( $wrappedBySisterKey[$timeKey] )
705 : null;
706
707 if ( $purge === null ) {
708 // No holdoff when lazy creating a check key, use cache right away (T344191)
709 $wrapped = $this->makeCheckPurgeValue( $now, self::HOLDOFF_TTL_NONE, $purge );
710 $this->cache->add(
711 $timeKey,
712 $wrapped,
713 self::CHECK_KEY_TTL,
714 $this->cache::WRITE_BACKGROUND
715 );
716 }
717
718 $purges[] = $purge;
719 }
720
721 return $purges;
722 }
723
807 final public function set( $key, $value, $ttl = self::TTL_INDEFINITE, array $opts = [] ) {
809 $span = $this->startOperationSpan( __FUNCTION__, $key );
810
811 $keygroup = $this->determineKeyGroupForStats( $key );
812
813 $ok = $this->setMainValue(
814 $key,
815 $value,
816 $ttl,
817 $opts['version'] ?? null,
818 $opts['walltime'] ?? null,
819 $opts['lag'] ?? 0,
820 $opts['since'] ?? null,
821 $opts['pending'] ?? false,
822 $opts['lockTSE'] ?? self::TSE_NONE,
823 $opts['staleTTL'] ?? self::STALE_TTL_NONE,
824 $opts['segmentable'] ?? false,
825 $opts['creating'] ?? false
826 );
827
828 $this->stats->getCounter( 'wanobjectcache_set_total' )
829 ->setLabel( 'keygroup', $keygroup )
830 ->setLabel( 'result', ( $ok ? 'ok' : 'error' ) )
831 ->copyToStatsdAt( "wanobjectcache.$keygroup.set." . ( $ok ? 'ok' : 'error' ) )
832 ->increment();
833
834 return $ok;
835 }
836
852 private function setMainValue(
853 $key,
854 $value,
855 $ttl,
856 ?int $version,
857 ?float $walltime,
858 $dataReplicaLag,
859 $dataReadSince,
860 bool $dataPendingCommit,
861 int $lockTSE,
862 int $staleTTL,
863 bool $segmentable,
864 bool $creating
865 ) {
866 if ( $ttl < 0 ) {
867 // not cacheable
868 return true;
869 }
870
871 $now = $this->getCurrentTime();
872 $ttl = (int)$ttl;
873 $walltime ??= $this->timeSinceLoggedMiss( $key, $now );
874 $dataSnapshotLag = ( $dataReadSince !== null ) ? max( 0, $now - $dataReadSince ) : 0;
875 $dataCombinedLag = $dataReplicaLag + $dataSnapshotLag;
876
877 // Forbid caching data that only exists within an uncommitted transaction. Also, lower
878 // the TTL when the data has a "since" time so far in the past that a delete() tombstone,
879 // made after that time, could have already expired (the key is no longer write-holed).
880 // The mitigation TTL depends on whether this data lag is assumed to systemically effect
881 // regeneration attempts in the near future. The TTL also reflects regeneration wall time.
882 if ( $dataPendingCommit ) {
883 // Case A: data comes from an uncommitted write transaction
884 $mitigated = 'pending writes';
885 // Data might never be committed; rely on a less problematic regeneration attempt
886 $mitigationTTL = self::TTL_UNCACHEABLE;
887 } elseif ( $dataSnapshotLag > self::MAX_READ_LAG ) {
888 // Case B: high snapshot lag
889 $pregenSnapshotLag = ( $walltime !== null ) ? ( $dataSnapshotLag - $walltime ) : 0;
890 if ( ( $pregenSnapshotLag + self::GENERATION_HIGH_SEC ) > self::MAX_READ_LAG ) {
891 // Case B1: generation started when transaction duration was already long
892 $mitigated = 'snapshot lag (late generation)';
893 // Probably non-systemic; rely on a less problematic regeneration attempt
894 $mitigationTTL = self::TTL_UNCACHEABLE;
895 } else {
896 // Case B2: slow generation made transaction duration long
897 $mitigated = 'snapshot lag (high generation time)';
898 // Probably systemic; use a low TTL to avoid stampedes/uncacheability
899 $mitigationTTL = self::TTL_LAGGED;
900 }
901 } elseif ( $dataReplicaLag === false || $dataReplicaLag > self::MAX_READ_LAG ) {
902 // Case C: low/medium snapshot lag with high replication lag
903 $mitigated = 'replication lag';
904 // Probably systemic; use a low TTL to avoid stampedes/uncacheability
905 $mitigationTTL = self::TTL_LAGGED;
906 } elseif ( $dataCombinedLag > self::MAX_READ_LAG ) {
907 $pregenCombinedLag = ( $walltime !== null ) ? ( $dataCombinedLag - $walltime ) : 0;
908 // Case D: medium snapshot lag with medium replication lag
909 if ( ( $pregenCombinedLag + self::GENERATION_HIGH_SEC ) > self::MAX_READ_LAG ) {
910 // Case D1: generation started when read lag was too high
911 $mitigated = 'read lag (late generation)';
912 // Probably non-systemic; rely on a less problematic regeneration attempt
913 $mitigationTTL = self::TTL_UNCACHEABLE;
914 } else {
915 // Case D2: slow generation made read lag too high
916 $mitigated = 'read lag (high generation time)';
917 // Probably systemic; use a low TTL to avoid stampedes/uncacheability
918 $mitigationTTL = self::TTL_LAGGED;
919 }
920 } else {
921 // Case E: new value generated with recent data
922 $mitigated = null;
923 // Nothing to mitigate
924 $mitigationTTL = null;
925 }
926
927 if ( $mitigationTTL === self::TTL_UNCACHEABLE ) {
928 $this->logger->warning(
929 "Rejected set() for {cachekey} due to $mitigated.",
930 [
931 'cachekey' => $key,
932 'lag' => $dataReplicaLag,
933 'age' => $dataSnapshotLag,
934 'walltime' => $walltime
935 ]
936 );
937
938 // no-op the write for being unsafe
939 return true;
940 }
941
942 // TTL to use in staleness checks (does not effect persistence layer TTL)
943 $logicalTTL = null;
944
945 if ( $mitigationTTL !== null ) {
946 // New value was generated from data that is old enough to be risky
947 if ( $lockTSE >= 0 ) {
948 // Persist the value as long as normal, but make it count as stale sooner
949 $logicalTTL = min( $ttl ?: INF, $mitigationTTL );
950 } else {
951 // Persist the value for a shorter duration
952 $ttl = min( $ttl ?: INF, $mitigationTTL );
953 }
954
955 $this->logger->warning(
956 "Lowered set() TTL for {cachekey} due to $mitigated.",
957 [
958 'cachekey' => $key,
959 'lag' => $dataReplicaLag,
960 'age' => $dataSnapshotLag,
961 'walltime' => $walltime
962 ]
963 );
964 }
965
966 // Wrap that value with time/TTL/version metadata
967 $wrapped = $this->wrap( $value, $logicalTTL ?: $ttl, $version, $now );
968 $storeTTL = $ttl + $staleTTL;
969
970 $flags = $this->cache::WRITE_BACKGROUND;
971 if ( $segmentable ) {
972 $flags |= $this->cache::WRITE_ALLOW_SEGMENTS;
973 }
974
975 if ( $creating ) {
976 $ok = $this->cache->add(
977 $this->makeSisterKey( $key, self::TYPE_VALUE ),
978 $wrapped,
979 $storeTTL,
980 $flags
981 );
982 } else {
983 $ok = $this->cache->merge(
984 $this->makeSisterKey( $key, self::TYPE_VALUE ),
985 static function ( $cache, $key, $cWrapped ) use ( $wrapped ) {
986 // A string value means that it is a tombstone; do nothing in that case
987 return ( is_string( $cWrapped ) ) ? false : $wrapped;
988 },
989 $storeTTL,
990 $this->cache::MAX_CONFLICTS_ONE,
991 $flags
992 );
993 }
994
995 return $ok;
996 }
997
1060 final public function delete( $key, $ttl = self::HOLDOFF_TTL ) {
1062 $span = $this->startOperationSpan( __FUNCTION__, $key );
1063
1064 // Purge values must be stored under the value key so that WANObjectCache::set()
1065 // can atomically merge values without accidentally undoing a recent purge and thus
1066 // violating the holdoff TTL restriction.
1067 $valueSisterKey = $this->makeSisterKey( $key, self::TYPE_VALUE );
1068
1069 if ( $ttl <= 0 ) {
1070 // A client or cache cleanup script is requesting a cache purge, so there is no
1071 // volatility period due to replica DB lag. Any recent change to an entity cached
1072 // in this key should have triggered an appropriate purge event.
1073 $ok = $this->cache->delete( $this->getRouteKey( $valueSisterKey ), $this->cache::WRITE_BACKGROUND );
1074 } else {
1075 // A cacheable entity recently changed, so there might be a volatility period due
1076 // to replica DB lag. Clients usually expect their actions to be reflected in any
1077 // of their subsequent web request. This is attainable if (a) purge relay lag is
1078 // lower than the time it takes for subsequent request by the client to arrive,
1079 // and, (b) DB replica queries have "read-your-writes" consistency due to DB lag
1080 // mitigation systems.
1081 $now = $this->getCurrentTime();
1082 // Set the key to the purge value in all datacenters
1083 $purge = self::PURGE_VAL_PREFIX . ':' . (int)$now;
1084 $ok = $this->cache->set(
1085 $this->getRouteKey( $valueSisterKey ),
1086 $purge,
1087 $ttl,
1088 $this->cache::WRITE_BACKGROUND
1089 );
1090 }
1091
1092 $keygroup = $this->determineKeyGroupForStats( $key );
1093
1094 $this->stats->getCounter( 'wanobjectcache_delete_total' )
1095 ->setLabel( 'keygroup', $keygroup )
1096 ->setLabel( 'result', ( $ok ? 'ok' : 'error' ) )
1097 ->copyToStatsdAt( "wanobjectcache.$keygroup.delete." . ( $ok ? 'ok' : 'error' ) )
1098 ->increment();
1099
1100 return $ok;
1101 }
1102
1122 final public function getCheckKeyTime( $key ) {
1124 $span = $this->startOperationSpan( __FUNCTION__, $key );
1125
1126 return $this->getMultiCheckKeyTime( [ $key ] )[$key];
1127 }
1128
1190 final public function getMultiCheckKeyTime( array $keys ) {
1192 $span = $this->startOperationSpan( __FUNCTION__, $keys );
1193
1194 $checkSisterKeysByKey = [];
1195 foreach ( $keys as $key ) {
1196 $checkSisterKeysByKey[$key] = $this->makeSisterKey( $key, self::TYPE_TIMESTAMP );
1197 }
1198
1199 $wrappedBySisterKey = $this->cache->getMulti( $checkSisterKeysByKey );
1200 $wrappedBySisterKey += array_fill_keys( $checkSisterKeysByKey, false );
1201
1202 $now = $this->getCurrentTime();
1203 $times = [];
1204 foreach ( $checkSisterKeysByKey as $key => $checkSisterKey ) {
1205 $purge = $this->parsePurgeValue( $wrappedBySisterKey[$checkSisterKey] );
1206 if ( $purge === null ) {
1207 $wrapped = $this->makeCheckPurgeValue( $now, self::HOLDOFF_TTL_NONE, $purge );
1208 $this->cache->add(
1209 $checkSisterKey,
1210 $wrapped,
1211 self::CHECK_KEY_TTL,
1212 $this->cache::WRITE_BACKGROUND
1213 );
1214 }
1215
1216 $times[$key] = $purge[self::PURGE_TIME];
1217 }
1218
1219 return $times;
1220 }
1221
1255 public function touchCheckKey( $key, $holdoff = self::HOLDOFF_TTL ) {
1257 $span = $this->startOperationSpan( __FUNCTION__, $key );
1258
1259 $checkSisterKey = $this->makeSisterKey( $key, self::TYPE_TIMESTAMP );
1260
1261 $now = $this->getCurrentTime();
1262 $purge = $this->makeCheckPurgeValue( $now, $holdoff );
1263 $ok = $this->cache->set(
1264 $this->getRouteKey( $checkSisterKey ),
1265 $purge,
1266 self::CHECK_KEY_TTL,
1267 $this->cache::WRITE_BACKGROUND
1268 );
1269
1270 $keygroup = $this->determineKeyGroupForStats( $key );
1271
1272 $this->stats->getCounter( 'wanobjectcache_check_total' )
1273 ->setLabel( 'keygroup', $keygroup )
1274 ->setLabel( 'result', ( $ok ? 'ok' : 'error' ) )
1275 ->copyToStatsdAt( "wanobjectcache.$keygroup.ck_touch." . ( $ok ? 'ok' : 'error' ) )
1276 ->increment();
1277
1278 return $ok;
1279 }
1280
1308 public function resetCheckKey( $key ) {
1310 $span = $this->startOperationSpan( __FUNCTION__, $key );
1311
1312 $checkSisterKey = $this->makeSisterKey( $key, self::TYPE_TIMESTAMP );
1313 $ok = $this->cache->delete( $this->getRouteKey( $checkSisterKey ), $this->cache::WRITE_BACKGROUND );
1314
1315 $keygroup = $this->determineKeyGroupForStats( $key );
1316
1317 $this->stats->getCounter( 'wanobjectcache_reset_total' )
1318 ->setLabel( 'keygroup', $keygroup )
1319 ->setLabel( 'result', ( $ok ? 'ok' : 'error' ) )
1320 ->copyToStatsdAt( "wanobjectcache.$keygroup.ck_reset." . ( $ok ? 'ok' : 'error' ) )
1321 ->increment();
1322
1323 return $ok;
1324 }
1325
1627 final public function getWithSetCallback(
1628 $key, $ttl, $callback, array $opts = [], array $cbParams = []
1629 ) {
1631 $span = $this->startOperationSpan( __FUNCTION__, $key );
1632
1633 $version = $opts['version'] ?? null;
1634 $pcTTL = $opts['pcTTL'] ?? self::TTL_UNCACHEABLE;
1635 $pCache = ( $pcTTL >= 0 )
1636 ? $this->getProcessCache( $opts['pcGroup'] ?? self::PC_PRIMARY )
1637 : null;
1638
1639 // Use the process cache if requested as long as no outer cache callback is running.
1640 // Nested callback process cache use is not lag-safe with regard to HOLDOFF_TTL since
1641 // process cached values are more lagged than persistent ones as they are not purged.
1642 if ( $pCache && $this->callbackDepth == 0 ) {
1643 $cached = $pCache->get( $key, $pcTTL, false );
1644 if ( $cached !== false ) {
1645 $this->logger->debug( "getWithSetCallback($key): process cache hit" );
1646 return $cached;
1647 }
1648 }
1649
1650 [ $value, $valueVersion, $curAsOf ] = $this->fetchOrRegenerate( $key, $ttl, $callback, $opts, $cbParams );
1651 if ( $valueVersion !== $version ) {
1652 // Current value has a different version; use the variant key for this version.
1653 // Regenerate the variant value if it is not newer than the main value at $key
1654 // so that purges to the main key propagate to the variant value.
1655 $this->logger->debug( "getWithSetCallback($key): using variant key" );
1656 [ $value ] = $this->fetchOrRegenerate(
1657 $this->makeGlobalKey( 'WANCache-key-variant', md5( $key ), (string)$version ),
1658 $ttl,
1659 $callback,
1660 [ 'version' => null, 'minAsOf' => $curAsOf ] + $opts,
1661 $cbParams
1662 );
1663 }
1664
1665 // Update the process cache if enabled
1666 if ( $pCache && $value !== false ) {
1667 $pCache->set( $key, $value );
1668 }
1669
1670 return $value;
1671 }
1672
1689 private function fetchOrRegenerate( $key, $ttl, $callback, array $opts, array $cbParams ) {
1690 $checkKeys = $opts['checkKeys'] ?? [];
1691 $graceTTL = $opts['graceTTL'] ?? self::GRACE_TTL_NONE;
1692 $minAsOf = $opts['minAsOf'] ?? self::MIN_TIMESTAMP_NONE;
1693 $hotTTR = $opts['hotTTR'] ?? self::HOT_TTR;
1694 $lowTTL = $opts['lowTTL'] ?? min( self::LOW_TTL, $ttl );
1695 $ageNew = $opts['ageNew'] ?? self::AGE_NEW;
1696 $touchedCb = $opts['touchedCallback'] ?? null;
1697 $startTime = $this->getCurrentTime();
1698
1699 $keygroup = $this->determineKeyGroupForStats( $key );
1700
1701 // Get the current key value and its metadata
1702 $curState = $this->fetchKeys( [ $key ], $checkKeys, $startTime, $touchedCb )[$key];
1703 $curValue = $curState[self::RES_VALUE];
1704
1705 // Use the cached value if it exists and is not due for synchronous regeneration
1706 if ( $this->isAcceptablyFreshValue( $curState, $graceTTL, $minAsOf ) ) {
1707 if ( !$this->isLotteryRefreshDue( $curState, $lowTTL, $ageNew, $hotTTR, $startTime ) ) {
1708 $this->stats->getTiming( 'wanobjectcache_getwithset_seconds' )
1709 ->setLabel( 'keygroup', $keygroup )
1710 ->setLabel( 'result', 'hit' )
1711 ->setLabel( 'reason', 'good' )
1712 ->copyToStatsdAt( "wanobjectcache.$keygroup.hit.good" )
1713 ->observe( 1e3 * ( $this->getCurrentTime() - $startTime ) );
1714
1715 return [ $curValue, $curState[self::RES_VERSION], $curState[self::RES_AS_OF] ];
1716 } elseif ( $this->scheduleAsyncRefresh( $key, $ttl, $callback, $opts, $cbParams ) ) {
1717 $this->logger->debug( "fetchOrRegenerate($key): hit with async refresh" );
1718
1719 $this->stats->getTiming( 'wanobjectcache_getwithset_seconds' )
1720 ->setLabel( 'keygroup', $keygroup )
1721 ->setLabel( 'result', 'hit' )
1722 ->setLabel( 'reason', 'refresh' )
1723 ->copyToStatsdAt( "wanobjectcache.$keygroup.hit.refresh" )
1724 ->observe( 1e3 * ( $this->getCurrentTime() - $startTime ) );
1725
1726 return [ $curValue, $curState[self::RES_VERSION], $curState[self::RES_AS_OF] ];
1727 } else {
1728 $this->logger->debug( "fetchOrRegenerate($key): hit with sync refresh" );
1729 }
1730 }
1731
1732 $isKeyTombstoned = ( $curState[self::RES_TOMB_AS_OF] !== null );
1733 // Use the interim key as a temporary alternative if the key is tombstoned
1734 if ( $isKeyTombstoned ) {
1735 $volState = $this->getInterimValue( $key, $minAsOf, $startTime, $touchedCb );
1736 $volValue = $volState[self::RES_VALUE];
1737 } else {
1738 $volState = $curState;
1739 $volValue = $curValue;
1740 }
1741
1742 // During the volatile "hold-off" period that follows a purge of the key, the value
1743 // will be regenerated many times if frequently accessed. This is done to mitigate
1744 // the effects of backend replication lag as soon as possible. However, throttle the
1745 // overhead of locking and regeneration by reusing values recently written to cache
1746 // tens of milliseconds ago. Verify the "as of" time against the last purge event.
1747 $lastPurgeTime = max(
1748 // RES_TOUCH_AS_OF depends on the value (possibly from the interim key)
1749 $volState[self::RES_TOUCH_AS_OF],
1750 $curState[self::RES_TOMB_AS_OF],
1751 $curState[self::RES_CHECK_AS_OF]
1752 );
1753 $safeMinAsOf = max( $minAsOf, $lastPurgeTime + self::TINY_POSITIVE );
1754
1755 if ( $volState[self::RES_VALUE] === false || $volState[self::RES_AS_OF] < $safeMinAsOf ) {
1756 $isExtremelyNewValue = false;
1757 } else {
1758 $age = $startTime - $volState[self::RES_AS_OF];
1759 $isExtremelyNewValue = ( $age < mt_rand( self::RECENT_SET_LOW_MS, self::RECENT_SET_HIGH_MS ) / 1e3 );
1760 }
1761 if ( $isExtremelyNewValue ) {
1762 $this->logger->debug( "fetchOrRegenerate($key): volatile hit" );
1763
1764 $this->stats->getTiming( 'wanobjectcache_getwithset_seconds' )
1765 ->setLabel( 'keygroup', $keygroup )
1766 ->setLabel( 'result', 'hit' )
1767 ->setLabel( 'reason', 'volatile' )
1768 ->copyToStatsdAt( "wanobjectcache.$keygroup.hit.volatile" )
1769 ->observe( 1e3 * ( $this->getCurrentTime() - $startTime ) );
1770
1771 return [ $volValue, $volState[self::RES_VERSION], $curState[self::RES_AS_OF] ];
1772 }
1773
1774 $lockTSE = $opts['lockTSE'] ?? self::TSE_NONE;
1775 $busyValue = $opts['busyValue'] ?? null;
1776 $staleTTL = $opts['staleTTL'] ?? self::STALE_TTL_NONE;
1777 $segmentable = $opts['segmentable'] ?? false;
1778 $version = $opts['version'] ?? null;
1779
1780 // Determine whether one thread per datacenter should handle regeneration at a time
1781 $useRegenerationLock =
1782 // Note that since tombstones no-op set(), $lockTSE and $curTTL cannot be used to
1783 // deduce the key hotness because |$curTTL| will always keep increasing until the
1784 // tombstone expires or is overwritten by a new tombstone. Also, even if $lockTSE
1785 // is not set, constant regeneration of a key for the tombstone lifetime might be
1786 // very expensive. Assume tombstoned keys are possibly hot in order to reduce
1787 // the risk of high regeneration load after the delete() method is called.
1788 $isKeyTombstoned ||
1789 // Assume a key is hot if requested soon ($lockTSE seconds) after purge.
1790 // This avoids stampedes when timestamps from $checkKeys/$touchedCb bump.
1791 (
1792 $curState[self::RES_CUR_TTL] !== null &&
1793 $curState[self::RES_CUR_TTL] <= 0 &&
1794 abs( $curState[self::RES_CUR_TTL] ) <= $lockTSE
1795 ) ||
1796 // Assume a key is hot if there is no value and a busy fallback is given.
1797 // This avoids stampedes on eviction or preemptive regeneration taking too long.
1798 ( $busyValue !== null && $volValue === false );
1799
1800 // If a regeneration lock is required, threads that do not get the lock will try to use
1801 // the stale value, the interim value, or the $busyValue placeholder, in that order. If
1802 // none of those are set then all threads will bypass the lock and regenerate the value.
1803 $mutexKey = $this->makeSisterKey( $key, self::TYPE_MUTEX );
1804 // Note that locking is not bypassed due to I/O errors; this avoids stampedes
1805 $hasLock = $useRegenerationLock && $this->cache->add( $mutexKey, 1, self::LOCK_TTL );
1806 if ( $useRegenerationLock && !$hasLock ) {
1807 // Determine if there is stale or volatile cached value that is still usable
1808 // @phan-suppress-next-line PhanTypeMismatchArgumentNullable False positive
1809 if ( $this->isValid( $volValue, $volState[self::RES_AS_OF], $minAsOf ) ) {
1810 $this->logger->debug( "fetchOrRegenerate($key): returning stale value" );
1811
1812 $this->stats->getTiming( 'wanobjectcache_getwithset_seconds' )
1813 ->setLabel( 'keygroup', $keygroup )
1814 ->setLabel( 'result', 'hit' )
1815 ->setLabel( 'reason', 'stale' )
1816 ->copyToStatsdAt( "wanobjectcache.$keygroup.hit.stale" )
1817 ->observe( 1e3 * ( $this->getCurrentTime() - $startTime ) );
1818
1819 return [ $volValue, $volState[self::RES_VERSION], $curState[self::RES_AS_OF] ];
1820 } elseif ( $busyValue !== null ) {
1821 $miss = is_infinite( $minAsOf ) ? 'renew' : 'miss';
1822 $this->logger->debug( "fetchOrRegenerate($key): busy $miss" );
1823
1824 $this->stats->getTiming( 'wanobjectcache_getwithset_seconds' )
1825 ->setLabel( 'keygroup', $keygroup )
1826 ->setLabel( 'result', $miss )
1827 ->setLabel( 'reason', 'busy' )
1828 ->copyToStatsdAt( "wanobjectcache.$keygroup.$miss.busy" )
1829 ->observe( 1e3 * ( $this->getCurrentTime() - $startTime ) );
1830
1831 $placeholderValue = ( $busyValue instanceof Closure ) ? $busyValue() : $busyValue;
1832
1833 return [ $placeholderValue, $version, $curState[self::RES_AS_OF] ];
1834 }
1835 }
1836
1837 // Generate the new value given any prior value with a matching version
1838 $setOpts = [];
1839 $preCallbackTime = $this->getCurrentTime();
1840 ++$this->callbackDepth;
1841 // https://github.com/phan/phan/issues/4419
1843 $value = null;
1844 try {
1845 $value = $callback(
1846 ( $curState[self::RES_VERSION] === $version ) ? $curValue : false,
1847 $ttl,
1848 $setOpts,
1849 ( $curState[self::RES_VERSION] === $version ) ? $curState[self::RES_AS_OF] : null,
1850 $cbParams
1851 );
1852 } finally {
1853 --$this->callbackDepth;
1854 }
1855 $postCallbackTime = $this->getCurrentTime();
1856
1857 // How long it took to generate the value
1858 $walltime = max( $postCallbackTime - $preCallbackTime, 0.0 );
1859
1860 $this->stats->getTiming( 'wanobjectcache_regen_seconds' )
1861 ->setLabel( 'keygroup', $keygroup )
1862 ->copyToStatsdAt( "wanobjectcache.$keygroup.regen_walltime" )
1863 ->observe( 1e3 * $walltime );
1864
1865 // Attempt to save the newly generated value if applicable
1866 if (
1867 // Callback yielded a cacheable value
1868 ( $value !== false && $ttl >= 0 ) &&
1869 // Current thread was not raced out of a regeneration lock or key is tombstoned
1870 ( !$useRegenerationLock || $hasLock || $isKeyTombstoned )
1871 ) {
1872 // If the key is write-holed then use the (volatile) interim key as an alternative
1873 if ( $isKeyTombstoned ) {
1874 $this->setInterimValue(
1875 $key,
1876 $value,
1877 $lockTSE,
1878 $version,
1879 $segmentable
1880 );
1881 } else {
1882 $this->setMainValue(
1883 $key,
1884 $value,
1885 $ttl,
1886 $version,
1887 $walltime,
1888 // @phan-suppress-next-line PhanCoalescingAlwaysNull
1889 $setOpts['lag'] ?? 0,
1890 // @phan-suppress-next-line PhanCoalescingAlwaysNull
1891 $setOpts['since'] ?? $preCallbackTime,
1892 // @phan-suppress-next-line PhanCoalescingAlwaysNull
1893 $setOpts['pending'] ?? false,
1894 $lockTSE,
1895 $staleTTL,
1896 $segmentable,
1897 ( $curValue === false )
1898 );
1899 }
1900 }
1901
1902 if ( $hasLock ) {
1903 $this->cache->delete( $mutexKey, $this->cache::WRITE_BACKGROUND );
1904 }
1905
1906 $miss = is_infinite( $minAsOf ) ? 'renew' : 'miss';
1907 $this->logger->debug( "fetchOrRegenerate($key): $miss, new value computed" );
1908
1909 $this->stats->getTiming( 'wanobjectcache_getwithset_seconds' )
1910 ->setLabel( 'keygroup', $keygroup )
1911 ->setLabel( 'result', $miss )
1912 ->setLabel( 'reason', 'compute' )
1913 ->copyToStatsdAt( "wanobjectcache.$keygroup.$miss.compute" )
1914 ->observe( 1e3 * ( $this->getCurrentTime() - $startTime ) );
1915
1916 return [ $value, $version, $curState[self::RES_AS_OF] ];
1917 }
1918
1928 private function makeSisterKey( string $baseKey, string $typeChar ) {
1929 if ( $this->coalesceScheme === self::SCHEME_HASH_STOP ) {
1930 // Key style: "WANCache:<base key>|#|<character>"
1931 $sisterKey = 'WANCache:' . $baseKey . '|#|' . $typeChar;
1932 } else {
1933 // Key style: "WANCache:{<base key>}:<character>"
1934 $sisterKey = 'WANCache:{' . $baseKey . '}:' . $typeChar;
1935 }
1936 return $sisterKey;
1937 }
1938
1948 private function getInterimValue( $key, $minAsOf, $now, $touchedCb ) {
1949 if ( $this->useInterimHoldOffCaching ) {
1950 $interimSisterKey = $this->makeSisterKey( $key, self::TYPE_INTERIM );
1951 $wrapped = $this->cache->get( $interimSisterKey );
1952 $res = $this->unwrap( $wrapped, $now );
1953 if ( $res[self::RES_VALUE] !== false && $res[self::RES_AS_OF] >= $minAsOf ) {
1954 if ( $touchedCb !== null ) {
1955 // Update "last purge time" since the $touchedCb timestamp depends on $value
1956 // Get the new "touched timestamp", accounting for callback-checked dependencies
1957 $res[self::RES_TOUCH_AS_OF] = max(
1958 $touchedCb( $res[self::RES_VALUE] ),
1959 $res[self::RES_TOUCH_AS_OF]
1960 );
1961 }
1962
1963 return $res;
1964 }
1965 }
1966
1967 return $this->unwrap( false, $now );
1968 }
1969
1978 private function setInterimValue(
1979 $key,
1980 $value,
1981 $ttl,
1982 ?int $version,
1983 bool $segmentable
1984 ) {
1985 $now = $this->getCurrentTime();
1986 $ttl = max( self::INTERIM_KEY_TTL, (int)$ttl );
1987
1988 // Wrap that value with time/TTL/version metadata
1989 $wrapped = $this->wrap( $value, $ttl, $version, $now );
1990
1991 $flags = $this->cache::WRITE_BACKGROUND;
1992 if ( $segmentable ) {
1993 $flags |= $this->cache::WRITE_ALLOW_SEGMENTS;
1994 }
1995
1996 return $this->cache->set(
1997 $this->makeSisterKey( $key, self::TYPE_INTERIM ),
1998 $wrapped,
1999 $ttl,
2000 $flags
2001 );
2002 }
2003
2069 final public function getMultiWithSetCallback(
2070 ArrayIterator $keyedIds, $ttl, callable $callback, array $opts = []
2071 ) {
2072 $span = $this->startOperationSpan( __FUNCTION__, '' );
2073 if ( $span->getContext()->isSampled() ) {
2074 $span->setAttributes( [
2075 'org.wikimedia.wancache.multi_count' => $keyedIds->count(),
2076 'org.wikimedia.wancache.ttl' => $ttl,
2077 ] );
2078 }
2079 // Batch load required keys into the in-process warmup cache
2080 $this->warmupCache = $this->fetchWrappedValuesForWarmupCache(
2081 $this->getNonProcessCachedMultiKeys( $keyedIds, $opts ),
2082 $opts['checkKeys'] ?? []
2083 );
2084 $this->warmupKeyMisses = 0;
2085
2086 // The required callback signature includes $id as the first argument for convenience
2087 // to distinguish different items. To reuse the code in getWithSetCallback(), wrap the
2088 // callback with a proxy callback that has the standard getWithSetCallback() signature.
2089 // This is defined only once per batch to avoid closure creation overhead.
2090 $proxyCb = static function ( $oldValue, &$ttl, &$setOpts, $oldAsOf, $params )
2091 use ( $callback )
2092 {
2093 return $callback( $params['id'], $oldValue, $ttl, $setOpts, $oldAsOf );
2094 };
2095
2096 // Get the order-preserved result map using the warm-up cache
2097 $values = [];
2098 foreach ( $keyedIds as $key => $id ) {
2099 $values[$key] = $this->getWithSetCallback(
2100 $key,
2101 $ttl,
2102 $proxyCb,
2103 $opts,
2104 [ 'id' => $id ]
2105 );
2106 }
2107
2108 $this->warmupCache = [];
2109
2110 return $values;
2111 }
2112
2179 final public function getMultiWithUnionSetCallback(
2180 ArrayIterator $keyedIds, $ttl, callable $callback, array $opts = []
2181 ) {
2182 $span = $this->startOperationSpan( __FUNCTION__, '' );
2183 if ( $span->getContext()->isSampled() ) {
2184 $span->setAttributes( [
2185 'org.wikimedia.wancache.multi_count' => $keyedIds->count(),
2186 'org.wikimedia.wancache.ttl' => $ttl,
2187 ] );
2188 }
2189 $checkKeys = $opts['checkKeys'] ?? []; // TODO: ???
2190 $minAsOf = $opts['minAsOf'] ?? self::MIN_TIMESTAMP_NONE;
2191
2192 // unset incompatible keys
2193 unset( $opts['lockTSE'] );
2194 unset( $opts['busyValue'] );
2195
2196 // Batch load required keys into the in-process warmup cache
2197 $keysByIdGet = $this->getNonProcessCachedMultiKeys( $keyedIds, $opts );
2198 $this->warmupCache = $this->fetchWrappedValuesForWarmupCache( $keysByIdGet, $checkKeys );
2199 $this->warmupKeyMisses = 0;
2200
2201 // IDs of entities known to be in need of generation
2202 $idsRegen = [];
2203
2204 // Find out which keys are missing/deleted/stale
2205 $now = $this->getCurrentTime();
2206 $resByKey = $this->fetchKeys( $keysByIdGet, $checkKeys, $now );
2207 foreach ( $keysByIdGet as $id => $key ) {
2208 $res = $resByKey[$key];
2209 if (
2210 $res[self::RES_VALUE] === false ||
2211 $res[self::RES_CUR_TTL] < 0 ||
2212 $res[self::RES_AS_OF] < $minAsOf
2213 ) {
2214 $idsRegen[] = $id;
2215 }
2216 }
2217
2218 // Run the callback to populate the generation value map for all required IDs
2219 $newSetOpts = [];
2220 $newTTLsById = array_fill_keys( $idsRegen, $ttl );
2221 $newValsById = $idsRegen ? $callback( $idsRegen, $newTTLsById, $newSetOpts ) : [];
2222
2223 $method = __METHOD__;
2224 // The required callback signature includes $id as the first argument for convenience
2225 // to distinguish different items. To reuse the code in getWithSetCallback(), wrap the
2226 // callback with a proxy callback that has the standard getWithSetCallback() signature.
2227 // This is defined only once per batch to avoid closure creation overhead.
2228 $proxyCb = function ( $oldValue, &$ttl, &$setOpts, $oldAsOf, $params )
2229 use ( $callback, $newValsById, $newTTLsById, $newSetOpts, $method )
2230 {
2231 $id = $params['id'];
2232
2233 if ( array_key_exists( $id, $newValsById ) ) {
2234 // Value was already regenerated as expected, so use the value in $newValsById
2235 $newValue = $newValsById[$id];
2236 $ttl = $newTTLsById[$id];
2237 $setOpts = $newSetOpts;
2238 } else {
2239 // Pre-emptive/popularity refresh and version mismatch cases are not detected
2240 // above and thus $newValsById has no entry. Run $callback on this single entity.
2241 $ttls = [ $id => $ttl ];
2242 $result = $callback( [ $id ], $ttls, $setOpts );
2243 if ( !isset( $result[$id] ) ) {
2244 // T303092
2245 $this->logger->warning(
2246 $method . ' failed due to {id} not set in result {result}', [
2247 'id' => $id,
2248 'result' => json_encode( $result )
2249 ] );
2250 }
2251 $newValue = $result[$id];
2252 $ttl = $ttls[$id];
2253 }
2254
2255 return $newValue;
2256 };
2257
2258 // Get the order-preserved result map using the warm-up cache
2259 $values = [];
2260 foreach ( $keyedIds as $key => $id ) {
2261 $values[$key] = $this->getWithSetCallback(
2262 $key,
2263 $ttl,
2264 $proxyCb,
2265 $opts,
2266 [ 'id' => $id ]
2267 );
2268 }
2269
2270 $this->warmupCache = [];
2271
2272 return $values;
2273 }
2274
2282 public function makeGlobalKey( $keygroup, ...$components ) {
2283 return $this->cache->makeGlobalKey( $keygroup, ...$components );
2284 }
2285
2293 public function makeKey( $keygroup, ...$components ) {
2294 return $this->cache->makeKey( $keygroup, ...$components );
2295 }
2296
2304 public function hash256( $component ) {
2305 return hash_hmac( 'sha256', $component, $this->secret );
2306 }
2307
2359 final public function makeMultiKeys( array $ids, $keyCallback ) {
2360 $idByKey = [];
2361 foreach ( $ids as $id ) {
2362 // Discourage triggering of automatic makeKey() hashing in some backends
2363 if ( strlen( $id ) > 64 ) {
2364 $this->logger->warning( __METHOD__ . ": long ID '$id'; use hash256()" );
2365 }
2366 $key = $keyCallback( $id, $this );
2367 // Edge case: ignore key collisions due to duplicate $ids like "42" and 42
2368 if ( !isset( $idByKey[$key] ) ) {
2369 $idByKey[$key] = $id;
2370 } elseif ( (string)$id !== (string)$idByKey[$key] ) {
2371 throw new UnexpectedValueException(
2372 "Cache key collision; IDs ('$id','{$idByKey[$key]}') map to '$key'"
2373 );
2374 }
2375 }
2376
2377 return new ArrayIterator( $idByKey );
2378 }
2379
2415 final public function multiRemap( array $ids, array $res ) {
2416 if ( count( $ids ) !== count( $res ) ) {
2417 // If makeMultiKeys() is called on a list of non-unique IDs, then the resulting
2418 // ArrayIterator will have less entries due to "first appearance" de-duplication
2419 $ids = array_keys( array_fill_keys( $ids, true ) );
2420 if ( count( $ids ) !== count( $res ) ) {
2421 throw new UnexpectedValueException( "Multi-key result does not match ID list" );
2422 }
2423 }
2424
2425 return array_combine( $ids, $res );
2426 }
2427
2434 public function watchErrors() {
2435 return $this->cache->watchErrors();
2436 }
2437
2455 final public function getLastError( $watchPoint = 0 ) {
2456 $code = $this->cache->getLastError( $watchPoint );
2457 switch ( $code ) {
2459 return BagOStuff::ERR_NONE;
2464 default:
2466 }
2467 }
2468
2474 public function clearProcessCache() {
2475 $this->processCaches = [];
2476 }
2477
2498 final public function useInterimHoldOffCaching( $enabled ) {
2499 $this->useInterimHoldOffCaching = $enabled;
2500 }
2501
2507 public function getQoS( $flag ) {
2508 return $this->cache->getQoS( $flag );
2509 }
2510
2574 public function adaptiveTTL( $mtime, $maxTTL, $minTTL = 30, $factor = 0.2 ) {
2575 // handle fractional seconds and string integers
2576 $mtime = (int)$mtime;
2577 if ( $mtime <= 0 ) {
2578 // no last-modified time provided
2579 return $minTTL;
2580 }
2581
2582 $age = (int)$this->getCurrentTime() - $mtime;
2583
2584 return (int)min( $maxTTL, max( $minTTL, $factor * $age ) );
2585 }
2586
2592 final public function getWarmupKeyMisses() {
2593 // Number of misses in $this->warmupCache during the last call to certain methods
2594 return $this->warmupKeyMisses;
2595 }
2596
2601 protected function getRouteKey( string $sisterKey ) {
2602 if ( $this->broadcastRoute !== null ) {
2603 if ( $sisterKey[0] === '/' ) {
2604 throw new RuntimeException( "Sister key '$sisterKey' already contains a route." );
2605 }
2606 return $this->broadcastRoute . $sisterKey;
2607 }
2608 return $sisterKey;
2609 }
2610
2622 private function scheduleAsyncRefresh( $key, $ttl, $callback, array $opts, array $cbParams ) {
2623 if ( !$this->asyncHandler ) {
2624 return false;
2625 }
2626 // Update the cache value later, such during post-send of an HTTP request. This forces
2627 // cache regeneration by setting "minAsOf" to infinity, meaning that no existing value
2628 // is considered valid. Furthermore, note that preemptive regeneration is not applicable
2629 // to invalid values, so there is no risk of infinite preemptive regeneration loops.
2630 $func = $this->asyncHandler;
2631 $func( function () use ( $key, $ttl, $callback, $opts, $cbParams ) {
2632 $opts['minAsOf'] = INF;
2633 try {
2634 $this->fetchOrRegenerate( $key, $ttl, $callback, $opts, $cbParams );
2635 } catch ( Exception $e ) {
2636 // Log some context for easier debugging
2637 $this->logger->error( 'Async refresh failed for {key}', [
2638 'key' => $key,
2639 'ttl' => $ttl,
2640 'exception' => $e
2641 ] );
2642 throw $e;
2643 }
2644 } );
2645
2646 return true;
2647 }
2648
2657 private function isAcceptablyFreshValue( $res, $graceTTL, $minAsOf ) {
2658 if ( !$this->isValid( $res[self::RES_VALUE], $res[self::RES_AS_OF], $minAsOf ) ) {
2659 // Value does not exists or is too old
2660 return false;
2661 }
2662
2663 $curTTL = $res[self::RES_CUR_TTL];
2664 if ( $curTTL > 0 ) {
2665 // Value is definitely still fresh
2666 return true;
2667 }
2668
2669 // Remaining seconds during which this stale value can be used
2670 $curGraceTTL = $graceTTL + $curTTL;
2671
2672 return ( $curGraceTTL > 0 )
2673 // Chance of using the value decreases as $curTTL goes from 0 to -$graceTTL
2674 ? !$this->worthRefreshExpiring( $curGraceTTL, $graceTTL, $graceTTL )
2675 // Value is too stale to fall in the grace period
2676 : false;
2677 }
2678
2689 protected function isLotteryRefreshDue( $res, $lowTTL, $ageNew, $hotTTR, $now ) {
2690 $curTTL = $res[self::RES_CUR_TTL];
2691 $logicalTTL = $res[self::RES_TTL];
2692 $asOf = $res[self::RES_AS_OF];
2693
2694 return (
2695 $this->worthRefreshExpiring( $curTTL, $logicalTTL, $lowTTL ) ||
2696 $this->worthRefreshPopular( $asOf, $ageNew, $hotTTR, $now )
2697 );
2698 }
2699
2715 protected function worthRefreshPopular( $asOf, $ageNew, $timeTillRefresh, $now ) {
2716 if ( $ageNew < 0 || $timeTillRefresh <= 0 ) {
2717 return false;
2718 }
2719
2720 $age = $now - $asOf;
2721 $timeOld = $age - $ageNew;
2722 if ( $timeOld <= 0 ) {
2723 return false;
2724 }
2725
2726 $popularHitsPerSec = 1;
2727 // Lifecycle is: new, ramp-up refresh chance, full refresh chance.
2728 // Note that the "expected # of refreshes" for the ramp-up time range is half
2729 // of what it would be if P(refresh) was at its full value during that time range.
2730 $refreshWindowSec = max( $timeTillRefresh - $ageNew - self::RAMPUP_TTL / 2, 1 );
2731 // P(refresh) * (# hits in $refreshWindowSec) = (expected # of refreshes)
2732 // P(refresh) * ($refreshWindowSec * $popularHitsPerSec) = 1 (by definition)
2733 // P(refresh) = 1/($refreshWindowSec * $popularHitsPerSec)
2734 $chance = 1 / ( $popularHitsPerSec * $refreshWindowSec );
2735 // Ramp up $chance from 0 to its nominal value over RAMPUP_TTL seconds to avoid stampedes
2736 $chance *= ( $timeOld <= self::RAMPUP_TTL ) ? $timeOld / self::RAMPUP_TTL : 1;
2737
2738 return ( mt_rand( 1, 1_000_000_000 ) <= 1_000_000_000 * $chance );
2739 }
2740
2759 protected function worthRefreshExpiring( $curTTL, $logicalTTL, $lowTTL ) {
2760 if ( $lowTTL <= 0 ) {
2761 return false;
2762 }
2763 // T264787: avoid having keys start off with a high chance of being refreshed;
2764 // the point where refreshing becomes possible cannot precede the key lifetime.
2765 $effectiveLowTTL = min( $lowTTL, $logicalTTL ?: INF );
2766
2767 // How long the value was in the "low TTL" phase
2768 $timeOld = $effectiveLowTTL - $curTTL;
2769 if ( $timeOld <= 0 || $timeOld >= $effectiveLowTTL ) {
2770 return false;
2771 }
2772
2773 // Ratio of the low TTL phase that has elapsed (r)
2774 $ttrRatio = $timeOld / $effectiveLowTTL;
2775 // Use p(r) as the monotonically increasing "chance of refresh" function,
2776 // having p(0)=0 and p(1)=1. The value expires at the nominal expiry.
2777 $chance = $ttrRatio ** 4;
2778
2779 return ( mt_rand( 1, 1_000_000_000 ) <= 1_000_000_000 * $chance );
2780 }
2781
2790 protected function isValid( $value, $asOf, $minAsOf ) {
2791 return ( $value !== false && $asOf >= $minAsOf );
2792 }
2793
2801 private function wrap( $value, $ttl, $version, $now ) {
2802 // Returns keys in ascending integer order for PHP7 array packing:
2803 // https://nikic.github.io/2014/12/22/PHPs-new-hashtable-implementation.html
2804 $wrapped = [
2805 self::FLD_FORMAT_VERSION => self::VERSION,
2806 self::FLD_VALUE => $value,
2807 self::FLD_TTL => $ttl,
2808 self::FLD_TIME => $now
2809 ];
2810 if ( $version !== null ) {
2811 $wrapped[self::FLD_VALUE_VERSION] = $version;
2812 }
2813
2814 return $wrapped;
2815 }
2816
2831 private function unwrap( $wrapped, $now ) {
2832 // https://nikic.github.io/2014/12/22/PHPs-new-hashtable-implementation.html
2833 $res = [
2834 // Attributes that only depend on the fetched key value
2835 self::RES_VALUE => false,
2836 self::RES_VERSION => null,
2837 self::RES_AS_OF => null,
2838 self::RES_TTL => null,
2839 self::RES_TOMB_AS_OF => null,
2840 // Attributes that depend on caller-specific "check" keys or "touched callbacks"
2841 self::RES_CHECK_AS_OF => null,
2842 self::RES_TOUCH_AS_OF => null,
2843 self::RES_CUR_TTL => null
2844 ];
2845
2846 if ( is_array( $wrapped ) ) {
2847 // Entry expected to be a cached value; validate it
2848 if (
2849 ( $wrapped[self::FLD_FORMAT_VERSION] ?? null ) === self::VERSION &&
2850 $wrapped[self::FLD_TIME] >= $this->epoch
2851 ) {
2852 if ( $wrapped[self::FLD_TTL] > 0 ) {
2853 // Get the approximate time left on the key
2854 $age = $now - $wrapped[self::FLD_TIME];
2855 $curTTL = max( $wrapped[self::FLD_TTL] - $age, 0.0 );
2856 } else {
2857 // Key had no TTL, so the time left is unbounded
2858 $curTTL = INF;
2859 }
2860 $res[self::RES_VALUE] = $wrapped[self::FLD_VALUE];
2861 $res[self::RES_VERSION] = $wrapped[self::FLD_VALUE_VERSION] ?? null;
2862 $res[self::RES_AS_OF] = $wrapped[self::FLD_TIME];
2863 $res[self::RES_CUR_TTL] = $curTTL;
2864 $res[self::RES_TTL] = $wrapped[self::FLD_TTL];
2865 }
2866 } else {
2867 // Entry expected to be a tombstone; parse it
2868 $purge = $this->parsePurgeValue( $wrapped );
2869 if ( $purge !== null ) {
2870 // Tombstoned keys should always have a negative "current TTL"
2871 $curTTL = min( $purge[self::PURGE_TIME] - $now, self::TINY_NEGATIVE );
2872 $res[self::RES_CUR_TTL] = $curTTL;
2873 $res[self::RES_TOMB_AS_OF] = $purge[self::PURGE_TIME];
2874 }
2875 }
2876
2877 return $res;
2878 }
2879
2885 private function determineKeyGroupForStats( $key ) {
2886 $parts = explode( ':', $key, 3 );
2887 // Fallback in case the key was not made by makeKey.
2888 // Replace dots because they are special in StatsD (T232907)
2889 return strtr( $parts[1] ?? $parts[0], '.', '_' );
2890 }
2891
2900 private function parsePurgeValue( $value ) {
2901 if ( !is_string( $value ) ) {
2902 return null;
2903 }
2904
2905 $segments = explode( ':', $value, 3 );
2906 $prefix = $segments[0];
2907 if ( $prefix !== self::PURGE_VAL_PREFIX ) {
2908 // Not a purge value
2909 return null;
2910 }
2911
2912 $timestamp = (float)$segments[1];
2913 // makeTombstonePurgeValue() doesn't store hold-off TTLs
2914 $holdoff = isset( $segments[2] ) ? (int)$segments[2] : self::HOLDOFF_TTL;
2915
2916 if ( $timestamp < $this->epoch ) {
2917 // Purge value is too old
2918 return null;
2919 }
2920
2921 return [ self::PURGE_TIME => $timestamp, self::PURGE_HOLDOFF => $holdoff ];
2922 }
2923
2930 private function makeCheckPurgeValue( float $timestamp, int $holdoff, ?array &$purge = null ) {
2931 $normalizedTime = (int)$timestamp;
2932 // Purge array that matches what parsePurgeValue() would have returned
2933 $purge = [ self::PURGE_TIME => (float)$normalizedTime, self::PURGE_HOLDOFF => $holdoff ];
2934
2935 return self::PURGE_VAL_PREFIX . ":$normalizedTime:$holdoff";
2936 }
2937
2942 private function getProcessCache( $group ) {
2943 if ( !isset( $this->processCaches[$group] ) ) {
2944 [ , $size ] = explode( ':', $group );
2945 $this->processCaches[$group] = new MapCacheLRU( (int)$size );
2946 if ( $this->wallClockOverride !== null ) {
2947 $this->processCaches[$group]->setMockTime( $this->wallClockOverride );
2948 }
2949 }
2950
2951 return $this->processCaches[$group];
2952 }
2953
2959 private function getNonProcessCachedMultiKeys( ArrayIterator $keys, array $opts ) {
2960 $pcTTL = $opts['pcTTL'] ?? self::TTL_UNCACHEABLE;
2961
2962 $keysMissing = [];
2963 if ( $pcTTL > 0 && $this->callbackDepth == 0 ) {
2964 $pCache = $this->getProcessCache( $opts['pcGroup'] ?? self::PC_PRIMARY );
2965 foreach ( $keys as $key => $id ) {
2966 if ( !$pCache->has( $key, $pcTTL ) ) {
2967 $keysMissing[$id] = $key;
2968 }
2969 }
2970 }
2971
2972 return $keysMissing;
2973 }
2974
2981 private function fetchWrappedValuesForWarmupCache( array $keys, array $checkKeys ) {
2982 if ( !$keys ) {
2983 return [];
2984 }
2985
2986 // Get all the value keys to fetch...
2987 $sisterKeys = [];
2988 foreach ( $keys as $baseKey ) {
2989 $sisterKeys[] = $this->makeSisterKey( $baseKey, self::TYPE_VALUE );
2990 }
2991 // Get all the "check" keys to fetch...
2992 foreach ( $checkKeys as $i => $checkKeyOrKeyGroup ) {
2993 // Note: avoid array_merge() inside loop in case there are many keys
2994 if ( is_int( $i ) ) {
2995 // Single "check" key that applies to all value keys
2996 $sisterKeys[] = $this->makeSisterKey( $checkKeyOrKeyGroup, self::TYPE_TIMESTAMP );
2997 } else {
2998 // List of "check" keys that apply to a specific value key
2999 foreach ( (array)$checkKeyOrKeyGroup as $checkKey ) {
3000 $sisterKeys[] = $this->makeSisterKey( $checkKey, self::TYPE_TIMESTAMP );
3001 }
3002 }
3003 }
3004
3005 $wrappedBySisterKey = $this->cache->getMulti( $sisterKeys );
3006 $wrappedBySisterKey += array_fill_keys( $sisterKeys, false );
3007
3008 return $wrappedBySisterKey;
3009 }
3010
3016 private function timeSinceLoggedMiss( $key, $now ) {
3017 // phpcs:ignore Generic.CodeAnalysis.AssignmentInCondition.Found
3018 for ( end( $this->missLog ); $miss = current( $this->missLog ); prev( $this->missLog ) ) {
3019 if ( $miss[0] === $key ) {
3020 return ( $now - $miss[1] );
3021 }
3022 }
3023
3024 return null;
3025 }
3026
3031 protected function getCurrentTime() {
3032 return $this->wallClockOverride ?: microtime( true );
3033 }
3034
3039 public function setMockTime( &$time ) {
3040 $this->wallClockOverride =& $time;
3041 $this->cache->setMockTime( $time );
3042 foreach ( $this->processCaches as $pCache ) {
3043 $pCache->setMockTime( $time );
3044 }
3045 }
3046
3058 private function startOperationSpan( $opName, $keys, $checkKeys = [] ) {
3059 $span = $this->tracer->createSpan( "WANObjectCache::$opName" )
3060 ->setSpanKind( SpanInterface::SPAN_KIND_CLIENT )
3061 ->start();
3062
3063 if ( !$span->getContext()->isSampled() ) {
3064 return $span;
3065 }
3066
3067 $keys = is_array( $keys ) ? implode( ' ', $keys ) : $keys;
3068
3069 if ( count( $checkKeys ) > 0 ) {
3070 $checkKeys = array_map(
3071 static fn ( $checkKeyOrKeyGroup ) =>
3072 is_array( $checkKeyOrKeyGroup )
3073 ? implode( ' ', $checkKeyOrKeyGroup )
3074 : $checkKeyOrKeyGroup,
3075 $checkKeys );
3076
3077 $checkKeys = implode( ' ', $checkKeys );
3078 $span->setAttributes( [ 'org.wikimedia.wancache.check_keys' => $checkKeys ] );
3079 }
3080
3081 $span->setAttributes( [ 'org.wikimedia.wancache.keys' => $keys ] );
3082
3083 $span->activate();
3084 return $span;
3085 }
3086}
3087
3089class_alias( WANObjectCache::class, 'WANObjectCache' );
Store key-value entries in a size-limited in-memory LRU cache.
Abstract class for any ephemeral data store.
Definition BagOStuff.php:87
const ERR_NO_RESPONSE
Storage operation failed to yield a complete response.
const ERR_NONE
Storage operation succeeded, or no operation was performed.
const ERR_UNEXPECTED
Storage operation failed due to usage limitations or an I/O error.
const ERR_UNREACHABLE
Storage operation could not establish a connection.
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.
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".
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.
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.
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,...
A no-op tracer that creates no-op spans and persists no data.
Generic interface providing Time-To-Live constants for expirable object storage.
Key-encoding methods for object caching (BagOStuff and WANObjectCache)
Represents an OpenTelemetry span, i.e.
const SPAN_KIND_CLIENT
Indicates that the span describes a request to some remote service.
Base interface for an OpenTelemetry tracer responsible for creating spans.