MediaWiki master
WANObjectCache.php
Go to the documentation of this file.
1<?php
22use Liuggio\StatsdClient\Factory\StatsdDataFactoryInterface;
23use Psr\Log\LoggerAwareInterface;
24use Psr\Log\LoggerInterface;
25use Psr\Log\NullLogger;
28
125class WANObjectCache implements
129 LoggerAwareInterface
130{
132 protected $cache;
134 protected $processCaches = [];
136 protected $logger;
138 protected $stats;
140 protected $asyncHandler;
141
153 protected $epoch;
155 protected $secret;
158
160 private $missLog;
161
163 private $callbackDepth = 0;
165 private $warmupCache = [];
167 private $warmupKeyMisses = 0;
168
170 private $wallClockOverride;
171
173 private const MAX_COMMIT_DELAY = 3;
175 private const MAX_READ_LAG = 7;
177 public const HOLDOFF_TTL = self::MAX_COMMIT_DELAY + self::MAX_READ_LAG + 1;
178
180 private const LOW_TTL = 60;
182 public const TTL_LAGGED = 30;
183
185 private const HOT_TTR = 900;
187 private const AGE_NEW = 60;
188
190 private const TSE_NONE = -1;
191
193 public const STALE_TTL_NONE = 0;
195 public const GRACE_TTL_NONE = 0;
197 public const HOLDOFF_TTL_NONE = 0;
198
200 public const MIN_TIMESTAMP_NONE = 0.0;
201
203 private const PC_PRIMARY = 'primary:1000';
204
206 public const PASS_BY_REF = [];
207
209 private const SCHEME_HASH_TAG = 1;
211 private const SCHEME_HASH_STOP = 2;
212
214 private const CHECK_KEY_TTL = self::TTL_YEAR;
216 private const INTERIM_KEY_TTL = 2;
217
219 private const LOCK_TTL = 10;
221 private const RAMPUP_TTL = 30;
222
224 private const TINY_NEGATIVE = -0.000001;
226 private const TINY_POSITIVE = 0.000001;
227
229 private const RECENT_SET_LOW_MS = 50;
231 private const RECENT_SET_HIGH_MS = 100;
232
234 private const GENERATION_HIGH_SEC = 0.2;
235
237 private const PURGE_TIME = 0;
239 private const PURGE_HOLDOFF = 1;
240
242 private const VERSION = 1;
243
245 public const KEY_VERSION = 'version';
247 public const KEY_AS_OF = 'asOf';
249 public const KEY_TTL = 'ttl';
251 public const KEY_CUR_TTL = 'curTTL';
253 public const KEY_TOMB_AS_OF = 'tombAsOf';
255 public const KEY_CHECK_AS_OF = 'lastCKPurge';
256
258 private const RES_VALUE = 0;
260 private const RES_VERSION = 1;
262 private const RES_AS_OF = 2;
264 private const RES_TTL = 3;
266 private const RES_TOMB_AS_OF = 4;
268 private const RES_CHECK_AS_OF = 5;
270 private const RES_TOUCH_AS_OF = 6;
272 private const RES_CUR_TTL = 7;
273
275 private const FLD_FORMAT_VERSION = 0;
277 private const FLD_VALUE = 1;
279 private const FLD_TTL = 2;
281 private const FLD_TIME = 3;
283 private const FLD_FLAGS = 4;
285 private const FLD_VALUE_VERSION = 5;
286 private const FLD_GENERATION_TIME = 6;
287
289 private const TYPE_VALUE = 'v';
291 private const TYPE_TIMESTAMP = 't';
293 private const TYPE_MUTEX = 'm';
295 private const TYPE_INTERIM = 'i';
296
298 private const PURGE_VAL_PREFIX = 'PURGED';
299
326 public function __construct( array $params ) {
327 $this->cache = $params['cache'];
328 $this->broadcastRoute = $params['broadcastRoutingPrefix'] ?? null;
329 $this->epoch = $params['epoch'] ?? 0;
330 $this->secret = $params['secret'] ?? (string)$this->epoch;
331 if ( ( $params['coalesceScheme'] ?? '' ) === 'hash_tag' ) {
332 // https://redis.io/topics/cluster-spec
333 // https://github.com/twitter/twemproxy/blob/v0.4.1/notes/recommendation.md#hash-tags
334 // https://github.com/Netflix/dynomite/blob/v0.7.0/notes/recommendation.md#hash-tags
335 $this->coalesceScheme = self::SCHEME_HASH_TAG;
336 } else {
337 // https://github.com/facebook/mcrouter/wiki/Key-syntax
338 $this->coalesceScheme = self::SCHEME_HASH_STOP;
339 }
340
341 $this->setLogger( $params['logger'] ?? new NullLogger() );
342 $this->stats = $params['stats'] ?? new NullStatsdDataFactory();
343 $this->asyncHandler = $params['asyncHandler'] ?? null;
344
345 $this->missLog = array_fill( 0, 10, [ '', 0.0 ] );
346 }
347
351 public function setLogger( LoggerInterface $logger ) {
352 $this->logger = $logger;
353 }
354
360 public static function newEmpty() {
361 return new static( [ 'cache' => new EmptyBagOStuff() ] );
362 }
363
419 final public function get( $key, &$curTTL = null, array $checkKeys = [], &$info = [] ) {
420 // Note that an undeclared variable passed as $info starts as null (not the default).
421 // Also, if no $info parameter is provided, then it doesn't matter how it changes here.
422 $legacyInfo = ( $info !== self::PASS_BY_REF );
423
424 $res = $this->fetchKeys( [ $key ], $checkKeys )[$key];
425
426 $curTTL = $res[self::RES_CUR_TTL];
427 $info = $legacyInfo
428 ? $res[self::RES_AS_OF]
429 : [
430 self::KEY_VERSION => $res[self::RES_VERSION],
431 self::KEY_AS_OF => $res[self::RES_AS_OF],
432 self::KEY_TTL => $res[self::RES_TTL],
433 self::KEY_CUR_TTL => $res[self::RES_CUR_TTL],
434 self::KEY_TOMB_AS_OF => $res[self::RES_TOMB_AS_OF],
435 self::KEY_CHECK_AS_OF => $res[self::RES_CHECK_AS_OF]
436 ];
437
438 if ( $curTTL === null || $curTTL <= 0 ) {
439 // Log the timestamp in case a corresponding set() call does not provide "walltime"
440 unset( $this->missLog[array_key_first( $this->missLog )] );
441 $this->missLog[] = [ $key, $this->getCurrentTime() ];
442 }
443
444 return $res[self::RES_VALUE];
445 }
446
471 final public function getMulti(
472 array $keys,
473 &$curTTLs = [],
474 array $checkKeys = [],
475 &$info = []
476 ) {
477 // Note that an undeclared variable passed as $info starts as null (not the default).
478 // Also, if no $info parameter is provided, then it doesn't matter how it changes here.
479 $legacyInfo = ( $info !== self::PASS_BY_REF );
480
481 $curTTLs = [];
482 $info = [];
483 $valuesByKey = [];
484
485 $resByKey = $this->fetchKeys( $keys, $checkKeys );
486 foreach ( $resByKey as $key => $res ) {
487 if ( $res[self::RES_VALUE] !== false ) {
488 $valuesByKey[$key] = $res[self::RES_VALUE];
489 }
490
491 if ( $res[self::RES_CUR_TTL] !== null ) {
492 $curTTLs[$key] = $res[self::RES_CUR_TTL];
493 }
494 $info[$key] = $legacyInfo
495 ? $res[self::RES_AS_OF]
496 : [
497 self::KEY_VERSION => $res[self::RES_VERSION],
498 self::KEY_AS_OF => $res[self::RES_AS_OF],
499 self::KEY_TTL => $res[self::RES_TTL],
500 self::KEY_CUR_TTL => $res[self::RES_CUR_TTL],
501 self::KEY_TOMB_AS_OF => $res[self::RES_TOMB_AS_OF],
502 self::KEY_CHECK_AS_OF => $res[self::RES_CHECK_AS_OF]
503 ];
504 }
505
506 return $valuesByKey;
507 }
508
523 protected function fetchKeys( array $keys, array $checkKeys, $touchedCb = null ) {
524 $resByKey = [];
525
526 // List of all sister keys that need to be fetched from cache
527 $allSisterKeys = [];
528 // Order-corresponding value sister key list for the base key list ($keys)
529 $valueSisterKeys = [];
530 // List of "check" sister keys to compare all value sister keys against
531 $checkSisterKeysForAll = [];
532 // Map of (base key => additional "check" sister key(s) to compare against)
533 $checkSisterKeysByKey = [];
534
535 foreach ( $keys as $key ) {
536 $sisterKey = $this->makeSisterKey( $key, self::TYPE_VALUE );
537 $allSisterKeys[] = $sisterKey;
538 $valueSisterKeys[] = $sisterKey;
539 }
540
541 foreach ( $checkKeys as $i => $checkKeyOrKeyGroup ) {
542 // Note: avoid array_merge() inside loop in case there are many keys
543 if ( is_int( $i ) ) {
544 // Single "check" key that applies to all base keys
545 $sisterKey = $this->makeSisterKey( $checkKeyOrKeyGroup, self::TYPE_TIMESTAMP );
546 $allSisterKeys[] = $sisterKey;
547 $checkSisterKeysForAll[] = $sisterKey;
548 } else {
549 // List of "check" keys that apply to a specific base key
550 foreach ( (array)$checkKeyOrKeyGroup as $checkKey ) {
551 $sisterKey = $this->makeSisterKey( $checkKey, self::TYPE_TIMESTAMP );
552 $allSisterKeys[] = $sisterKey;
553 $checkSisterKeysByKey[$i][] = $sisterKey;
554 }
555 }
556 }
557
558 if ( $this->warmupCache ) {
559 // Get the wrapped values of the sister keys from the warmup cache
560 $wrappedBySisterKey = $this->warmupCache;
561 $sisterKeysMissing = array_diff( $allSisterKeys, array_keys( $wrappedBySisterKey ) );
562 if ( $sisterKeysMissing ) {
563 $this->warmupKeyMisses += count( $sisterKeysMissing );
564 $wrappedBySisterKey += $this->cache->getMulti( $sisterKeysMissing );
565 }
566 } else {
567 // Fetch the wrapped values of the sister keys from the backend
568 $wrappedBySisterKey = $this->cache->getMulti( $allSisterKeys );
569 }
570
571 // Pessimistically treat the "current time" as the time when any network I/O finished
572 $now = $this->getCurrentTime();
573
574 // List of "check" sister key purge timestamps to compare all value sister keys against
575 $ckPurgesForAll = $this->processCheckKeys(
576 $checkSisterKeysForAll,
577 $wrappedBySisterKey,
578 $now
579 );
580 // Map of (base key => extra "check" sister key purge timestamp(s) to compare against)
581 $ckPurgesByKey = [];
582 foreach ( $checkSisterKeysByKey as $keyWithCheckKeys => $checkKeysForKey ) {
583 $ckPurgesByKey[$keyWithCheckKeys] = $this->processCheckKeys(
584 $checkKeysForKey,
585 $wrappedBySisterKey,
586 $now
587 );
588 }
589
590 // Unwrap and validate any value found for each base key (under the value sister key)
591 reset( $keys );
592 foreach ( $valueSisterKeys as $valueSisterKey ) {
593 // Get the corresponding base key for this value sister key
594 $key = current( $keys );
595 next( $keys );
596
597 if ( array_key_exists( $valueSisterKey, $wrappedBySisterKey ) ) {
598 // Key exists as either a live value or tombstone value
599 $wrapped = $wrappedBySisterKey[$valueSisterKey];
600 } else {
601 // Key does not exist
602 $wrapped = false;
603 }
604
605 $res = $this->unwrap( $wrapped, $now );
606 $value = $res[self::RES_VALUE];
607
608 foreach ( array_merge( $ckPurgesForAll, $ckPurgesByKey[$key] ?? [] ) as $ckPurge ) {
609 $res[self::RES_CHECK_AS_OF] = max(
610 $ckPurge[self::PURGE_TIME],
611 $res[self::RES_CHECK_AS_OF]
612 );
613 // Timestamp marking the end of the hold-off period for this purge
614 $holdoffDeadline = $ckPurge[self::PURGE_TIME] + $ckPurge[self::PURGE_HOLDOFF];
615 // Check if the value was generated during the hold-off period
616 if ( $value !== false && $holdoffDeadline >= $res[self::RES_AS_OF] ) {
617 // How long ago this value was purged by *this* "check" key
618 $ago = min( $ckPurge[self::PURGE_TIME] - $now, self::TINY_NEGATIVE );
619 // How long ago this value was purged by *any* known "check" key
620 $res[self::RES_CUR_TTL] = min( $res[self::RES_CUR_TTL], $ago );
621 }
622 }
623
624 if ( $touchedCb !== null && $value !== false ) {
625 $touched = $touchedCb( $value );
626 if ( $touched !== null && $touched >= $res[self::RES_AS_OF] ) {
627 $res[self::RES_CUR_TTL] = min(
628 $res[self::RES_CUR_TTL],
629 $res[self::RES_AS_OF] - $touched,
630 self::TINY_NEGATIVE
631 );
632 }
633 } else {
634 $touched = null;
635 }
636
637 $res[self::RES_TOUCH_AS_OF] = max( $res[self::RES_TOUCH_AS_OF], $touched );
638
639 $resByKey[$key] = $res;
640 }
641
642 return $resByKey;
643 }
644
651 private function processCheckKeys(
652 array $checkSisterKeys,
653 array $wrappedBySisterKey,
654 float $now
655 ) {
656 $purges = [];
657
658 foreach ( $checkSisterKeys as $timeKey ) {
659 $purge = isset( $wrappedBySisterKey[$timeKey] )
660 ? $this->parsePurgeValue( $wrappedBySisterKey[$timeKey] )
661 : null;
662
663 if ( $purge === null ) {
664 // No holdoff when lazy creating a check key, use cache right away (T344191)
665 $wrapped = $this->makeCheckPurgeValue( $now, self::HOLDOFF_TTL_NONE, $purge );
666 $this->cache->add(
667 $timeKey,
668 $wrapped,
669 self::CHECK_KEY_TTL,
670 $this->cache::WRITE_BACKGROUND
671 );
672 }
673
674 $purges[] = $purge;
675 }
676
677 return $purges;
678 }
679
763 final public function set( $key, $value, $ttl = self::TTL_INDEFINITE, array $opts = [] ) {
764 $keygroup = $this->determineKeyGroupForStats( $key );
765
766 $ok = $this->setMainValue(
767 $key,
768 $value,
769 $ttl,
770 $opts['version'] ?? null,
771 $opts['walltime'] ?? null,
772 $opts['lag'] ?? 0,
773 $opts['since'] ?? null,
774 $opts['pending'] ?? false,
775 $opts['lockTSE'] ?? self::TSE_NONE,
776 $opts['staleTTL'] ?? self::STALE_TTL_NONE,
777 $opts['segmentable'] ?? false,
778 $opts['creating'] ?? false
779 );
780
781 $this->stats->increment( "wanobjectcache.$keygroup.set." . ( $ok ? 'ok' : 'error' ) );
782
783 return $ok;
784 }
785
801 private function setMainValue(
802 $key,
803 $value,
804 $ttl,
805 ?int $version,
806 ?float $walltime,
807 $dataReplicaLag,
808 $dataReadSince,
809 bool $dataPendingCommit,
810 int $lockTSE,
811 int $staleTTL,
812 bool $segmentable,
813 bool $creating
814 ) {
815 if ( $ttl < 0 ) {
816 // not cacheable
817 return true;
818 }
819
820 $now = $this->getCurrentTime();
821 $ttl = (int)$ttl;
822 $walltime ??= $this->timeSinceLoggedMiss( $key, $now );
823 $dataSnapshotLag = ( $dataReadSince !== null ) ? max( 0, $now - $dataReadSince ) : 0;
824 $dataCombinedLag = $dataReplicaLag + $dataSnapshotLag;
825
826 // Forbid caching data that only exists within an uncommitted transaction. Also, lower
827 // the TTL when the data has a "since" time so far in the past that a delete() tombstone,
828 // made after that time, could have already expired (the key is no longer write-holed).
829 // The mitigation TTL depends on whether this data lag is assumed to systemically effect
830 // regeneration attempts in the near future. The TTL also reflects regeneration wall time.
831 if ( $dataPendingCommit ) {
832 // Case A: data comes from an uncommitted write transaction
833 $mitigated = 'pending writes';
834 // Data might never be committed; rely on a less problematic regeneration attempt
835 $mitigationTTL = self::TTL_UNCACHEABLE;
836 } elseif ( $dataSnapshotLag > self::MAX_READ_LAG ) {
837 // Case B: high snapshot lag
838 $pregenSnapshotLag = ( $walltime !== null ) ? ( $dataSnapshotLag - $walltime ) : 0;
839 if ( ( $pregenSnapshotLag + self::GENERATION_HIGH_SEC ) > self::MAX_READ_LAG ) {
840 // Case B1: generation started when transaction duration was already long
841 $mitigated = 'snapshot lag (late generation)';
842 // Probably non-systemic; rely on a less problematic regeneration attempt
843 $mitigationTTL = self::TTL_UNCACHEABLE;
844 } else {
845 // Case B2: slow generation made transaction duration long
846 $mitigated = 'snapshot lag (high generation time)';
847 // Probably systemic; use a low TTL to avoid stampedes/uncacheability
848 $mitigationTTL = self::TTL_LAGGED;
849 }
850 } elseif ( $dataReplicaLag === false || $dataReplicaLag > self::MAX_READ_LAG ) {
851 // Case C: low/medium snapshot lag with high replication lag
852 $mitigated = 'replication lag';
853 // Probably systemic; use a low TTL to avoid stampedes/uncacheability
854 $mitigationTTL = self::TTL_LAGGED;
855 } elseif ( $dataCombinedLag > self::MAX_READ_LAG ) {
856 $pregenCombinedLag = ( $walltime !== null ) ? ( $dataCombinedLag - $walltime ) : 0;
857 // Case D: medium snapshot lag with medium replication lag
858 if ( ( $pregenCombinedLag + self::GENERATION_HIGH_SEC ) > self::MAX_READ_LAG ) {
859 // Case D1: generation started when read lag was too high
860 $mitigated = 'read lag (late generation)';
861 // Probably non-systemic; rely on a less problematic regeneration attempt
862 $mitigationTTL = self::TTL_UNCACHEABLE;
863 } else {
864 // Case D2: slow generation made read lag too high
865 $mitigated = 'read lag (high generation time)';
866 // Probably systemic; use a low TTL to avoid stampedes/uncacheability
867 $mitigationTTL = self::TTL_LAGGED;
868 }
869 } else {
870 // Case E: new value generated with recent data
871 $mitigated = null;
872 // Nothing to mitigate
873 $mitigationTTL = null;
874 }
875
876 if ( $mitigationTTL === self::TTL_UNCACHEABLE ) {
877 $this->logger->warning(
878 "Rejected set() for {cachekey} due to $mitigated.",
879 [
880 'cachekey' => $key,
881 'lag' => $dataReplicaLag,
882 'age' => $dataSnapshotLag,
883 'walltime' => $walltime
884 ]
885 );
886
887 // no-op the write for being unsafe
888 return true;
889 }
890
891 // TTL to use in staleness checks (does not effect persistence layer TTL)
892 $logicalTTL = null;
893
894 if ( $mitigationTTL !== null ) {
895 // New value was generated from data that is old enough to be risky
896 if ( $lockTSE >= 0 ) {
897 // Persist the value as long as normal, but make it count as stale sooner
898 $logicalTTL = min( $ttl ?: INF, $mitigationTTL );
899 } else {
900 // Persist the value for a shorter duration
901 $ttl = min( $ttl ?: INF, $mitigationTTL );
902 }
903
904 $this->logger->warning(
905 "Lowered set() TTL for {cachekey} due to $mitigated.",
906 [
907 'cachekey' => $key,
908 'lag' => $dataReplicaLag,
909 'age' => $dataSnapshotLag,
910 'walltime' => $walltime
911 ]
912 );
913 }
914
915 // Wrap that value with time/TTL/version metadata
916 $wrapped = $this->wrap( $value, $logicalTTL ?: $ttl, $version, $now );
917 $storeTTL = $ttl + $staleTTL;
918
919 $flags = $this->cache::WRITE_BACKGROUND;
920 if ( $segmentable ) {
921 $flags |= $this->cache::WRITE_ALLOW_SEGMENTS;
922 }
923
924 if ( $creating ) {
925 $ok = $this->cache->add(
926 $this->makeSisterKey( $key, self::TYPE_VALUE ),
927 $wrapped,
928 $storeTTL,
929 $flags
930 );
931 } else {
932 $ok = $this->cache->merge(
933 $this->makeSisterKey( $key, self::TYPE_VALUE ),
934 static function ( $cache, $key, $cWrapped ) use ( $wrapped ) {
935 // A string value means that it is a tombstone; do nothing in that case
936 return ( is_string( $cWrapped ) ) ? false : $wrapped;
937 },
938 $storeTTL,
939 $this->cache::MAX_CONFLICTS_ONE,
940 $flags
941 );
942 }
943
944 return $ok;
945 }
946
1009 final public function delete( $key, $ttl = self::HOLDOFF_TTL ) {
1010 // Purge values must be stored under the value key so that WANObjectCache::set()
1011 // can atomically merge values without accidentally undoing a recent purge and thus
1012 // violating the holdoff TTL restriction.
1013 $valueSisterKey = $this->makeSisterKey( $key, self::TYPE_VALUE );
1014
1015 if ( $ttl <= 0 ) {
1016 // A client or cache cleanup script is requesting a cache purge, so there is no
1017 // volatility period due to replica DB lag. Any recent change to an entity cached
1018 // in this key should have triggered an appropriate purge event.
1019 $ok = $this->relayNonVolatilePurge( $valueSisterKey );
1020 } else {
1021 // A cacheable entity recently changed, so there might be a volatility period due
1022 // to replica DB lag. Clients usually expect their actions to be reflected in any
1023 // of their subsequent web request. This is attainable if (a) purge relay lag is
1024 // lower than the time it takes for subsequent request by the client to arrive,
1025 // and, (b) DB replica queries have "read-your-writes" consistency due to DB lag
1026 // mitigation systems.
1027 $now = $this->getCurrentTime();
1028 // Set the key to the purge value in all datacenters
1029 $purge = $this->makeTombstonePurgeValue( $now );
1030 $ok = $this->relayVolatilePurge( $valueSisterKey, $purge, $ttl );
1031 }
1032
1033 $keygroup = $this->determineKeyGroupForStats( $key );
1034 $this->stats->increment( "wanobjectcache.$keygroup.delete." . ( $ok ? 'ok' : 'error' ) );
1035
1036 return $ok;
1037 }
1038
1058 final public function getCheckKeyTime( $key ) {
1059 return $this->getMultiCheckKeyTime( [ $key ] )[$key];
1060 }
1061
1123 final public function getMultiCheckKeyTime( array $keys ) {
1124 $checkSisterKeysByKey = [];
1125 foreach ( $keys as $key ) {
1126 $checkSisterKeysByKey[$key] = $this->makeSisterKey( $key, self::TYPE_TIMESTAMP );
1127 }
1128
1129 $wrappedBySisterKey = $this->cache->getMulti( $checkSisterKeysByKey );
1130 $wrappedBySisterKey += array_fill_keys( $checkSisterKeysByKey, false );
1131
1132 $now = $this->getCurrentTime();
1133 $times = [];
1134 foreach ( $checkSisterKeysByKey as $key => $checkSisterKey ) {
1135 $purge = $this->parsePurgeValue( $wrappedBySisterKey[$checkSisterKey] );
1136 if ( $purge === null ) {
1137 $wrapped = $this->makeCheckPurgeValue( $now, self::HOLDOFF_TTL_NONE, $purge );
1138 $this->cache->add(
1139 $checkSisterKey,
1140 $wrapped,
1141 self::CHECK_KEY_TTL,
1142 $this->cache::WRITE_BACKGROUND
1143 );
1144 }
1145
1146 $times[$key] = $purge[self::PURGE_TIME];
1147 }
1148
1149 return $times;
1150 }
1151
1185 final public function touchCheckKey( $key, $holdoff = self::HOLDOFF_TTL ) {
1186 $checkSisterKey = $this->makeSisterKey( $key, self::TYPE_TIMESTAMP );
1187
1188 $now = $this->getCurrentTime();
1189 $purge = $this->makeCheckPurgeValue( $now, $holdoff );
1190 $ok = $this->relayVolatilePurge( $checkSisterKey, $purge, self::CHECK_KEY_TTL );
1191
1192 $keygroup = $this->determineKeyGroupForStats( $key );
1193 $this->stats->increment( "wanobjectcache.$keygroup.ck_touch." . ( $ok ? 'ok' : 'error' ) );
1194
1195 return $ok;
1196 }
1197
1225 final public function resetCheckKey( $key ) {
1226 $checkSisterKey = $this->makeSisterKey( $key, self::TYPE_TIMESTAMP );
1227 $ok = $this->relayNonVolatilePurge( $checkSisterKey );
1228
1229 $keygroup = $this->determineKeyGroupForStats( $key );
1230 $this->stats->increment( "wanobjectcache.$keygroup.ck_reset." . ( $ok ? 'ok' : 'error' ) );
1231
1232 return $ok;
1233 }
1234
1536 final public function getWithSetCallback(
1537 $key, $ttl, $callback, array $opts = [], array $cbParams = []
1538 ) {
1539 $version = $opts['version'] ?? null;
1540 $pcTTL = $opts['pcTTL'] ?? self::TTL_UNCACHEABLE;
1541 $pCache = ( $pcTTL >= 0 )
1542 ? $this->getProcessCache( $opts['pcGroup'] ?? self::PC_PRIMARY )
1543 : null;
1544
1545 // Use the process cache if requested as long as no outer cache callback is running.
1546 // Nested callback process cache use is not lag-safe with regard to HOLDOFF_TTL since
1547 // process cached values are more lagged than persistent ones as they are not purged.
1548 if ( $pCache && $this->callbackDepth == 0 ) {
1549 $cached = $pCache->get( $key, $pcTTL, false );
1550 if ( $cached !== false ) {
1551 $this->logger->debug( "getWithSetCallback($key): process cache hit" );
1552 return $cached;
1553 }
1554 }
1555
1556 [ $value, $valueVersion, $curAsOf ] = $this->fetchOrRegenerate( $key, $ttl, $callback, $opts, $cbParams );
1557 if ( $valueVersion !== $version ) {
1558 // Current value has a different version; use the variant key for this version.
1559 // Regenerate the variant value if it is not newer than the main value at $key
1560 // so that purges to the main key propagate to the variant value.
1561 $this->logger->debug( "getWithSetCallback($key): using variant key" );
1562 [ $value ] = $this->fetchOrRegenerate(
1563 $this->makeGlobalKey( 'WANCache-key-variant', md5( $key ), (string)$version ),
1564 $ttl,
1565 $callback,
1566 [ 'version' => null, 'minAsOf' => $curAsOf ] + $opts,
1567 $cbParams
1568 );
1569 }
1570
1571 // Update the process cache if enabled
1572 if ( $pCache && $value !== false ) {
1573 $pCache->set( $key, $value );
1574 }
1575
1576 return $value;
1577 }
1578
1595 private function fetchOrRegenerate( $key, $ttl, $callback, array $opts, array $cbParams ) {
1596 $checkKeys = $opts['checkKeys'] ?? [];
1597 $graceTTL = $opts['graceTTL'] ?? self::GRACE_TTL_NONE;
1598 $minAsOf = $opts['minAsOf'] ?? self::MIN_TIMESTAMP_NONE;
1599 $hotTTR = $opts['hotTTR'] ?? self::HOT_TTR;
1600 $lowTTL = $opts['lowTTL'] ?? min( self::LOW_TTL, $ttl );
1601 $ageNew = $opts['ageNew'] ?? self::AGE_NEW;
1602 $touchedCb = $opts['touchedCallback'] ?? null;
1603 $startTime = $this->getCurrentTime();
1604
1605 $keygroup = $this->determineKeyGroupForStats( $key );
1606
1607 // Get the current key value and its metadata
1608 $curState = $this->fetchKeys( [ $key ], $checkKeys, $touchedCb )[$key];
1609 $curValue = $curState[self::RES_VALUE];
1610 // Use the cached value if it exists and is not due for synchronous regeneration
1611 if ( $this->isAcceptablyFreshValue( $curState, $graceTTL, $minAsOf ) ) {
1612 if ( !$this->isLotteryRefreshDue( $curState, $lowTTL, $ageNew, $hotTTR, $startTime ) ) {
1613 $this->stats->timing(
1614 "wanobjectcache.$keygroup.hit.good",
1615 1e3 * ( $this->getCurrentTime() - $startTime )
1616 );
1617
1618 return [ $curValue, $curState[self::RES_VERSION], $curState[self::RES_AS_OF] ];
1619 } elseif ( $this->scheduleAsyncRefresh( $key, $ttl, $callback, $opts, $cbParams ) ) {
1620 $this->logger->debug( "fetchOrRegenerate($key): hit with async refresh" );
1621 $this->stats->timing(
1622 "wanobjectcache.$keygroup.hit.refresh",
1623 1e3 * ( $this->getCurrentTime() - $startTime )
1624 );
1625
1626 return [ $curValue, $curState[self::RES_VERSION], $curState[self::RES_AS_OF] ];
1627 } else {
1628 $this->logger->debug( "fetchOrRegenerate($key): hit with sync refresh" );
1629 }
1630 }
1631
1632 $isKeyTombstoned = ( $curState[self::RES_TOMB_AS_OF] !== null );
1633 // Use the interim key as a temporary alternative if the key is tombstoned
1634 if ( $isKeyTombstoned ) {
1635 $volState = $this->getInterimValue( $key, $minAsOf, $startTime, $touchedCb );
1636 $volValue = $volState[self::RES_VALUE];
1637 } else {
1638 $volState = $curState;
1639 $volValue = $curValue;
1640 }
1641
1642 // During the volatile "hold-off" period that follows a purge of the key, the value
1643 // will be regenerated many times if frequently accessed. This is done to mitigate
1644 // the effects of backend replication lag as soon as possible. However, throttle the
1645 // overhead of locking and regeneration by reusing values recently written to cache
1646 // tens of milliseconds ago. Verify the "as of" time against the last purge event.
1647 $lastPurgeTime = max(
1648 // RES_TOUCH_AS_OF depends on the value (possibly from the interim key)
1649 $volState[self::RES_TOUCH_AS_OF],
1650 $curState[self::RES_TOMB_AS_OF],
1651 $curState[self::RES_CHECK_AS_OF]
1652 );
1653 $safeMinAsOf = max( $minAsOf, $lastPurgeTime + self::TINY_POSITIVE );
1654 if ( $this->isExtremelyNewValue( $volState, $safeMinAsOf, $startTime ) ) {
1655 $this->logger->debug( "fetchOrRegenerate($key): volatile hit" );
1656 $this->stats->timing(
1657 "wanobjectcache.$keygroup.hit.volatile",
1658 1e3 * ( $this->getCurrentTime() - $startTime )
1659 );
1660
1661 return [ $volValue, $volState[self::RES_VERSION], $curState[self::RES_AS_OF] ];
1662 }
1663
1664 $lockTSE = $opts['lockTSE'] ?? self::TSE_NONE;
1665 $busyValue = $opts['busyValue'] ?? null;
1666 $staleTTL = $opts['staleTTL'] ?? self::STALE_TTL_NONE;
1667 $segmentable = $opts['segmentable'] ?? false;
1668 $version = $opts['version'] ?? null;
1669
1670 // Determine whether one thread per datacenter should handle regeneration at a time
1671 $useRegenerationLock =
1672 // Note that since tombstones no-op set(), $lockTSE and $curTTL cannot be used to
1673 // deduce the key hotness because |$curTTL| will always keep increasing until the
1674 // tombstone expires or is overwritten by a new tombstone. Also, even if $lockTSE
1675 // is not set, constant regeneration of a key for the tombstone lifetime might be
1676 // very expensive. Assume tombstoned keys are possibly hot in order to reduce
1677 // the risk of high regeneration load after the delete() method is called.
1678 $isKeyTombstoned ||
1679 // Assume a key is hot if requested soon ($lockTSE seconds) after purge.
1680 // This avoids stampedes when timestamps from $checkKeys/$touchedCb bump.
1681 (
1682 $curState[self::RES_CUR_TTL] !== null &&
1683 $curState[self::RES_CUR_TTL] <= 0 &&
1684 abs( $curState[self::RES_CUR_TTL] ) <= $lockTSE
1685 ) ||
1686 // Assume a key is hot if there is no value and a busy fallback is given.
1687 // This avoids stampedes on eviction or preemptive regeneration taking too long.
1688 ( $busyValue !== null && $volValue === false );
1689
1690 // If a regeneration lock is required, threads that do not get the lock will try to use
1691 // the stale value, the interim value, or the $busyValue placeholder, in that order. If
1692 // none of those are set then all threads will bypass the lock and regenerate the value.
1693 $hasLock = $useRegenerationLock && $this->claimStampedeLock( $key );
1694 if ( $useRegenerationLock && !$hasLock ) {
1695 // Determine if there is stale or volatile cached value that is still usable
1696 // @phan-suppress-next-line PhanTypeMismatchArgumentNullable False positive
1697 if ( $this->isValid( $volValue, $volState[self::RES_AS_OF], $minAsOf ) ) {
1698 $this->logger->debug( "fetchOrRegenerate($key): returning stale value" );
1699 $this->stats->timing(
1700 "wanobjectcache.$keygroup.hit.stale",
1701 1e3 * ( $this->getCurrentTime() - $startTime )
1702 );
1703
1704 return [ $volValue, $volState[self::RES_VERSION], $curState[self::RES_AS_OF] ];
1705 } elseif ( $busyValue !== null ) {
1706 $miss = is_infinite( $minAsOf ) ? 'renew' : 'miss';
1707 $this->logger->debug( "fetchOrRegenerate($key): busy $miss" );
1708 $this->stats->timing(
1709 "wanobjectcache.$keygroup.$miss.busy",
1710 1e3 * ( $this->getCurrentTime() - $startTime )
1711 );
1712 $placeholderValue = $this->resolveBusyValue( $busyValue );
1713
1714 return [ $placeholderValue, $version, $curState[self::RES_AS_OF] ];
1715 }
1716 }
1717
1718 // Generate the new value given any prior value with a matching version
1719 $setOpts = [];
1720 $preCallbackTime = $this->getCurrentTime();
1721 ++$this->callbackDepth;
1722 // https://github.com/phan/phan/issues/4419
1723 $value = null;
1724 try {
1725 $value = $callback(
1726 ( $curState[self::RES_VERSION] === $version ) ? $curValue : false,
1727 $ttl,
1728 $setOpts,
1729 ( $curState[self::RES_VERSION] === $version ) ? $curState[self::RES_AS_OF] : null,
1730 $cbParams
1731 );
1732 } finally {
1733 --$this->callbackDepth;
1734 }
1735 $postCallbackTime = $this->getCurrentTime();
1736
1737 // How long it took to fetch, validate, and generate the value
1738 $elapsed = max( $postCallbackTime - $startTime, 0.0 );
1739
1740 // How long it took to generate the value
1741 $walltime = max( $postCallbackTime - $preCallbackTime, 0.0 );
1742 $this->stats->timing( "wanobjectcache.$keygroup.regen_walltime", 1e3 * $walltime );
1743
1744 // Attempt to save the newly generated value if applicable
1745 if (
1746 // Callback yielded a cacheable value
1747 ( $value !== false && $ttl >= 0 ) &&
1748 // Current thread was not raced out of a regeneration lock or key is tombstoned
1749 ( !$useRegenerationLock || $hasLock || $isKeyTombstoned )
1750 ) {
1751 $this->stats->timing( "wanobjectcache.$keygroup.regen_set_delay", 1e3 * $elapsed );
1752 // If the key is write-holed then use the (volatile) interim key as an alternative
1753 if ( $isKeyTombstoned ) {
1754 $this->setInterimValue(
1755 $key,
1756 $value,
1757 $lockTSE,
1758 $version,
1759 $segmentable
1760 );
1761 } else {
1762 $this->setMainValue(
1763 $key,
1764 $value,
1765 $ttl,
1766 $version,
1767 $walltime,
1768 // @phan-suppress-next-line PhanCoalescingAlwaysNull
1769 $setOpts['lag'] ?? 0,
1770 // @phan-suppress-next-line PhanCoalescingAlwaysNull
1771 $setOpts['since'] ?? $preCallbackTime,
1772 // @phan-suppress-next-line PhanCoalescingAlwaysNull
1773 $setOpts['pending'] ?? false,
1774 $lockTSE,
1775 $staleTTL,
1776 $segmentable,
1777 ( $curValue === false )
1778 );
1779 }
1780 }
1781
1782 $this->yieldStampedeLock( $key, $hasLock );
1783
1784 $miss = is_infinite( $minAsOf ) ? 'renew' : 'miss';
1785 $this->logger->debug( "fetchOrRegenerate($key): $miss, new value computed" );
1786 $this->stats->timing(
1787 "wanobjectcache.$keygroup.$miss.compute",
1788 1e3 * ( $this->getCurrentTime() - $startTime )
1789 );
1790
1791 return [ $value, $version, $curState[self::RES_AS_OF] ];
1792 }
1793
1798 private function claimStampedeLock( $key ) {
1799 $checkSisterKey = $this->makeSisterKey( $key, self::TYPE_MUTEX );
1800 // Note that locking is not bypassed due to I/O errors; this avoids stampedes
1801 return $this->cache->add( $checkSisterKey, 1, self::LOCK_TTL );
1802 }
1803
1808 private function yieldStampedeLock( $key, $hasLock ) {
1809 if ( $hasLock ) {
1810 $checkSisterKey = $this->makeSisterKey( $key, self::TYPE_MUTEX );
1811 $this->cache->delete( $checkSisterKey, $this->cache::WRITE_BACKGROUND );
1812 }
1813 }
1814
1825 private function makeSisterKeys( array $baseKeys, string $type, string $route = null ) {
1826 $sisterKeys = [];
1827 foreach ( $baseKeys as $baseKey ) {
1828 $sisterKeys[] = $this->makeSisterKey( $baseKey, $type, $route );
1829 }
1830
1831 return $sisterKeys;
1832 }
1833
1844 private function makeSisterKey( string $baseKey, string $typeChar, string $route = null ) {
1845 if ( $this->coalesceScheme === self::SCHEME_HASH_STOP ) {
1846 // Key style: "WANCache:<base key>|#|<character>"
1847 $sisterKey = 'WANCache:' . $baseKey . '|#|' . $typeChar;
1848 } else {
1849 // Key style: "WANCache:{<base key>}:<character>"
1850 $sisterKey = 'WANCache:{' . $baseKey . '}:' . $typeChar;
1851 }
1852
1853 if ( $route !== null ) {
1854 $sisterKey = $this->prependRoute( $sisterKey, $route );
1855 }
1856
1857 return $sisterKey;
1858 }
1859
1872 private function isExtremelyNewValue( $res, $minAsOf, $now ) {
1873 if ( $res[self::RES_VALUE] === false || $res[self::RES_AS_OF] < $minAsOf ) {
1874 return false;
1875 }
1876
1877 $age = $now - $res[self::RES_AS_OF];
1878
1879 return ( $age < mt_rand( self::RECENT_SET_LOW_MS, self::RECENT_SET_HIGH_MS ) / 1e3 );
1880 }
1881
1891 private function getInterimValue( $key, $minAsOf, $now, $touchedCb ) {
1892 if ( $this->useInterimHoldOffCaching ) {
1893 $interimSisterKey = $this->makeSisterKey( $key, self::TYPE_INTERIM );
1894 $wrapped = $this->cache->get( $interimSisterKey );
1895 $res = $this->unwrap( $wrapped, $now );
1896 if ( $res[self::RES_VALUE] !== false && $res[self::RES_AS_OF] >= $minAsOf ) {
1897 if ( $touchedCb !== null ) {
1898 // Update "last purge time" since the $touchedCb timestamp depends on $value
1899 // Get the new "touched timestamp", accounting for callback-checked dependencies
1900 $res[self::RES_TOUCH_AS_OF] = max(
1901 $touchedCb( $res[self::RES_VALUE] ),
1902 $res[self::RES_TOUCH_AS_OF]
1903 );
1904 }
1905
1906 return $res;
1907 }
1908 }
1909
1910 return $this->unwrap( false, $now );
1911 }
1912
1921 private function setInterimValue(
1922 $key,
1923 $value,
1924 $ttl,
1925 ?int $version,
1926 bool $segmentable
1927 ) {
1928 $now = $this->getCurrentTime();
1929 $ttl = max( self::INTERIM_KEY_TTL, (int)$ttl );
1930
1931 // Wrap that value with time/TTL/version metadata
1932 $wrapped = $this->wrap( $value, $ttl, $version, $now );
1933
1934 $flags = $this->cache::WRITE_BACKGROUND;
1935 if ( $segmentable ) {
1936 $flags |= $this->cache::WRITE_ALLOW_SEGMENTS;
1937 }
1938
1939 return $this->cache->set(
1940 $this->makeSisterKey( $key, self::TYPE_INTERIM ),
1941 $wrapped,
1942 $ttl,
1943 $flags
1944 );
1945 }
1946
1951 private function resolveBusyValue( $busyValue ) {
1952 return ( $busyValue instanceof Closure ) ? $busyValue() : $busyValue;
1953 }
1954
2020 final public function getMultiWithSetCallback(
2021 ArrayIterator $keyedIds, $ttl, callable $callback, array $opts = []
2022 ) {
2023 // Batch load required keys into the in-process warmup cache
2024 $this->warmupCache = $this->fetchWrappedValuesForWarmupCache(
2025 $this->getNonProcessCachedMultiKeys( $keyedIds, $opts ),
2026 $opts['checkKeys'] ?? []
2027 );
2028 $this->warmupKeyMisses = 0;
2029
2030 // The required callback signature includes $id as the first argument for convenience
2031 // to distinguish different items. To reuse the code in getWithSetCallback(), wrap the
2032 // callback with a proxy callback that has the standard getWithSetCallback() signature.
2033 // This is defined only once per batch to avoid closure creation overhead.
2034 $proxyCb = static function ( $oldValue, &$ttl, &$setOpts, $oldAsOf, $params )
2035 use ( $callback )
2036 {
2037 return $callback( $params['id'], $oldValue, $ttl, $setOpts, $oldAsOf );
2038 };
2039
2040 // Get the order-preserved result map using the warm-up cache
2041 $values = [];
2042 foreach ( $keyedIds as $key => $id ) {
2043 $values[$key] = $this->getWithSetCallback(
2044 $key,
2045 $ttl,
2046 $proxyCb,
2047 $opts,
2048 [ 'id' => $id ]
2049 );
2050 }
2051
2052 $this->warmupCache = [];
2053
2054 return $values;
2055 }
2056
2123 final public function getMultiWithUnionSetCallback(
2124 ArrayIterator $keyedIds, $ttl, callable $callback, array $opts = []
2125 ) {
2126 $checkKeys = $opts['checkKeys'] ?? [];
2127 $minAsOf = $opts['minAsOf'] ?? self::MIN_TIMESTAMP_NONE;
2128
2129 // unset incompatible keys
2130 unset( $opts['lockTSE'] );
2131 unset( $opts['busyValue'] );
2132
2133 // Batch load required keys into the in-process warmup cache
2134 $keysByIdGet = $this->getNonProcessCachedMultiKeys( $keyedIds, $opts );
2135 $this->warmupCache = $this->fetchWrappedValuesForWarmupCache( $keysByIdGet, $checkKeys );
2136 $this->warmupKeyMisses = 0;
2137
2138 // IDs of entities known to be in need of generation
2139 $idsRegen = [];
2140
2141 // Find out which keys are missing/deleted/stale
2142 $resByKey = $this->fetchKeys( $keysByIdGet, $checkKeys );
2143 foreach ( $keysByIdGet as $id => $key ) {
2144 $res = $resByKey[$key];
2145 if (
2146 $res[self::RES_VALUE] === false ||
2147 $res[self::RES_CUR_TTL] < 0 ||
2148 $res[self::RES_AS_OF] < $minAsOf
2149 ) {
2150 $idsRegen[] = $id;
2151 }
2152 }
2153
2154 // Run the callback to populate the generation value map for all required IDs
2155 $newSetOpts = [];
2156 $newTTLsById = array_fill_keys( $idsRegen, $ttl );
2157 $newValsById = $idsRegen ? $callback( $idsRegen, $newTTLsById, $newSetOpts ) : [];
2158
2159 $method = __METHOD__;
2160 // The required callback signature includes $id as the first argument for convenience
2161 // to distinguish different items. To reuse the code in getWithSetCallback(), wrap the
2162 // callback with a proxy callback that has the standard getWithSetCallback() signature.
2163 // This is defined only once per batch to avoid closure creation overhead.
2164 $proxyCb = function ( $oldValue, &$ttl, &$setOpts, $oldAsOf, $params )
2165 use ( $callback, $newValsById, $newTTLsById, $newSetOpts, $method )
2166 {
2167 $id = $params['id'];
2168
2169 if ( array_key_exists( $id, $newValsById ) ) {
2170 // Value was already regenerated as expected, so use the value in $newValsById
2171 $newValue = $newValsById[$id];
2172 $ttl = $newTTLsById[$id];
2173 $setOpts = $newSetOpts;
2174 } else {
2175 // Pre-emptive/popularity refresh and version mismatch cases are not detected
2176 // above and thus $newValsById has no entry. Run $callback on this single entity.
2177 $ttls = [ $id => $ttl ];
2178 $result = $callback( [ $id ], $ttls, $setOpts );
2179 if ( !isset( $result[$id] ) ) {
2180 // T303092
2181 $this->logger->warning(
2182 $method . ' failed due to {id} not set in result {result}', [
2183 'id' => $id,
2184 'result' => json_encode( $result )
2185 ] );
2186 }
2187 $newValue = $result[$id];
2188 $ttl = $ttls[$id];
2189 }
2190
2191 return $newValue;
2192 };
2193
2194 // Get the order-preserved result map using the warm-up cache
2195 $values = [];
2196 foreach ( $keyedIds as $key => $id ) {
2197 $values[$key] = $this->getWithSetCallback(
2198 $key,
2199 $ttl,
2200 $proxyCb,
2201 $opts,
2202 [ 'id' => $id ]
2203 );
2204 }
2205
2206 $this->warmupCache = [];
2207
2208 return $values;
2209 }
2210
2218 public function makeGlobalKey( $keygroup, ...$components ) {
2219 // @phan-suppress-next-line PhanParamTooFewUnpack Should infer non-emptiness
2220 return $this->cache->makeGlobalKey( ...func_get_args() );
2221 }
2222
2230 public function makeKey( $keygroup, ...$components ) {
2231 // @phan-suppress-next-line PhanParamTooFewUnpack Should infer non-emptiness
2232 return $this->cache->makeKey( ...func_get_args() );
2233 }
2234
2242 public function hash256( $component ) {
2243 return hash_hmac( 'sha256', $component, $this->secret );
2244 }
2245
2297 final public function makeMultiKeys( array $ids, $keyCallback ) {
2298 $idByKey = [];
2299 foreach ( $ids as $id ) {
2300 // Discourage triggering of automatic makeKey() hashing in some backends
2301 if ( strlen( $id ) > 64 ) {
2302 $this->logger->warning( __METHOD__ . ": long ID '$id'; use hash256()" );
2303 }
2304 $key = $keyCallback( $id, $this );
2305 // Edge case: ignore key collisions due to duplicate $ids like "42" and 42
2306 if ( !isset( $idByKey[$key] ) ) {
2307 $idByKey[$key] = $id;
2308 } elseif ( (string)$id !== (string)$idByKey[$key] ) {
2309 throw new UnexpectedValueException(
2310 "Cache key collision; IDs ('$id','{$idByKey[$key]}') map to '$key'"
2311 );
2312 }
2313 }
2314
2315 return new ArrayIterator( $idByKey );
2316 }
2317
2353 final public function multiRemap( array $ids, array $res ) {
2354 if ( count( $ids ) !== count( $res ) ) {
2355 // If makeMultiKeys() is called on a list of non-unique IDs, then the resulting
2356 // ArrayIterator will have less entries due to "first appearance" de-duplication
2357 $ids = array_keys( array_fill_keys( $ids, true ) );
2358 if ( count( $ids ) !== count( $res ) ) {
2359 throw new UnexpectedValueException( "Multi-key result does not match ID list" );
2360 }
2361 }
2362
2363 return array_combine( $ids, $res );
2364 }
2365
2372 public function watchErrors() {
2373 return $this->cache->watchErrors();
2374 }
2375
2393 final public function getLastError( $watchPoint = 0 ) {
2394 $code = $this->cache->getLastError( $watchPoint );
2395 switch ( $code ) {
2396 case self::ERR_NONE:
2397 return self::ERR_NONE;
2398 case self::ERR_NO_RESPONSE:
2399 return self::ERR_NO_RESPONSE;
2400 case self::ERR_UNREACHABLE:
2401 return self::ERR_UNREACHABLE;
2402 default:
2403 return self::ERR_UNEXPECTED;
2404 }
2405 }
2406
2411 final public function clearLastError() {
2412 $this->cache->clearLastError();
2413 }
2414
2420 public function clearProcessCache() {
2421 $this->processCaches = [];
2422 }
2423
2444 final public function useInterimHoldOffCaching( $enabled ) {
2445 $this->useInterimHoldOffCaching = $enabled;
2446 }
2447
2453 public function getQoS( $flag ) {
2454 return $this->cache->getQoS( $flag );
2455 }
2456
2520 public function adaptiveTTL( $mtime, $maxTTL, $minTTL = 30, $factor = 0.2 ) {
2521 // handle fractional seconds and string integers
2522 $mtime = (int)$mtime;
2523 if ( $mtime <= 0 ) {
2524 // no last-modified time provided
2525 return $minTTL;
2526 }
2527
2528 $age = (int)$this->getCurrentTime() - $mtime;
2529
2530 return (int)min( $maxTTL, max( $minTTL, $factor * $age ) );
2531 }
2532
2538 final public function getWarmupKeyMisses() {
2539 // Number of misses in $this->warmupCache during the last call to certain methods
2540 return $this->warmupKeyMisses;
2541 }
2542
2557 protected function relayVolatilePurge( string $sisterKey, string $purgeValue, int $ttl ) {
2558 if ( $this->broadcastRoute !== null ) {
2559 $routeKey = $this->prependRoute( $sisterKey, $this->broadcastRoute );
2560 } else {
2561 $routeKey = $sisterKey;
2562 }
2563
2564 return $this->cache->set(
2565 $routeKey,
2566 $purgeValue,
2567 $ttl,
2568 $this->cache::WRITE_BACKGROUND
2569 );
2570 }
2571
2580 protected function relayNonVolatilePurge( string $sisterKey ) {
2581 if ( $this->broadcastRoute !== null ) {
2582 $routeKey = $this->prependRoute( $sisterKey, $this->broadcastRoute );
2583 } else {
2584 $routeKey = $sisterKey;
2585 }
2586
2587 return $this->cache->delete( $routeKey, $this->cache::WRITE_BACKGROUND );
2588 }
2589
2595 protected function prependRoute( string $sisterKey, string $route ) {
2596 if ( $sisterKey[0] === '/' ) {
2597 throw new RuntimeException( "Sister key '$sisterKey' already contains a route." );
2598 }
2599
2600 return $route . $sisterKey;
2601 }
2602
2614 private function scheduleAsyncRefresh( $key, $ttl, $callback, array $opts, array $cbParams ) {
2615 if ( !$this->asyncHandler ) {
2616 return false;
2617 }
2618 // Update the cache value later, such during post-send of an HTTP request. This forces
2619 // cache regeneration by setting "minAsOf" to infinity, meaning that no existing value
2620 // is considered valid. Furthermore, note that preemptive regeneration is not applicable
2621 // to invalid values, so there is no risk of infinite preemptive regeneration loops.
2622 $func = $this->asyncHandler;
2623 $func( function () use ( $key, $ttl, $callback, $opts, $cbParams ) {
2624 $opts['minAsOf'] = INF;
2625 try {
2626 $this->fetchOrRegenerate( $key, $ttl, $callback, $opts, $cbParams );
2627 } catch ( Exception $e ) {
2628 // Log some context for easier debugging
2629 $this->logger->error( 'Async refresh failed for {key}', [
2630 'key' => $key,
2631 'ttl' => $ttl,
2632 'exception' => $e
2633 ] );
2634 throw $e;
2635 }
2636 } );
2637
2638 return true;
2639 }
2640
2649 private function isAcceptablyFreshValue( $res, $graceTTL, $minAsOf ) {
2650 if ( !$this->isValid( $res[self::RES_VALUE], $res[self::RES_AS_OF], $minAsOf ) ) {
2651 // Value does not exists or is too old
2652 return false;
2653 }
2654
2655 $curTTL = $res[self::RES_CUR_TTL];
2656 if ( $curTTL > 0 ) {
2657 // Value is definitely still fresh
2658 return true;
2659 }
2660
2661 // Remaining seconds during which this stale value can be used
2662 $curGraceTTL = $graceTTL + $curTTL;
2663
2664 return ( $curGraceTTL > 0 )
2665 // Chance of using the value decreases as $curTTL goes from 0 to -$graceTTL
2666 ? !$this->worthRefreshExpiring( $curGraceTTL, $graceTTL, $graceTTL )
2667 // Value is too stale to fall in the grace period
2668 : false;
2669 }
2670
2681 protected function isLotteryRefreshDue( $res, $lowTTL, $ageNew, $hotTTR, $now ) {
2682 $curTTL = $res[self::RES_CUR_TTL];
2683 $logicalTTL = $res[self::RES_TTL];
2684 $asOf = $res[self::RES_AS_OF];
2685
2686 return (
2687 $this->worthRefreshExpiring( $curTTL, $logicalTTL, $lowTTL ) ||
2688 $this->worthRefreshPopular( $asOf, $ageNew, $hotTTR, $now )
2689 );
2690 }
2691
2707 protected function worthRefreshPopular( $asOf, $ageNew, $timeTillRefresh, $now ) {
2708 if ( $ageNew < 0 || $timeTillRefresh <= 0 ) {
2709 return false;
2710 }
2711
2712 $age = $now - $asOf;
2713 $timeOld = $age - $ageNew;
2714 if ( $timeOld <= 0 ) {
2715 return false;
2716 }
2717
2718 $popularHitsPerSec = 1;
2719 // Lifecycle is: new, ramp-up refresh chance, full refresh chance.
2720 // Note that the "expected # of refreshes" for the ramp-up time range is half
2721 // of what it would be if P(refresh) was at its full value during that time range.
2722 $refreshWindowSec = max( $timeTillRefresh - $ageNew - self::RAMPUP_TTL / 2, 1 );
2723 // P(refresh) * (# hits in $refreshWindowSec) = (expected # of refreshes)
2724 // P(refresh) * ($refreshWindowSec * $popularHitsPerSec) = 1 (by definition)
2725 // P(refresh) = 1/($refreshWindowSec * $popularHitsPerSec)
2726 $chance = 1 / ( $popularHitsPerSec * $refreshWindowSec );
2727 // Ramp up $chance from 0 to its nominal value over RAMPUP_TTL seconds to avoid stampedes
2728 $chance *= ( $timeOld <= self::RAMPUP_TTL ) ? $timeOld / self::RAMPUP_TTL : 1;
2729
2730 return ( mt_rand( 1, 1_000_000_000 ) <= 1_000_000_000 * $chance );
2731 }
2732
2751 protected function worthRefreshExpiring( $curTTL, $logicalTTL, $lowTTL ) {
2752 if ( $lowTTL <= 0 ) {
2753 return false;
2754 }
2755 // T264787: avoid having keys start off with a high chance of being refreshed;
2756 // the point where refreshing becomes possible cannot precede the key lifetime.
2757 $effectiveLowTTL = min( $lowTTL, $logicalTTL ?: INF );
2758
2759 // How long the value was in the "low TTL" phase
2760 $timeOld = $effectiveLowTTL - $curTTL;
2761 if ( $timeOld <= 0 || $timeOld >= $effectiveLowTTL ) {
2762 return false;
2763 }
2764
2765 // Ratio of the low TTL phase that has elapsed (r)
2766 $ttrRatio = $timeOld / $effectiveLowTTL;
2767 // Use p(r) as the monotonically increasing "chance of refresh" function,
2768 // having p(0)=0 and p(1)=1. The value expires at the nominal expiry.
2769 $chance = $ttrRatio ** 4;
2770
2771 return ( mt_rand( 1, 1_000_000_000 ) <= 1_000_000_000 * $chance );
2772 }
2773
2782 protected function isValid( $value, $asOf, $minAsOf ) {
2783 return ( $value !== false && $asOf >= $minAsOf );
2784 }
2785
2793 private function wrap( $value, $ttl, $version, $now ) {
2794 // Returns keys in ascending integer order for PHP7 array packing:
2795 // https://nikic.github.io/2014/12/22/PHPs-new-hashtable-implementation.html
2796 $wrapped = [
2797 self::FLD_FORMAT_VERSION => self::VERSION,
2798 self::FLD_VALUE => $value,
2799 self::FLD_TTL => $ttl,
2800 self::FLD_TIME => $now
2801 ];
2802 if ( $version !== null ) {
2803 $wrapped[self::FLD_VALUE_VERSION] = $version;
2804 }
2805
2806 return $wrapped;
2807 }
2808
2823 private function unwrap( $wrapped, $now ) {
2824 // https://nikic.github.io/2014/12/22/PHPs-new-hashtable-implementation.html
2825 $res = [
2826 // Attributes that only depend on the fetched key value
2827 self::RES_VALUE => false,
2828 self::RES_VERSION => null,
2829 self::RES_AS_OF => null,
2830 self::RES_TTL => null,
2831 self::RES_TOMB_AS_OF => null,
2832 // Attributes that depend on caller-specific "check" keys or "touched callbacks"
2833 self::RES_CHECK_AS_OF => null,
2834 self::RES_TOUCH_AS_OF => null,
2835 self::RES_CUR_TTL => null
2836 ];
2837
2838 if ( is_array( $wrapped ) ) {
2839 // Entry expected to be a cached value; validate it
2840 if (
2841 ( $wrapped[self::FLD_FORMAT_VERSION] ?? null ) === self::VERSION &&
2842 $wrapped[self::FLD_TIME] >= $this->epoch
2843 ) {
2844 if ( $wrapped[self::FLD_TTL] > 0 ) {
2845 // Get the approximate time left on the key
2846 $age = $now - $wrapped[self::FLD_TIME];
2847 $curTTL = max( $wrapped[self::FLD_TTL] - $age, 0.0 );
2848 } else {
2849 // Key had no TTL, so the time left is unbounded
2850 $curTTL = INF;
2851 }
2852 $res[self::RES_VALUE] = $wrapped[self::FLD_VALUE];
2853 $res[self::RES_VERSION] = $wrapped[self::FLD_VALUE_VERSION] ?? null;
2854 $res[self::RES_AS_OF] = $wrapped[self::FLD_TIME];
2855 $res[self::RES_CUR_TTL] = $curTTL;
2856 $res[self::RES_TTL] = $wrapped[self::FLD_TTL];
2857 }
2858 } else {
2859 // Entry expected to be a tombstone; parse it
2860 $purge = $this->parsePurgeValue( $wrapped );
2861 if ( $purge !== null ) {
2862 // Tombstoned keys should always have a negative "current TTL"
2863 $curTTL = min( $purge[self::PURGE_TIME] - $now, self::TINY_NEGATIVE );
2864 $res[self::RES_CUR_TTL] = $curTTL;
2865 $res[self::RES_TOMB_AS_OF] = $purge[self::PURGE_TIME];
2866 }
2867 }
2868
2869 return $res;
2870 }
2871
2877 private function determineKeyGroupForStats( $key ) {
2878 $parts = explode( ':', $key, 3 );
2879 // Fallback in case the key was not made by makeKey.
2880 // Replace dots because they are special in StatsD (T232907)
2881 return strtr( $parts[1] ?? $parts[0], '.', '_' );
2882 }
2883
2892 private function parsePurgeValue( $value ) {
2893 if ( !is_string( $value ) ) {
2894 return null;
2895 }
2896
2897 $segments = explode( ':', $value, 3 );
2898 $prefix = $segments[0];
2899 if ( $prefix !== self::PURGE_VAL_PREFIX ) {
2900 // Not a purge value
2901 return null;
2902 }
2903
2904 $timestamp = (float)$segments[1];
2905 // makeTombstonePurgeValue() doesn't store hold-off TTLs
2906 $holdoff = isset( $segments[2] ) ? (int)$segments[2] : self::HOLDOFF_TTL;
2907
2908 if ( $timestamp < $this->epoch ) {
2909 // Purge value is too old
2910 return null;
2911 }
2912
2913 return [ self::PURGE_TIME => $timestamp, self::PURGE_HOLDOFF => $holdoff ];
2914 }
2915
2920 private function makeTombstonePurgeValue( float $timestamp ) {
2921 return self::PURGE_VAL_PREFIX . ':' . (int)$timestamp;
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 = $this->makeSisterKeys( $keys, self::TYPE_VALUE );
2988 // Get all the "check" keys to fetch...
2989 foreach ( $checkKeys as $i => $checkKeyOrKeyGroup ) {
2990 // Note: avoid array_merge() inside loop in case there are many keys
2991 if ( is_int( $i ) ) {
2992 // Single "check" key that applies to all value keys
2993 $sisterKeys[] = $this->makeSisterKey( $checkKeyOrKeyGroup, self::TYPE_TIMESTAMP );
2994 } else {
2995 // List of "check" keys that apply to a specific value key
2996 foreach ( (array)$checkKeyOrKeyGroup as $checkKey ) {
2997 $sisterKeys[] = $this->makeSisterKey( $checkKey, self::TYPE_TIMESTAMP );
2998 }
2999 }
3000 }
3001
3002 $wrappedBySisterKey = $this->cache->getMulti( $sisterKeys );
3003 $wrappedBySisterKey += array_fill_keys( $sisterKeys, false );
3004
3005 return $wrappedBySisterKey;
3006 }
3007
3013 private function timeSinceLoggedMiss( $key, $now ) {
3014 for ( end( $this->missLog ); $miss = current( $this->missLog ); prev( $this->missLog ) ) {
3015 if ( $miss[0] === $key ) {
3016 return ( $now - $miss[1] );
3017 }
3018 }
3019
3020 return null;
3021 }
3022
3027 protected function getCurrentTime() {
3028 return $this->wallClockOverride ?: microtime( true );
3029 }
3030
3035 public function setMockTime( &$time ) {
3036 $this->wallClockOverride =& $time;
3037 $this->cache->setMockTime( $time );
3038 foreach ( $this->processCaches as $pCache ) {
3039 $pCache->setMockTime( $time );
3040 }
3041 }
3042}
array $params
The job parameters.
Class representing a cache/ephemeral data store.
Definition BagOStuff.php:85
A BagOStuff object with no objects in it.
Store key-value entries in a size-limited in-memory LRU cache.
Multi-datacenter aware caching interface.
makeGlobalKey( $keygroup,... $components)
const HOLDOFF_TTL
Seconds to tombstone keys on delete() and to treat keys as volatile after purges.
const KEY_VERSION
Version number attribute for a key; keep value for b/c (< 1.36)
__construct(array $params)
isValid( $value, $asOf, $minAsOf)
Check that a wrapper value exists and has an acceptable age.
worthRefreshPopular( $asOf, $ageNew, $timeTillRefresh, $now)
Check if a key is due for randomized regeneration due to its popularity.
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...
relayVolatilePurge(string $sisterKey, string $purgeValue, int $ttl)
Set a sister key to a purge value in all datacenters.
prependRoute(string $sisterKey, string $route)
touchCheckKey( $key, $holdoff=self::HOLDOFF_TTL)
Increase the last-purge timestamp of a "check" key in all datacenters.
adaptiveTTL( $mtime, $maxTTL, $minTTL=30, $factor=0.2)
Get a TTL that is higher for objects that have not changed recently.
const GRACE_TTL_NONE
Idiom for set()/getWithSetCallback() meaning "no post-expiration grace period".
BagOStuff $cache
The local datacenter cache.
makeKey( $keygroup,... $components)
fetchKeys(array $keys, array $checkKeys, $touchedCb=null)
Fetch the value and key metadata of several keys from cache.
getWithSetCallback( $key, $ttl, $callback, array $opts=[], array $cbParams=[])
Method to fetch/regenerate a cache key.
getCheckKeyTime( $key)
Fetch the value of a timestamp "check" key.
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.
LoggerInterface $logger
relayNonVolatilePurge(string $sisterKey)
Remove a sister key from all datacenters.
getMultiWithSetCallback(ArrayIterator $keyedIds, $ttl, callable $callback, array $opts=[])
Method to fetch multiple cache keys at once with regeneration.
makeMultiKeys(array $ids, $keyCallback)
Get an iterator of (cache key => entity ID) for a list of entity IDs.
static newEmpty()
Get an instance that wraps EmptyBagOStuff.
int $coalesceScheme
Scheme to use for key coalescing (Hash Tags or Hash Stops)
isLotteryRefreshDue( $res, $lowTTL, $ageNew, $hotTTR, $now)
Check if a key is due for randomized regeneration due to near-expiration/popularity.
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.
worthRefreshExpiring( $curTTL, $logicalTTL, $lowTTL)
Check if a key is nearing expiration and thus due for randomized regeneration.
const HOLDOFF_TTL_NONE
Idiom for delete()/touchCheckKey() meaning "no hold-off period".
useInterimHoldOffCaching( $enabled)
Enable or disable the use of brief caching for tombstoned keys.
StatsdDataFactoryInterface $stats
clearProcessCache()
Clear the in-process caches; useful for testing.
const KEY_AS_OF
Generation completion timestamp attribute for a key; keep value for b/c (< 1.36)
getLastError( $watchPoint=0)
Get the "last error" registry.
float $epoch
Unix timestamp of the oldest possible valid values.
callable null $asyncHandler
Function that takes a WAN cache callback and runs it later.
string null $broadcastRoute
Routing prefix for operations that should be broadcasted to all data centers.
setLogger(LoggerInterface $logger)
const PASS_BY_REF
Idiom for get()/getMulti() to return extra information by reference.
const KEY_CHECK_AS_OF
Highest "check" key timestamp for a key; keep value for b/c (< 1.36)
clearLastError()
Clear the "last error" registry.
const STALE_TTL_NONE
Idiom for set()/getWithSetCallback() meaning "no post-expiration persistence".
MapCacheLRU[] $processCaches
Map of group PHP instance caches.
string $secret
Stable secret used for hashing long strings into key components.
resetCheckKey( $key)
Clear the last-purge timestamp of a "check" key in all datacenters.
const KEY_TOMB_AS_OF
Tomstone timestamp attribute for a key; keep value for b/c (< 1.36)
const KEY_CUR_TTL
Remaining TTL attribute for a key; keep value for b/c (< 1.36)
const TTL_LAGGED
Max TTL, in seconds, to store keys when a data source has high replication lag.
hash256( $component)
Hash a possibly long string into a suitable component for makeKey()/makeGlobalKey()
getMultiCheckKeyTime(array $keys)
Fetch the values of each timestamp "check" key.
const KEY_TTL
Logical TTL attribute for a key.
Key-encoding methods for object caching (BagOStuff and WANObjectCache)
Generic interface providing Time-To-Live constants for expirable object storage.
Generic interface providing error code and quality-of-service constants for object stores.