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;
31
128class WANObjectCache implements
132 LoggerAwareInterface
133{
135 protected $cache;
137 protected $processCaches = [];
139 protected $logger;
141 protected $stats;
143 protected $asyncHandler;
144
156 protected $epoch;
158 protected $secret;
161
163 private $missLog;
164
166 private $callbackDepth = 0;
168 private $warmupCache = [];
170 private $warmupKeyMisses = 0;
171
173 private $wallClockOverride;
174
176 private const MAX_COMMIT_DELAY = 3;
178 private const MAX_READ_LAG = 7;
180 public const HOLDOFF_TTL = self::MAX_COMMIT_DELAY + self::MAX_READ_LAG + 1;
181
183 private const LOW_TTL = 60;
185 public const TTL_LAGGED = 30;
186
188 private const HOT_TTR = 900;
190 private const AGE_NEW = 60;
191
193 private const TSE_NONE = -1;
194
196 public const STALE_TTL_NONE = 0;
198 public const GRACE_TTL_NONE = 0;
200 public const HOLDOFF_TTL_NONE = 0;
201
203 public const MIN_TIMESTAMP_NONE = 0.0;
204
206 private const PC_PRIMARY = 'primary:1000';
207
209 public const PASS_BY_REF = [];
210
212 private const SCHEME_HASH_TAG = 1;
214 private const SCHEME_HASH_STOP = 2;
215
217 private const CHECK_KEY_TTL = self::TTL_YEAR;
219 private const INTERIM_KEY_TTL = 2;
220
222 private const LOCK_TTL = 10;
224 private const RAMPUP_TTL = 30;
225
227 private const TINY_NEGATIVE = -0.000001;
229 private const TINY_POSITIVE = 0.000001;
230
232 private const RECENT_SET_LOW_MS = 50;
234 private const RECENT_SET_HIGH_MS = 100;
235
237 private const GENERATION_HIGH_SEC = 0.2;
238
240 private const PURGE_TIME = 0;
242 private const PURGE_HOLDOFF = 1;
243
245 private const VERSION = 1;
246
248 public const KEY_VERSION = 'version';
250 public const KEY_AS_OF = 'asOf';
252 public const KEY_TTL = 'ttl';
254 public const KEY_CUR_TTL = 'curTTL';
256 public const KEY_TOMB_AS_OF = 'tombAsOf';
258 public const KEY_CHECK_AS_OF = 'lastCKPurge';
259
261 private const RES_VALUE = 0;
263 private const RES_VERSION = 1;
265 private const RES_AS_OF = 2;
267 private const RES_TTL = 3;
269 private const RES_TOMB_AS_OF = 4;
271 private const RES_CHECK_AS_OF = 5;
273 private const RES_TOUCH_AS_OF = 6;
275 private const RES_CUR_TTL = 7;
276
278 private const FLD_FORMAT_VERSION = 0;
280 private const FLD_VALUE = 1;
282 private const FLD_TTL = 2;
284 private const FLD_TIME = 3;
286 private const FLD_FLAGS = 4;
288 private const FLD_VALUE_VERSION = 5;
289 private const FLD_GENERATION_TIME = 6;
290
292 private const TYPE_VALUE = 'v';
294 private const TYPE_TIMESTAMP = 't';
296 private const TYPE_MUTEX = 'm';
298 private const TYPE_INTERIM = 'i';
299
301 private const PURGE_VAL_PREFIX = 'PURGED';
302
329 public function __construct( array $params ) {
330 $this->cache = $params['cache'];
331 $this->broadcastRoute = $params['broadcastRoutingPrefix'] ?? null;
332 $this->epoch = $params['epoch'] ?? 0;
333 $this->secret = $params['secret'] ?? (string)$this->epoch;
334 if ( ( $params['coalesceScheme'] ?? '' ) === 'hash_tag' ) {
335 // https://redis.io/topics/cluster-spec
336 // https://github.com/twitter/twemproxy/blob/v0.4.1/notes/recommendation.md#hash-tags
337 // https://github.com/Netflix/dynomite/blob/v0.7.0/notes/recommendation.md#hash-tags
338 $this->coalesceScheme = self::SCHEME_HASH_TAG;
339 } else {
340 // https://github.com/facebook/mcrouter/wiki/Key-syntax
341 $this->coalesceScheme = self::SCHEME_HASH_STOP;
342 }
343
344 $this->setLogger( $params['logger'] ?? new NullLogger() );
345 $this->stats = $params['stats'] ?? new NullStatsdDataFactory();
346 $this->asyncHandler = $params['asyncHandler'] ?? null;
347
348 $this->missLog = array_fill( 0, 10, [ '', 0.0 ] );
349 }
350
354 public function setLogger( LoggerInterface $logger ) {
355 $this->logger = $logger;
356 }
357
363 public static function newEmpty() {
364 return new static( [ 'cache' => new EmptyBagOStuff() ] );
365 }
366
422 final public function get( $key, &$curTTL = null, array $checkKeys = [], &$info = [] ) {
423 // Note that an undeclared variable passed as $info starts as null (not the default).
424 // Also, if no $info parameter is provided, then it doesn't matter how it changes here.
425 $legacyInfo = ( $info !== self::PASS_BY_REF );
426
427 $now = $this->getCurrentTime();
428 $res = $this->fetchKeys( [ $key ], $checkKeys, $now )[$key];
429
430 $curTTL = $res[self::RES_CUR_TTL];
431 $info = $legacyInfo
432 ? $res[self::RES_AS_OF]
433 : [
434 self::KEY_VERSION => $res[self::RES_VERSION],
435 self::KEY_AS_OF => $res[self::RES_AS_OF],
436 self::KEY_TTL => $res[self::RES_TTL],
437 self::KEY_CUR_TTL => $res[self::RES_CUR_TTL],
438 self::KEY_TOMB_AS_OF => $res[self::RES_TOMB_AS_OF],
439 self::KEY_CHECK_AS_OF => $res[self::RES_CHECK_AS_OF]
440 ];
441
442 if ( $curTTL === null || $curTTL <= 0 ) {
443 // Log the timestamp in case a corresponding set() call does not provide "walltime"
444 unset( $this->missLog[array_key_first( $this->missLog )] );
445 $this->missLog[] = [ $key, $this->getCurrentTime() ];
446 }
447
448 return $res[self::RES_VALUE];
449 }
450
475 final public function getMulti(
476 array $keys,
477 &$curTTLs = [],
478 array $checkKeys = [],
479 &$info = []
480 ) {
481 // Note that an undeclared variable passed as $info starts as null (not the default).
482 // Also, if no $info parameter is provided, then it doesn't matter how it changes here.
483 $legacyInfo = ( $info !== self::PASS_BY_REF );
484
485 $curTTLs = [];
486 $info = [];
487 $valuesByKey = [];
488
489 $now = $this->getCurrentTime();
490 $resByKey = $this->fetchKeys( $keys, $checkKeys, $now );
491 foreach ( $resByKey as $key => $res ) {
492 if ( $res[self::RES_VALUE] !== false ) {
493 $valuesByKey[$key] = $res[self::RES_VALUE];
494 }
495
496 if ( $res[self::RES_CUR_TTL] !== null ) {
497 $curTTLs[$key] = $res[self::RES_CUR_TTL];
498 }
499 $info[$key] = $legacyInfo
500 ? $res[self::RES_AS_OF]
501 : [
502 self::KEY_VERSION => $res[self::RES_VERSION],
503 self::KEY_AS_OF => $res[self::RES_AS_OF],
504 self::KEY_TTL => $res[self::RES_TTL],
505 self::KEY_CUR_TTL => $res[self::RES_CUR_TTL],
506 self::KEY_TOMB_AS_OF => $res[self::RES_TOMB_AS_OF],
507 self::KEY_CHECK_AS_OF => $res[self::RES_CHECK_AS_OF]
508 ];
509 }
510
511 return $valuesByKey;
512 }
513
529 protected function fetchKeys( array $keys, array $checkKeys, float $now, $touchedCb = null ) {
530 $resByKey = [];
531
532 // List of all sister keys that need to be fetched from cache
533 $allSisterKeys = [];
534 // Order-corresponding value sister key list for the base key list ($keys)
535 $valueSisterKeys = [];
536 // List of "check" sister keys to compare all value sister keys against
537 $checkSisterKeysForAll = [];
538 // Map of (base key => additional "check" sister key(s) to compare against)
539 $checkSisterKeysByKey = [];
540
541 foreach ( $keys as $key ) {
542 $sisterKey = $this->makeSisterKey( $key, self::TYPE_VALUE );
543 $allSisterKeys[] = $sisterKey;
544 $valueSisterKeys[] = $sisterKey;
545 }
546
547 foreach ( $checkKeys as $i => $checkKeyOrKeyGroup ) {
548 // Note: avoid array_merge() inside loop in case there are many keys
549 if ( is_int( $i ) ) {
550 // Single "check" key that applies to all base keys
551 $sisterKey = $this->makeSisterKey( $checkKeyOrKeyGroup, self::TYPE_TIMESTAMP );
552 $allSisterKeys[] = $sisterKey;
553 $checkSisterKeysForAll[] = $sisterKey;
554 } else {
555 // List of "check" keys that apply to a specific base key
556 foreach ( (array)$checkKeyOrKeyGroup as $checkKey ) {
557 $sisterKey = $this->makeSisterKey( $checkKey, self::TYPE_TIMESTAMP );
558 $allSisterKeys[] = $sisterKey;
559 $checkSisterKeysByKey[$i][] = $sisterKey;
560 }
561 }
562 }
563
564 if ( $this->warmupCache ) {
565 // Get the wrapped values of the sister keys from the warmup cache
566 $wrappedBySisterKey = $this->warmupCache;
567 $sisterKeysMissing = array_diff( $allSisterKeys, array_keys( $wrappedBySisterKey ) );
568 if ( $sisterKeysMissing ) {
569 $this->warmupKeyMisses += count( $sisterKeysMissing );
570 $wrappedBySisterKey += $this->cache->getMulti( $sisterKeysMissing );
571 }
572 } else {
573 // Fetch the wrapped values of the sister keys from the backend
574 $wrappedBySisterKey = $this->cache->getMulti( $allSisterKeys );
575 }
576
577 // List of "check" sister key purge timestamps to compare all value sister keys against
578 $ckPurgesForAll = $this->processCheckKeys(
579 $checkSisterKeysForAll,
580 $wrappedBySisterKey,
581 $now
582 );
583 // Map of (base key => extra "check" sister key purge timestamp(s) to compare against)
584 $ckPurgesByKey = [];
585 foreach ( $checkSisterKeysByKey as $keyWithCheckKeys => $checkKeysForKey ) {
586 $ckPurgesByKey[$keyWithCheckKeys] = $this->processCheckKeys(
587 $checkKeysForKey,
588 $wrappedBySisterKey,
589 $now
590 );
591 }
592
593 // Unwrap and validate any value found for each base key (under the value sister key)
594 reset( $keys );
595 foreach ( $valueSisterKeys as $valueSisterKey ) {
596 // Get the corresponding base key for this value sister key
597 $key = current( $keys );
598 next( $keys );
599
600 if ( array_key_exists( $valueSisterKey, $wrappedBySisterKey ) ) {
601 // Key exists as either a live value or tombstone value
602 $wrapped = $wrappedBySisterKey[$valueSisterKey];
603 } else {
604 // Key does not exist
605 $wrapped = false;
606 }
607
608 $res = $this->unwrap( $wrapped, $now );
609 $value = $res[self::RES_VALUE];
610
611 foreach ( array_merge( $ckPurgesForAll, $ckPurgesByKey[$key] ?? [] ) as $ckPurge ) {
612 $res[self::RES_CHECK_AS_OF] = max(
613 $ckPurge[self::PURGE_TIME],
614 $res[self::RES_CHECK_AS_OF]
615 );
616 // Timestamp marking the end of the hold-off period for this purge
617 $holdoffDeadline = $ckPurge[self::PURGE_TIME] + $ckPurge[self::PURGE_HOLDOFF];
618 // Check if the value was generated during the hold-off period
619 if ( $value !== false && $holdoffDeadline >= $res[self::RES_AS_OF] ) {
620 // How long ago this value was purged by *this* "check" key
621 $ago = min( $ckPurge[self::PURGE_TIME] - $now, self::TINY_NEGATIVE );
622 // How long ago this value was purged by *any* known "check" key
623 $res[self::RES_CUR_TTL] = min( $res[self::RES_CUR_TTL], $ago );
624 }
625 }
626
627 if ( $touchedCb !== null && $value !== false ) {
628 $touched = $touchedCb( $value );
629 if ( $touched !== null && $touched >= $res[self::RES_AS_OF] ) {
630 $res[self::RES_CUR_TTL] = min(
631 $res[self::RES_CUR_TTL],
632 $res[self::RES_AS_OF] - $touched,
633 self::TINY_NEGATIVE
634 );
635 }
636 } else {
637 $touched = null;
638 }
639
640 $res[self::RES_TOUCH_AS_OF] = max( $res[self::RES_TOUCH_AS_OF], $touched );
641
642 $resByKey[$key] = $res;
643 }
644
645 return $resByKey;
646 }
647
654 private function processCheckKeys(
655 array $checkSisterKeys,
656 array $wrappedBySisterKey,
657 float $now
658 ) {
659 $purges = [];
660
661 foreach ( $checkSisterKeys as $timeKey ) {
662 $purge = isset( $wrappedBySisterKey[$timeKey] )
663 ? $this->parsePurgeValue( $wrappedBySisterKey[$timeKey] )
664 : null;
665
666 if ( $purge === null ) {
667 // No holdoff when lazy creating a check key, use cache right away (T344191)
668 $wrapped = $this->makeCheckPurgeValue( $now, self::HOLDOFF_TTL_NONE, $purge );
669 $this->cache->add(
670 $timeKey,
671 $wrapped,
672 self::CHECK_KEY_TTL,
673 $this->cache::WRITE_BACKGROUND
674 );
675 }
676
677 $purges[] = $purge;
678 }
679
680 return $purges;
681 }
682
766 final public function set( $key, $value, $ttl = self::TTL_INDEFINITE, array $opts = [] ) {
767 $keygroup = $this->determineKeyGroupForStats( $key );
768
769 $ok = $this->setMainValue(
770 $key,
771 $value,
772 $ttl,
773 $opts['version'] ?? null,
774 $opts['walltime'] ?? null,
775 $opts['lag'] ?? 0,
776 $opts['since'] ?? null,
777 $opts['pending'] ?? false,
778 $opts['lockTSE'] ?? self::TSE_NONE,
779 $opts['staleTTL'] ?? self::STALE_TTL_NONE,
780 $opts['segmentable'] ?? false,
781 $opts['creating'] ?? false
782 );
783
784 $this->stats->increment( "wanobjectcache.$keygroup.set." . ( $ok ? 'ok' : 'error' ) );
785
786 return $ok;
787 }
788
804 private function setMainValue(
805 $key,
806 $value,
807 $ttl,
808 ?int $version,
809 ?float $walltime,
810 $dataReplicaLag,
811 $dataReadSince,
812 bool $dataPendingCommit,
813 int $lockTSE,
814 int $staleTTL,
815 bool $segmentable,
816 bool $creating
817 ) {
818 if ( $ttl < 0 ) {
819 // not cacheable
820 return true;
821 }
822
823 $now = $this->getCurrentTime();
824 $ttl = (int)$ttl;
825 $walltime ??= $this->timeSinceLoggedMiss( $key, $now );
826 $dataSnapshotLag = ( $dataReadSince !== null ) ? max( 0, $now - $dataReadSince ) : 0;
827 $dataCombinedLag = $dataReplicaLag + $dataSnapshotLag;
828
829 // Forbid caching data that only exists within an uncommitted transaction. Also, lower
830 // the TTL when the data has a "since" time so far in the past that a delete() tombstone,
831 // made after that time, could have already expired (the key is no longer write-holed).
832 // The mitigation TTL depends on whether this data lag is assumed to systemically effect
833 // regeneration attempts in the near future. The TTL also reflects regeneration wall time.
834 if ( $dataPendingCommit ) {
835 // Case A: data comes from an uncommitted write transaction
836 $mitigated = 'pending writes';
837 // Data might never be committed; rely on a less problematic regeneration attempt
838 $mitigationTTL = self::TTL_UNCACHEABLE;
839 } elseif ( $dataSnapshotLag > self::MAX_READ_LAG ) {
840 // Case B: high snapshot lag
841 $pregenSnapshotLag = ( $walltime !== null ) ? ( $dataSnapshotLag - $walltime ) : 0;
842 if ( ( $pregenSnapshotLag + self::GENERATION_HIGH_SEC ) > self::MAX_READ_LAG ) {
843 // Case B1: generation started when transaction duration was already long
844 $mitigated = 'snapshot lag (late generation)';
845 // Probably non-systemic; rely on a less problematic regeneration attempt
846 $mitigationTTL = self::TTL_UNCACHEABLE;
847 } else {
848 // Case B2: slow generation made transaction duration long
849 $mitigated = 'snapshot lag (high generation time)';
850 // Probably systemic; use a low TTL to avoid stampedes/uncacheability
851 $mitigationTTL = self::TTL_LAGGED;
852 }
853 } elseif ( $dataReplicaLag === false || $dataReplicaLag > self::MAX_READ_LAG ) {
854 // Case C: low/medium snapshot lag with high replication lag
855 $mitigated = 'replication lag';
856 // Probably systemic; use a low TTL to avoid stampedes/uncacheability
857 $mitigationTTL = self::TTL_LAGGED;
858 } elseif ( $dataCombinedLag > self::MAX_READ_LAG ) {
859 $pregenCombinedLag = ( $walltime !== null ) ? ( $dataCombinedLag - $walltime ) : 0;
860 // Case D: medium snapshot lag with medium replication lag
861 if ( ( $pregenCombinedLag + self::GENERATION_HIGH_SEC ) > self::MAX_READ_LAG ) {
862 // Case D1: generation started when read lag was too high
863 $mitigated = 'read lag (late generation)';
864 // Probably non-systemic; rely on a less problematic regeneration attempt
865 $mitigationTTL = self::TTL_UNCACHEABLE;
866 } else {
867 // Case D2: slow generation made read lag too high
868 $mitigated = 'read lag (high generation time)';
869 // Probably systemic; use a low TTL to avoid stampedes/uncacheability
870 $mitigationTTL = self::TTL_LAGGED;
871 }
872 } else {
873 // Case E: new value generated with recent data
874 $mitigated = null;
875 // Nothing to mitigate
876 $mitigationTTL = null;
877 }
878
879 if ( $mitigationTTL === self::TTL_UNCACHEABLE ) {
880 $this->logger->warning(
881 "Rejected set() for {cachekey} due to $mitigated.",
882 [
883 'cachekey' => $key,
884 'lag' => $dataReplicaLag,
885 'age' => $dataSnapshotLag,
886 'walltime' => $walltime
887 ]
888 );
889
890 // no-op the write for being unsafe
891 return true;
892 }
893
894 // TTL to use in staleness checks (does not effect persistence layer TTL)
895 $logicalTTL = null;
896
897 if ( $mitigationTTL !== null ) {
898 // New value was generated from data that is old enough to be risky
899 if ( $lockTSE >= 0 ) {
900 // Persist the value as long as normal, but make it count as stale sooner
901 $logicalTTL = min( $ttl ?: INF, $mitigationTTL );
902 } else {
903 // Persist the value for a shorter duration
904 $ttl = min( $ttl ?: INF, $mitigationTTL );
905 }
906
907 $this->logger->warning(
908 "Lowered set() TTL for {cachekey} due to $mitigated.",
909 [
910 'cachekey' => $key,
911 'lag' => $dataReplicaLag,
912 'age' => $dataSnapshotLag,
913 'walltime' => $walltime
914 ]
915 );
916 }
917
918 // Wrap that value with time/TTL/version metadata
919 $wrapped = $this->wrap( $value, $logicalTTL ?: $ttl, $version, $now );
920 $storeTTL = $ttl + $staleTTL;
921
922 $flags = $this->cache::WRITE_BACKGROUND;
923 if ( $segmentable ) {
924 $flags |= $this->cache::WRITE_ALLOW_SEGMENTS;
925 }
926
927 if ( $creating ) {
928 $ok = $this->cache->add(
929 $this->makeSisterKey( $key, self::TYPE_VALUE ),
930 $wrapped,
931 $storeTTL,
932 $flags
933 );
934 } else {
935 $ok = $this->cache->merge(
936 $this->makeSisterKey( $key, self::TYPE_VALUE ),
937 static function ( $cache, $key, $cWrapped ) use ( $wrapped ) {
938 // A string value means that it is a tombstone; do nothing in that case
939 return ( is_string( $cWrapped ) ) ? false : $wrapped;
940 },
941 $storeTTL,
942 $this->cache::MAX_CONFLICTS_ONE,
943 $flags
944 );
945 }
946
947 return $ok;
948 }
949
1012 final public function delete( $key, $ttl = self::HOLDOFF_TTL ) {
1013 // Purge values must be stored under the value key so that WANObjectCache::set()
1014 // can atomically merge values without accidentally undoing a recent purge and thus
1015 // violating the holdoff TTL restriction.
1016 $valueSisterKey = $this->makeSisterKey( $key, self::TYPE_VALUE );
1017
1018 if ( $ttl <= 0 ) {
1019 // A client or cache cleanup script is requesting a cache purge, so there is no
1020 // volatility period due to replica DB lag. Any recent change to an entity cached
1021 // in this key should have triggered an appropriate purge event.
1022 $ok = $this->relayNonVolatilePurge( $valueSisterKey );
1023 } else {
1024 // A cacheable entity recently changed, so there might be a volatility period due
1025 // to replica DB lag. Clients usually expect their actions to be reflected in any
1026 // of their subsequent web request. This is attainable if (a) purge relay lag is
1027 // lower than the time it takes for subsequent request by the client to arrive,
1028 // and, (b) DB replica queries have "read-your-writes" consistency due to DB lag
1029 // mitigation systems.
1030 $now = $this->getCurrentTime();
1031 // Set the key to the purge value in all datacenters
1032 $purge = $this->makeTombstonePurgeValue( $now );
1033 $ok = $this->relayVolatilePurge( $valueSisterKey, $purge, $ttl );
1034 }
1035
1036 $keygroup = $this->determineKeyGroupForStats( $key );
1037 $this->stats->increment( "wanobjectcache.$keygroup.delete." . ( $ok ? 'ok' : 'error' ) );
1038
1039 return $ok;
1040 }
1041
1061 final public function getCheckKeyTime( $key ) {
1062 return $this->getMultiCheckKeyTime( [ $key ] )[$key];
1063 }
1064
1126 final public function getMultiCheckKeyTime( array $keys ) {
1127 $checkSisterKeysByKey = [];
1128 foreach ( $keys as $key ) {
1129 $checkSisterKeysByKey[$key] = $this->makeSisterKey( $key, self::TYPE_TIMESTAMP );
1130 }
1131
1132 $wrappedBySisterKey = $this->cache->getMulti( $checkSisterKeysByKey );
1133 $wrappedBySisterKey += array_fill_keys( $checkSisterKeysByKey, false );
1134
1135 $now = $this->getCurrentTime();
1136 $times = [];
1137 foreach ( $checkSisterKeysByKey as $key => $checkSisterKey ) {
1138 $purge = $this->parsePurgeValue( $wrappedBySisterKey[$checkSisterKey] );
1139 if ( $purge === null ) {
1140 $wrapped = $this->makeCheckPurgeValue( $now, self::HOLDOFF_TTL_NONE, $purge );
1141 $this->cache->add(
1142 $checkSisterKey,
1143 $wrapped,
1144 self::CHECK_KEY_TTL,
1145 $this->cache::WRITE_BACKGROUND
1146 );
1147 }
1148
1149 $times[$key] = $purge[self::PURGE_TIME];
1150 }
1151
1152 return $times;
1153 }
1154
1188 final public function touchCheckKey( $key, $holdoff = self::HOLDOFF_TTL ) {
1189 $checkSisterKey = $this->makeSisterKey( $key, self::TYPE_TIMESTAMP );
1190
1191 $now = $this->getCurrentTime();
1192 $purge = $this->makeCheckPurgeValue( $now, $holdoff );
1193 $ok = $this->relayVolatilePurge( $checkSisterKey, $purge, self::CHECK_KEY_TTL );
1194
1195 $keygroup = $this->determineKeyGroupForStats( $key );
1196 $this->stats->increment( "wanobjectcache.$keygroup.ck_touch." . ( $ok ? 'ok' : 'error' ) );
1197
1198 return $ok;
1199 }
1200
1228 final public function resetCheckKey( $key ) {
1229 $checkSisterKey = $this->makeSisterKey( $key, self::TYPE_TIMESTAMP );
1230 $ok = $this->relayNonVolatilePurge( $checkSisterKey );
1231
1232 $keygroup = $this->determineKeyGroupForStats( $key );
1233 $this->stats->increment( "wanobjectcache.$keygroup.ck_reset." . ( $ok ? 'ok' : 'error' ) );
1234
1235 return $ok;
1236 }
1237
1539 final public function getWithSetCallback(
1540 $key, $ttl, $callback, array $opts = [], array $cbParams = []
1541 ) {
1542 $version = $opts['version'] ?? null;
1543 $pcTTL = $opts['pcTTL'] ?? self::TTL_UNCACHEABLE;
1544 $pCache = ( $pcTTL >= 0 )
1545 ? $this->getProcessCache( $opts['pcGroup'] ?? self::PC_PRIMARY )
1546 : null;
1547
1548 // Use the process cache if requested as long as no outer cache callback is running.
1549 // Nested callback process cache use is not lag-safe with regard to HOLDOFF_TTL since
1550 // process cached values are more lagged than persistent ones as they are not purged.
1551 if ( $pCache && $this->callbackDepth == 0 ) {
1552 $cached = $pCache->get( $key, $pcTTL, false );
1553 if ( $cached !== false ) {
1554 $this->logger->debug( "getWithSetCallback($key): process cache hit" );
1555 return $cached;
1556 }
1557 }
1558
1559 [ $value, $valueVersion, $curAsOf ] = $this->fetchOrRegenerate( $key, $ttl, $callback, $opts, $cbParams );
1560 if ( $valueVersion !== $version ) {
1561 // Current value has a different version; use the variant key for this version.
1562 // Regenerate the variant value if it is not newer than the main value at $key
1563 // so that purges to the main key propagate to the variant value.
1564 $this->logger->debug( "getWithSetCallback($key): using variant key" );
1565 [ $value ] = $this->fetchOrRegenerate(
1566 $this->makeGlobalKey( 'WANCache-key-variant', md5( $key ), (string)$version ),
1567 $ttl,
1568 $callback,
1569 [ 'version' => null, 'minAsOf' => $curAsOf ] + $opts,
1570 $cbParams
1571 );
1572 }
1573
1574 // Update the process cache if enabled
1575 if ( $pCache && $value !== false ) {
1576 $pCache->set( $key, $value );
1577 }
1578
1579 return $value;
1580 }
1581
1598 private function fetchOrRegenerate( $key, $ttl, $callback, array $opts, array $cbParams ) {
1599 $checkKeys = $opts['checkKeys'] ?? [];
1600 $graceTTL = $opts['graceTTL'] ?? self::GRACE_TTL_NONE;
1601 $minAsOf = $opts['minAsOf'] ?? self::MIN_TIMESTAMP_NONE;
1602 $hotTTR = $opts['hotTTR'] ?? self::HOT_TTR;
1603 $lowTTL = $opts['lowTTL'] ?? min( self::LOW_TTL, $ttl );
1604 $ageNew = $opts['ageNew'] ?? self::AGE_NEW;
1605 $touchedCb = $opts['touchedCallback'] ?? null;
1606 $startTime = $this->getCurrentTime();
1607
1608 $keygroup = $this->determineKeyGroupForStats( $key );
1609
1610 // Get the current key value and its metadata
1611 $curState = $this->fetchKeys( [ $key ], $checkKeys, $startTime, $touchedCb )[$key];
1612 $curValue = $curState[self::RES_VALUE];
1613 // Use the cached value if it exists and is not due for synchronous regeneration
1614 if ( $this->isAcceptablyFreshValue( $curState, $graceTTL, $minAsOf ) ) {
1615 if ( !$this->isLotteryRefreshDue( $curState, $lowTTL, $ageNew, $hotTTR, $startTime ) ) {
1616 $this->stats->timing(
1617 "wanobjectcache.$keygroup.hit.good",
1618 1e3 * ( $this->getCurrentTime() - $startTime )
1619 );
1620
1621 return [ $curValue, $curState[self::RES_VERSION], $curState[self::RES_AS_OF] ];
1622 } elseif ( $this->scheduleAsyncRefresh( $key, $ttl, $callback, $opts, $cbParams ) ) {
1623 $this->logger->debug( "fetchOrRegenerate($key): hit with async refresh" );
1624 $this->stats->timing(
1625 "wanobjectcache.$keygroup.hit.refresh",
1626 1e3 * ( $this->getCurrentTime() - $startTime )
1627 );
1628
1629 return [ $curValue, $curState[self::RES_VERSION], $curState[self::RES_AS_OF] ];
1630 } else {
1631 $this->logger->debug( "fetchOrRegenerate($key): hit with sync refresh" );
1632 }
1633 }
1634
1635 $isKeyTombstoned = ( $curState[self::RES_TOMB_AS_OF] !== null );
1636 // Use the interim key as a temporary alternative if the key is tombstoned
1637 if ( $isKeyTombstoned ) {
1638 $volState = $this->getInterimValue( $key, $minAsOf, $startTime, $touchedCb );
1639 $volValue = $volState[self::RES_VALUE];
1640 } else {
1641 $volState = $curState;
1642 $volValue = $curValue;
1643 }
1644
1645 // During the volatile "hold-off" period that follows a purge of the key, the value
1646 // will be regenerated many times if frequently accessed. This is done to mitigate
1647 // the effects of backend replication lag as soon as possible. However, throttle the
1648 // overhead of locking and regeneration by reusing values recently written to cache
1649 // tens of milliseconds ago. Verify the "as of" time against the last purge event.
1650 $lastPurgeTime = max(
1651 // RES_TOUCH_AS_OF depends on the value (possibly from the interim key)
1652 $volState[self::RES_TOUCH_AS_OF],
1653 $curState[self::RES_TOMB_AS_OF],
1654 $curState[self::RES_CHECK_AS_OF]
1655 );
1656 $safeMinAsOf = max( $minAsOf, $lastPurgeTime + self::TINY_POSITIVE );
1657 if ( $this->isExtremelyNewValue( $volState, $safeMinAsOf, $startTime ) ) {
1658 $this->logger->debug( "fetchOrRegenerate($key): volatile hit" );
1659 $this->stats->timing(
1660 "wanobjectcache.$keygroup.hit.volatile",
1661 1e3 * ( $this->getCurrentTime() - $startTime )
1662 );
1663
1664 return [ $volValue, $volState[self::RES_VERSION], $curState[self::RES_AS_OF] ];
1665 }
1666
1667 $lockTSE = $opts['lockTSE'] ?? self::TSE_NONE;
1668 $busyValue = $opts['busyValue'] ?? null;
1669 $staleTTL = $opts['staleTTL'] ?? self::STALE_TTL_NONE;
1670 $segmentable = $opts['segmentable'] ?? false;
1671 $version = $opts['version'] ?? null;
1672
1673 // Determine whether one thread per datacenter should handle regeneration at a time
1674 $useRegenerationLock =
1675 // Note that since tombstones no-op set(), $lockTSE and $curTTL cannot be used to
1676 // deduce the key hotness because |$curTTL| will always keep increasing until the
1677 // tombstone expires or is overwritten by a new tombstone. Also, even if $lockTSE
1678 // is not set, constant regeneration of a key for the tombstone lifetime might be
1679 // very expensive. Assume tombstoned keys are possibly hot in order to reduce
1680 // the risk of high regeneration load after the delete() method is called.
1681 $isKeyTombstoned ||
1682 // Assume a key is hot if requested soon ($lockTSE seconds) after purge.
1683 // This avoids stampedes when timestamps from $checkKeys/$touchedCb bump.
1684 (
1685 $curState[self::RES_CUR_TTL] !== null &&
1686 $curState[self::RES_CUR_TTL] <= 0 &&
1687 abs( $curState[self::RES_CUR_TTL] ) <= $lockTSE
1688 ) ||
1689 // Assume a key is hot if there is no value and a busy fallback is given.
1690 // This avoids stampedes on eviction or preemptive regeneration taking too long.
1691 ( $busyValue !== null && $volValue === false );
1692
1693 // If a regeneration lock is required, threads that do not get the lock will try to use
1694 // the stale value, the interim value, or the $busyValue placeholder, in that order. If
1695 // none of those are set then all threads will bypass the lock and regenerate the value.
1696 $hasLock = $useRegenerationLock && $this->claimStampedeLock( $key );
1697 if ( $useRegenerationLock && !$hasLock ) {
1698 // Determine if there is stale or volatile cached value that is still usable
1699 // @phan-suppress-next-line PhanTypeMismatchArgumentNullable False positive
1700 if ( $this->isValid( $volValue, $volState[self::RES_AS_OF], $minAsOf ) ) {
1701 $this->logger->debug( "fetchOrRegenerate($key): returning stale value" );
1702 $this->stats->timing(
1703 "wanobjectcache.$keygroup.hit.stale",
1704 1e3 * ( $this->getCurrentTime() - $startTime )
1705 );
1706
1707 return [ $volValue, $volState[self::RES_VERSION], $curState[self::RES_AS_OF] ];
1708 } elseif ( $busyValue !== null ) {
1709 $miss = is_infinite( $minAsOf ) ? 'renew' : 'miss';
1710 $this->logger->debug( "fetchOrRegenerate($key): busy $miss" );
1711 $this->stats->timing(
1712 "wanobjectcache.$keygroup.$miss.busy",
1713 1e3 * ( $this->getCurrentTime() - $startTime )
1714 );
1715 $placeholderValue = $this->resolveBusyValue( $busyValue );
1716
1717 return [ $placeholderValue, $version, $curState[self::RES_AS_OF] ];
1718 }
1719 }
1720
1721 // Generate the new value given any prior value with a matching version
1722 $setOpts = [];
1723 $preCallbackTime = $this->getCurrentTime();
1724 ++$this->callbackDepth;
1725 // https://github.com/phan/phan/issues/4419
1726 $value = null;
1727 try {
1728 $value = $callback(
1729 ( $curState[self::RES_VERSION] === $version ) ? $curValue : false,
1730 $ttl,
1731 $setOpts,
1732 ( $curState[self::RES_VERSION] === $version ) ? $curState[self::RES_AS_OF] : null,
1733 $cbParams
1734 );
1735 } finally {
1736 --$this->callbackDepth;
1737 }
1738 $postCallbackTime = $this->getCurrentTime();
1739
1740 // How long it took to fetch, validate, and generate the value
1741 $elapsed = max( $postCallbackTime - $startTime, 0.0 );
1742
1743 // How long it took to generate the value
1744 $walltime = max( $postCallbackTime - $preCallbackTime, 0.0 );
1745 $this->stats->timing( "wanobjectcache.$keygroup.regen_walltime", 1e3 * $walltime );
1746
1747 // Attempt to save the newly generated value if applicable
1748 if (
1749 // Callback yielded a cacheable value
1750 ( $value !== false && $ttl >= 0 ) &&
1751 // Current thread was not raced out of a regeneration lock or key is tombstoned
1752 ( !$useRegenerationLock || $hasLock || $isKeyTombstoned )
1753 ) {
1754 $this->stats->timing( "wanobjectcache.$keygroup.regen_set_delay", 1e3 * $elapsed );
1755 // If the key is write-holed then use the (volatile) interim key as an alternative
1756 if ( $isKeyTombstoned ) {
1757 $this->setInterimValue(
1758 $key,
1759 $value,
1760 $lockTSE,
1761 $version,
1762 $segmentable
1763 );
1764 } else {
1765 $this->setMainValue(
1766 $key,
1767 $value,
1768 $ttl,
1769 $version,
1770 $walltime,
1771 // @phan-suppress-next-line PhanCoalescingAlwaysNull
1772 $setOpts['lag'] ?? 0,
1773 // @phan-suppress-next-line PhanCoalescingAlwaysNull
1774 $setOpts['since'] ?? $preCallbackTime,
1775 // @phan-suppress-next-line PhanCoalescingAlwaysNull
1776 $setOpts['pending'] ?? false,
1777 $lockTSE,
1778 $staleTTL,
1779 $segmentable,
1780 ( $curValue === false )
1781 );
1782 }
1783 }
1784
1785 $this->yieldStampedeLock( $key, $hasLock );
1786
1787 $miss = is_infinite( $minAsOf ) ? 'renew' : 'miss';
1788 $this->logger->debug( "fetchOrRegenerate($key): $miss, new value computed" );
1789 $this->stats->timing(
1790 "wanobjectcache.$keygroup.$miss.compute",
1791 1e3 * ( $this->getCurrentTime() - $startTime )
1792 );
1793
1794 return [ $value, $version, $curState[self::RES_AS_OF] ];
1795 }
1796
1801 private function claimStampedeLock( $key ) {
1802 $checkSisterKey = $this->makeSisterKey( $key, self::TYPE_MUTEX );
1803 // Note that locking is not bypassed due to I/O errors; this avoids stampedes
1804 return $this->cache->add( $checkSisterKey, 1, self::LOCK_TTL );
1805 }
1806
1811 private function yieldStampedeLock( $key, $hasLock ) {
1812 if ( $hasLock ) {
1813 $checkSisterKey = $this->makeSisterKey( $key, self::TYPE_MUTEX );
1814 $this->cache->delete( $checkSisterKey, $this->cache::WRITE_BACKGROUND );
1815 }
1816 }
1817
1828 private function makeSisterKeys( array $baseKeys, string $type, string $route = null ) {
1829 $sisterKeys = [];
1830 foreach ( $baseKeys as $baseKey ) {
1831 $sisterKeys[] = $this->makeSisterKey( $baseKey, $type, $route );
1832 }
1833
1834 return $sisterKeys;
1835 }
1836
1847 private function makeSisterKey( string $baseKey, string $typeChar, string $route = null ) {
1848 if ( $this->coalesceScheme === self::SCHEME_HASH_STOP ) {
1849 // Key style: "WANCache:<base key>|#|<character>"
1850 $sisterKey = 'WANCache:' . $baseKey . '|#|' . $typeChar;
1851 } else {
1852 // Key style: "WANCache:{<base key>}:<character>"
1853 $sisterKey = 'WANCache:{' . $baseKey . '}:' . $typeChar;
1854 }
1855
1856 if ( $route !== null ) {
1857 $sisterKey = $this->prependRoute( $sisterKey, $route );
1858 }
1859
1860 return $sisterKey;
1861 }
1862
1875 private function isExtremelyNewValue( $res, $minAsOf, $now ) {
1876 if ( $res[self::RES_VALUE] === false || $res[self::RES_AS_OF] < $minAsOf ) {
1877 return false;
1878 }
1879
1880 $age = $now - $res[self::RES_AS_OF];
1881
1882 return ( $age < mt_rand( self::RECENT_SET_LOW_MS, self::RECENT_SET_HIGH_MS ) / 1e3 );
1883 }
1884
1894 private function getInterimValue( $key, $minAsOf, $now, $touchedCb ) {
1895 if ( $this->useInterimHoldOffCaching ) {
1896 $interimSisterKey = $this->makeSisterKey( $key, self::TYPE_INTERIM );
1897 $wrapped = $this->cache->get( $interimSisterKey );
1898 $res = $this->unwrap( $wrapped, $now );
1899 if ( $res[self::RES_VALUE] !== false && $res[self::RES_AS_OF] >= $minAsOf ) {
1900 if ( $touchedCb !== null ) {
1901 // Update "last purge time" since the $touchedCb timestamp depends on $value
1902 // Get the new "touched timestamp", accounting for callback-checked dependencies
1903 $res[self::RES_TOUCH_AS_OF] = max(
1904 $touchedCb( $res[self::RES_VALUE] ),
1905 $res[self::RES_TOUCH_AS_OF]
1906 );
1907 }
1908
1909 return $res;
1910 }
1911 }
1912
1913 return $this->unwrap( false, $now );
1914 }
1915
1924 private function setInterimValue(
1925 $key,
1926 $value,
1927 $ttl,
1928 ?int $version,
1929 bool $segmentable
1930 ) {
1931 $now = $this->getCurrentTime();
1932 $ttl = max( self::INTERIM_KEY_TTL, (int)$ttl );
1933
1934 // Wrap that value with time/TTL/version metadata
1935 $wrapped = $this->wrap( $value, $ttl, $version, $now );
1936
1937 $flags = $this->cache::WRITE_BACKGROUND;
1938 if ( $segmentable ) {
1939 $flags |= $this->cache::WRITE_ALLOW_SEGMENTS;
1940 }
1941
1942 return $this->cache->set(
1943 $this->makeSisterKey( $key, self::TYPE_INTERIM ),
1944 $wrapped,
1945 $ttl,
1946 $flags
1947 );
1948 }
1949
1954 private function resolveBusyValue( $busyValue ) {
1955 return ( $busyValue instanceof Closure ) ? $busyValue() : $busyValue;
1956 }
1957
2023 final public function getMultiWithSetCallback(
2024 ArrayIterator $keyedIds, $ttl, callable $callback, array $opts = []
2025 ) {
2026 // Batch load required keys into the in-process warmup cache
2027 $this->warmupCache = $this->fetchWrappedValuesForWarmupCache(
2028 $this->getNonProcessCachedMultiKeys( $keyedIds, $opts ),
2029 $opts['checkKeys'] ?? []
2030 );
2031 $this->warmupKeyMisses = 0;
2032
2033 // The required callback signature includes $id as the first argument for convenience
2034 // to distinguish different items. To reuse the code in getWithSetCallback(), wrap the
2035 // callback with a proxy callback that has the standard getWithSetCallback() signature.
2036 // This is defined only once per batch to avoid closure creation overhead.
2037 $proxyCb = static function ( $oldValue, &$ttl, &$setOpts, $oldAsOf, $params )
2038 use ( $callback )
2039 {
2040 return $callback( $params['id'], $oldValue, $ttl, $setOpts, $oldAsOf );
2041 };
2042
2043 // Get the order-preserved result map using the warm-up cache
2044 $values = [];
2045 foreach ( $keyedIds as $key => $id ) {
2046 $values[$key] = $this->getWithSetCallback(
2047 $key,
2048 $ttl,
2049 $proxyCb,
2050 $opts,
2051 [ 'id' => $id ]
2052 );
2053 }
2054
2055 $this->warmupCache = [];
2056
2057 return $values;
2058 }
2059
2126 final public function getMultiWithUnionSetCallback(
2127 ArrayIterator $keyedIds, $ttl, callable $callback, array $opts = []
2128 ) {
2129 $checkKeys = $opts['checkKeys'] ?? [];
2130 $minAsOf = $opts['minAsOf'] ?? self::MIN_TIMESTAMP_NONE;
2131
2132 // unset incompatible keys
2133 unset( $opts['lockTSE'] );
2134 unset( $opts['busyValue'] );
2135
2136 // Batch load required keys into the in-process warmup cache
2137 $keysByIdGet = $this->getNonProcessCachedMultiKeys( $keyedIds, $opts );
2138 $this->warmupCache = $this->fetchWrappedValuesForWarmupCache( $keysByIdGet, $checkKeys );
2139 $this->warmupKeyMisses = 0;
2140
2141 // IDs of entities known to be in need of generation
2142 $idsRegen = [];
2143
2144 // Find out which keys are missing/deleted/stale
2145 $now = $this->getCurrentTime();
2146 $resByKey = $this->fetchKeys( $keysByIdGet, $checkKeys, $now );
2147 foreach ( $keysByIdGet as $id => $key ) {
2148 $res = $resByKey[$key];
2149 if (
2150 $res[self::RES_VALUE] === false ||
2151 $res[self::RES_CUR_TTL] < 0 ||
2152 $res[self::RES_AS_OF] < $minAsOf
2153 ) {
2154 $idsRegen[] = $id;
2155 }
2156 }
2157
2158 // Run the callback to populate the generation value map for all required IDs
2159 $newSetOpts = [];
2160 $newTTLsById = array_fill_keys( $idsRegen, $ttl );
2161 $newValsById = $idsRegen ? $callback( $idsRegen, $newTTLsById, $newSetOpts ) : [];
2162
2163 $method = __METHOD__;
2164 // The required callback signature includes $id as the first argument for convenience
2165 // to distinguish different items. To reuse the code in getWithSetCallback(), wrap the
2166 // callback with a proxy callback that has the standard getWithSetCallback() signature.
2167 // This is defined only once per batch to avoid closure creation overhead.
2168 $proxyCb = function ( $oldValue, &$ttl, &$setOpts, $oldAsOf, $params )
2169 use ( $callback, $newValsById, $newTTLsById, $newSetOpts, $method )
2170 {
2171 $id = $params['id'];
2172
2173 if ( array_key_exists( $id, $newValsById ) ) {
2174 // Value was already regenerated as expected, so use the value in $newValsById
2175 $newValue = $newValsById[$id];
2176 $ttl = $newTTLsById[$id];
2177 $setOpts = $newSetOpts;
2178 } else {
2179 // Pre-emptive/popularity refresh and version mismatch cases are not detected
2180 // above and thus $newValsById has no entry. Run $callback on this single entity.
2181 $ttls = [ $id => $ttl ];
2182 $result = $callback( [ $id ], $ttls, $setOpts );
2183 if ( !isset( $result[$id] ) ) {
2184 // T303092
2185 $this->logger->warning(
2186 $method . ' failed due to {id} not set in result {result}', [
2187 'id' => $id,
2188 'result' => json_encode( $result )
2189 ] );
2190 }
2191 $newValue = $result[$id];
2192 $ttl = $ttls[$id];
2193 }
2194
2195 return $newValue;
2196 };
2197
2198 // Get the order-preserved result map using the warm-up cache
2199 $values = [];
2200 foreach ( $keyedIds as $key => $id ) {
2201 $values[$key] = $this->getWithSetCallback(
2202 $key,
2203 $ttl,
2204 $proxyCb,
2205 $opts,
2206 [ 'id' => $id ]
2207 );
2208 }
2209
2210 $this->warmupCache = [];
2211
2212 return $values;
2213 }
2214
2222 public function makeGlobalKey( $keygroup, ...$components ) {
2223 // @phan-suppress-next-line PhanParamTooFewUnpack Should infer non-emptiness
2224 return $this->cache->makeGlobalKey( ...func_get_args() );
2225 }
2226
2234 public function makeKey( $keygroup, ...$components ) {
2235 // @phan-suppress-next-line PhanParamTooFewUnpack Should infer non-emptiness
2236 return $this->cache->makeKey( ...func_get_args() );
2237 }
2238
2246 public function hash256( $component ) {
2247 return hash_hmac( 'sha256', $component, $this->secret );
2248 }
2249
2301 final public function makeMultiKeys( array $ids, $keyCallback ) {
2302 $idByKey = [];
2303 foreach ( $ids as $id ) {
2304 // Discourage triggering of automatic makeKey() hashing in some backends
2305 if ( strlen( $id ) > 64 ) {
2306 $this->logger->warning( __METHOD__ . ": long ID '$id'; use hash256()" );
2307 }
2308 $key = $keyCallback( $id, $this );
2309 // Edge case: ignore key collisions due to duplicate $ids like "42" and 42
2310 if ( !isset( $idByKey[$key] ) ) {
2311 $idByKey[$key] = $id;
2312 } elseif ( (string)$id !== (string)$idByKey[$key] ) {
2313 throw new UnexpectedValueException(
2314 "Cache key collision; IDs ('$id','{$idByKey[$key]}') map to '$key'"
2315 );
2316 }
2317 }
2318
2319 return new ArrayIterator( $idByKey );
2320 }
2321
2357 final public function multiRemap( array $ids, array $res ) {
2358 if ( count( $ids ) !== count( $res ) ) {
2359 // If makeMultiKeys() is called on a list of non-unique IDs, then the resulting
2360 // ArrayIterator will have less entries due to "first appearance" de-duplication
2361 $ids = array_keys( array_fill_keys( $ids, true ) );
2362 if ( count( $ids ) !== count( $res ) ) {
2363 throw new UnexpectedValueException( "Multi-key result does not match ID list" );
2364 }
2365 }
2366
2367 return array_combine( $ids, $res );
2368 }
2369
2376 public function watchErrors() {
2377 return $this->cache->watchErrors();
2378 }
2379
2397 final public function getLastError( $watchPoint = 0 ) {
2398 $code = $this->cache->getLastError( $watchPoint );
2399 switch ( $code ) {
2400 case self::ERR_NONE:
2401 return self::ERR_NONE;
2402 case self::ERR_NO_RESPONSE:
2403 return self::ERR_NO_RESPONSE;
2404 case self::ERR_UNREACHABLE:
2405 return self::ERR_UNREACHABLE;
2406 default:
2407 return self::ERR_UNEXPECTED;
2408 }
2409 }
2410
2415 final public function clearLastError() {
2416 wfDeprecated( __METHOD__, '1.38' );
2417 $this->cache->clearLastError();
2418 }
2419
2425 public function clearProcessCache() {
2426 $this->processCaches = [];
2427 }
2428
2449 final public function useInterimHoldOffCaching( $enabled ) {
2450 $this->useInterimHoldOffCaching = $enabled;
2451 }
2452
2458 public function getQoS( $flag ) {
2459 return $this->cache->getQoS( $flag );
2460 }
2461
2525 public function adaptiveTTL( $mtime, $maxTTL, $minTTL = 30, $factor = 0.2 ) {
2526 // handle fractional seconds and string integers
2527 $mtime = (int)$mtime;
2528 if ( $mtime <= 0 ) {
2529 // no last-modified time provided
2530 return $minTTL;
2531 }
2532
2533 $age = (int)$this->getCurrentTime() - $mtime;
2534
2535 return (int)min( $maxTTL, max( $minTTL, $factor * $age ) );
2536 }
2537
2543 final public function getWarmupKeyMisses() {
2544 // Number of misses in $this->warmupCache during the last call to certain methods
2545 return $this->warmupKeyMisses;
2546 }
2547
2562 protected function relayVolatilePurge( string $sisterKey, string $purgeValue, int $ttl ) {
2563 if ( $this->broadcastRoute !== null ) {
2564 $routeKey = $this->prependRoute( $sisterKey, $this->broadcastRoute );
2565 } else {
2566 $routeKey = $sisterKey;
2567 }
2568
2569 return $this->cache->set(
2570 $routeKey,
2571 $purgeValue,
2572 $ttl,
2573 $this->cache::WRITE_BACKGROUND
2574 );
2575 }
2576
2585 protected function relayNonVolatilePurge( string $sisterKey ) {
2586 if ( $this->broadcastRoute !== null ) {
2587 $routeKey = $this->prependRoute( $sisterKey, $this->broadcastRoute );
2588 } else {
2589 $routeKey = $sisterKey;
2590 }
2591
2592 return $this->cache->delete( $routeKey, $this->cache::WRITE_BACKGROUND );
2593 }
2594
2600 protected function prependRoute( string $sisterKey, string $route ) {
2601 if ( $sisterKey[0] === '/' ) {
2602 throw new RuntimeException( "Sister key '$sisterKey' already contains a route." );
2603 }
2604
2605 return $route . $sisterKey;
2606 }
2607
2619 private function scheduleAsyncRefresh( $key, $ttl, $callback, array $opts, array $cbParams ) {
2620 if ( !$this->asyncHandler ) {
2621 return false;
2622 }
2623 // Update the cache value later, such during post-send of an HTTP request. This forces
2624 // cache regeneration by setting "minAsOf" to infinity, meaning that no existing value
2625 // is considered valid. Furthermore, note that preemptive regeneration is not applicable
2626 // to invalid values, so there is no risk of infinite preemptive regeneration loops.
2627 $func = $this->asyncHandler;
2628 $func( function () use ( $key, $ttl, $callback, $opts, $cbParams ) {
2629 $opts['minAsOf'] = INF;
2630 try {
2631 $this->fetchOrRegenerate( $key, $ttl, $callback, $opts, $cbParams );
2632 } catch ( Exception $e ) {
2633 // Log some context for easier debugging
2634 $this->logger->error( 'Async refresh failed for {key}', [
2635 'key' => $key,
2636 'ttl' => $ttl,
2637 'exception' => $e
2638 ] );
2639 throw $e;
2640 }
2641 } );
2642
2643 return true;
2644 }
2645
2654 private function isAcceptablyFreshValue( $res, $graceTTL, $minAsOf ) {
2655 if ( !$this->isValid( $res[self::RES_VALUE], $res[self::RES_AS_OF], $minAsOf ) ) {
2656 // Value does not exists or is too old
2657 return false;
2658 }
2659
2660 $curTTL = $res[self::RES_CUR_TTL];
2661 if ( $curTTL > 0 ) {
2662 // Value is definitely still fresh
2663 return true;
2664 }
2665
2666 // Remaining seconds during which this stale value can be used
2667 $curGraceTTL = $graceTTL + $curTTL;
2668
2669 return ( $curGraceTTL > 0 )
2670 // Chance of using the value decreases as $curTTL goes from 0 to -$graceTTL
2671 ? !$this->worthRefreshExpiring( $curGraceTTL, $graceTTL, $graceTTL )
2672 // Value is too stale to fall in the grace period
2673 : false;
2674 }
2675
2686 protected function isLotteryRefreshDue( $res, $lowTTL, $ageNew, $hotTTR, $now ) {
2687 $curTTL = $res[self::RES_CUR_TTL];
2688 $logicalTTL = $res[self::RES_TTL];
2689 $asOf = $res[self::RES_AS_OF];
2690
2691 return (
2692 $this->worthRefreshExpiring( $curTTL, $logicalTTL, $lowTTL ) ||
2693 $this->worthRefreshPopular( $asOf, $ageNew, $hotTTR, $now )
2694 );
2695 }
2696
2712 protected function worthRefreshPopular( $asOf, $ageNew, $timeTillRefresh, $now ) {
2713 if ( $ageNew < 0 || $timeTillRefresh <= 0 ) {
2714 return false;
2715 }
2716
2717 $age = $now - $asOf;
2718 $timeOld = $age - $ageNew;
2719 if ( $timeOld <= 0 ) {
2720 return false;
2721 }
2722
2723 $popularHitsPerSec = 1;
2724 // Lifecycle is: new, ramp-up refresh chance, full refresh chance.
2725 // Note that the "expected # of refreshes" for the ramp-up time range is half
2726 // of what it would be if P(refresh) was at its full value during that time range.
2727 $refreshWindowSec = max( $timeTillRefresh - $ageNew - self::RAMPUP_TTL / 2, 1 );
2728 // P(refresh) * (# hits in $refreshWindowSec) = (expected # of refreshes)
2729 // P(refresh) * ($refreshWindowSec * $popularHitsPerSec) = 1 (by definition)
2730 // P(refresh) = 1/($refreshWindowSec * $popularHitsPerSec)
2731 $chance = 1 / ( $popularHitsPerSec * $refreshWindowSec );
2732 // Ramp up $chance from 0 to its nominal value over RAMPUP_TTL seconds to avoid stampedes
2733 $chance *= ( $timeOld <= self::RAMPUP_TTL ) ? $timeOld / self::RAMPUP_TTL : 1;
2734
2735 return ( mt_rand( 1, 1_000_000_000 ) <= 1_000_000_000 * $chance );
2736 }
2737
2756 protected function worthRefreshExpiring( $curTTL, $logicalTTL, $lowTTL ) {
2757 if ( $lowTTL <= 0 ) {
2758 return false;
2759 }
2760 // T264787: avoid having keys start off with a high chance of being refreshed;
2761 // the point where refreshing becomes possible cannot precede the key lifetime.
2762 $effectiveLowTTL = min( $lowTTL, $logicalTTL ?: INF );
2763
2764 // How long the value was in the "low TTL" phase
2765 $timeOld = $effectiveLowTTL - $curTTL;
2766 if ( $timeOld <= 0 || $timeOld >= $effectiveLowTTL ) {
2767 return false;
2768 }
2769
2770 // Ratio of the low TTL phase that has elapsed (r)
2771 $ttrRatio = $timeOld / $effectiveLowTTL;
2772 // Use p(r) as the monotonically increasing "chance of refresh" function,
2773 // having p(0)=0 and p(1)=1. The value expires at the nominal expiry.
2774 $chance = $ttrRatio ** 4;
2775
2776 return ( mt_rand( 1, 1_000_000_000 ) <= 1_000_000_000 * $chance );
2777 }
2778
2787 protected function isValid( $value, $asOf, $minAsOf ) {
2788 return ( $value !== false && $asOf >= $minAsOf );
2789 }
2790
2798 private function wrap( $value, $ttl, $version, $now ) {
2799 // Returns keys in ascending integer order for PHP7 array packing:
2800 // https://nikic.github.io/2014/12/22/PHPs-new-hashtable-implementation.html
2801 $wrapped = [
2802 self::FLD_FORMAT_VERSION => self::VERSION,
2803 self::FLD_VALUE => $value,
2804 self::FLD_TTL => $ttl,
2805 self::FLD_TIME => $now
2806 ];
2807 if ( $version !== null ) {
2808 $wrapped[self::FLD_VALUE_VERSION] = $version;
2809 }
2810
2811 return $wrapped;
2812 }
2813
2828 private function unwrap( $wrapped, $now ) {
2829 // https://nikic.github.io/2014/12/22/PHPs-new-hashtable-implementation.html
2830 $res = [
2831 // Attributes that only depend on the fetched key value
2832 self::RES_VALUE => false,
2833 self::RES_VERSION => null,
2834 self::RES_AS_OF => null,
2835 self::RES_TTL => null,
2836 self::RES_TOMB_AS_OF => null,
2837 // Attributes that depend on caller-specific "check" keys or "touched callbacks"
2838 self::RES_CHECK_AS_OF => null,
2839 self::RES_TOUCH_AS_OF => null,
2840 self::RES_CUR_TTL => null
2841 ];
2842
2843 if ( is_array( $wrapped ) ) {
2844 // Entry expected to be a cached value; validate it
2845 if (
2846 ( $wrapped[self::FLD_FORMAT_VERSION] ?? null ) === self::VERSION &&
2847 $wrapped[self::FLD_TIME] >= $this->epoch
2848 ) {
2849 if ( $wrapped[self::FLD_TTL] > 0 ) {
2850 // Get the approximate time left on the key
2851 $age = $now - $wrapped[self::FLD_TIME];
2852 $curTTL = max( $wrapped[self::FLD_TTL] - $age, 0.0 );
2853 } else {
2854 // Key had no TTL, so the time left is unbounded
2855 $curTTL = INF;
2856 }
2857 $res[self::RES_VALUE] = $wrapped[self::FLD_VALUE];
2858 $res[self::RES_VERSION] = $wrapped[self::FLD_VALUE_VERSION] ?? null;
2859 $res[self::RES_AS_OF] = $wrapped[self::FLD_TIME];
2860 $res[self::RES_CUR_TTL] = $curTTL;
2861 $res[self::RES_TTL] = $wrapped[self::FLD_TTL];
2862 }
2863 } else {
2864 // Entry expected to be a tombstone; parse it
2865 $purge = $this->parsePurgeValue( $wrapped );
2866 if ( $purge !== null ) {
2867 // Tombstoned keys should always have a negative "current TTL"
2868 $curTTL = min( $purge[self::PURGE_TIME] - $now, self::TINY_NEGATIVE );
2869 $res[self::RES_CUR_TTL] = $curTTL;
2870 $res[self::RES_TOMB_AS_OF] = $purge[self::PURGE_TIME];
2871 }
2872 }
2873
2874 return $res;
2875 }
2876
2882 private function determineKeyGroupForStats( $key ) {
2883 $parts = explode( ':', $key, 3 );
2884 // Fallback in case the key was not made by makeKey.
2885 // Replace dots because they are special in StatsD (T232907)
2886 return strtr( $parts[1] ?? $parts[0], '.', '_' );
2887 }
2888
2897 private function parsePurgeValue( $value ) {
2898 if ( !is_string( $value ) ) {
2899 return null;
2900 }
2901
2902 $segments = explode( ':', $value, 3 );
2903 $prefix = $segments[0];
2904 if ( $prefix !== self::PURGE_VAL_PREFIX ) {
2905 // Not a purge value
2906 return null;
2907 }
2908
2909 $timestamp = (float)$segments[1];
2910 // makeTombstonePurgeValue() doesn't store hold-off TTLs
2911 $holdoff = isset( $segments[2] ) ? (int)$segments[2] : self::HOLDOFF_TTL;
2912
2913 if ( $timestamp < $this->epoch ) {
2914 // Purge value is too old
2915 return null;
2916 }
2917
2918 return [ self::PURGE_TIME => $timestamp, self::PURGE_HOLDOFF => $holdoff ];
2919 }
2920
2925 private function makeTombstonePurgeValue( float $timestamp ) {
2926 return self::PURGE_VAL_PREFIX . ':' . (int)$timestamp;
2927 }
2928
2935 private function makeCheckPurgeValue( float $timestamp, int $holdoff, array &$purge = null ) {
2936 $normalizedTime = (int)$timestamp;
2937 // Purge array that matches what parsePurgeValue() would have returned
2938 $purge = [ self::PURGE_TIME => (float)$normalizedTime, self::PURGE_HOLDOFF => $holdoff ];
2939
2940 return self::PURGE_VAL_PREFIX . ":$normalizedTime:$holdoff";
2941 }
2942
2947 private function getProcessCache( $group ) {
2948 if ( !isset( $this->processCaches[$group] ) ) {
2949 [ , $size ] = explode( ':', $group );
2950 $this->processCaches[$group] = new MapCacheLRU( (int)$size );
2951 if ( $this->wallClockOverride !== null ) {
2952 $this->processCaches[$group]->setMockTime( $this->wallClockOverride );
2953 }
2954 }
2955
2956 return $this->processCaches[$group];
2957 }
2958
2964 private function getNonProcessCachedMultiKeys( ArrayIterator $keys, array $opts ) {
2965 $pcTTL = $opts['pcTTL'] ?? self::TTL_UNCACHEABLE;
2966
2967 $keysMissing = [];
2968 if ( $pcTTL > 0 && $this->callbackDepth == 0 ) {
2969 $pCache = $this->getProcessCache( $opts['pcGroup'] ?? self::PC_PRIMARY );
2970 foreach ( $keys as $key => $id ) {
2971 if ( !$pCache->has( $key, $pcTTL ) ) {
2972 $keysMissing[$id] = $key;
2973 }
2974 }
2975 }
2976
2977 return $keysMissing;
2978 }
2979
2986 private function fetchWrappedValuesForWarmupCache( array $keys, array $checkKeys ) {
2987 if ( !$keys ) {
2988 return [];
2989 }
2990
2991 // Get all the value keys to fetch...
2992 $sisterKeys = $this->makeSisterKeys( $keys, self::TYPE_VALUE );
2993 // Get all the "check" keys to fetch...
2994 foreach ( $checkKeys as $i => $checkKeyOrKeyGroup ) {
2995 // Note: avoid array_merge() inside loop in case there are many keys
2996 if ( is_int( $i ) ) {
2997 // Single "check" key that applies to all value keys
2998 $sisterKeys[] = $this->makeSisterKey( $checkKeyOrKeyGroup, self::TYPE_TIMESTAMP );
2999 } else {
3000 // List of "check" keys that apply to a specific value key
3001 foreach ( (array)$checkKeyOrKeyGroup as $checkKey ) {
3002 $sisterKeys[] = $this->makeSisterKey( $checkKey, self::TYPE_TIMESTAMP );
3003 }
3004 }
3005 }
3006
3007 $wrappedBySisterKey = $this->cache->getMulti( $sisterKeys );
3008 $wrappedBySisterKey += array_fill_keys( $sisterKeys, false );
3009
3010 return $wrappedBySisterKey;
3011 }
3012
3018 private function timeSinceLoggedMiss( $key, $now ) {
3019 for ( end( $this->missLog ); $miss = current( $this->missLog ); prev( $this->missLog ) ) {
3020 if ( $miss[0] === $key ) {
3021 return ( $now - $miss[1] );
3022 }
3023 }
3024
3025 return null;
3026 }
3027
3032 protected function getCurrentTime() {
3033 return $this->wallClockOverride ?: microtime( true );
3034 }
3035
3040 public function setMockTime( &$time ) {
3041 $this->wallClockOverride =& $time;
3042 $this->cache->setMockTime( $time );
3043 foreach ( $this->processCaches as $pCache ) {
3044 $pCache->setMockTime( $time );
3045 }
3046 }
3047}
wfDeprecated( $function, $version=false, $component=false, $callerOffset=2)
Logs a warning that a deprecated feature was used.
array $params
The job parameters.
Store key-value entries in a size-limited in-memory LRU cache.
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.
fetchKeys(array $keys, array $checkKeys, float $now, $touchedCb=null)
Fetch the value and key metadata of several keys from cache.
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)
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.
Class representing a cache/ephemeral data store.
Definition BagOStuff.php:88
A BagOStuff object with no objects in it.
Generic interface providing Time-To-Live constants for expirable object storage.
Generic interface providing error code and quality-of-service constants for object stores.
Key-encoding methods for object caching (BagOStuff and WANObjectCache)