MediaWiki  master
WANObjectCache.php
Go to the documentation of this file.
1 <?php
22 use Liuggio\StatsdClient\Factory\StatsdDataFactoryInterface;
23 use Psr\Log\LoggerAwareInterface;
24 use Psr\Log\LoggerInterface;
25 use Psr\Log\NullLogger;
28 
125 class WANObjectCache implements
129  LoggerAwareInterface
130 {
132  protected $cache;
134  protected $processCaches = [];
136  protected $logger;
138  protected $stats;
140  protected $asyncHandler;
141 
149  protected $broadcastRoute;
151  protected $useInterimHoldOffCaching = true;
153  protected $epoch;
155  protected $secret;
157  protected $coalesceScheme;
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_POSTIVE = 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_POSTIVE );
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, 1000000000 ) <= 1000000000 * $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, 1000000000 ) <= 1000000000 * $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 }
A BagOStuff object with no objects in it.
Store key-value entries in a size-limited in-memory LRU cache.
Definition: MapCacheLRU.php:34
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.
const ERR_UNREACHABLE
Storage medium could not be reached to establish a connection.
const ERR_UNEXPECTED
Storage medium operation failed due to usage limitations or an I/O error.
const ERR_NO_RESPONSE
Storage medium failed to yield a complete response to an operation.