includes/Settings/Cache/CachedSource.php
<?php
namespace MediaWiki\Settings\Cache;
use MediaWiki\Settings\SettingsBuilderException;
use MediaWiki\Settings\Source\SettingsIncludeLocator;
use MediaWiki\Settings\Source\SettingsSource;
use Stringable;
use Wikimedia\ObjectCache\BagOStuff;
use Wikimedia\WaitConditionLoop;
/**
* Provides a caching layer for a {@link CacheableSource}.
*
* @newable
* @since 1.38
*/
class CachedSource implements Stringable, SettingsSource, SettingsIncludeLocator {
/**
* Cached source generation timeout (in seconds).
*/
private const TIMEOUT = 2;
/** @var BagOStuff */
private $cache;
/** @var CacheableSource */
private $source;
/**
* Constructs a new CachedSource using an instantiated cache and
* {@link CacheableSource}.
*
* @stable to call
*
* @param BagOStuff $cache
* @param CacheableSource $source
*/
public function __construct(
BagOStuff $cache,
CacheableSource $source
) {
$this->cache = $cache;
$this->source = $source;
}
/**
* Queries cache for source contents and performs loading/caching of the
* source contents on miss.
*
* If the load fails but the source implements {@link
* CacheableSource::allowsStaleLoad()} as <code>true</code>, stale results
* may be returned if still present in the cache store.
*
* @return array
*/
public function load(): array {
$key = $this->cache->makeGlobalKey(
__CLASS__,
$this->source->getHashKey()
);
$result = null;
$loop = new WaitConditionLoop(
function () use ( $key, &$result ) {
$item = $this->cache->get( $key );
if ( $this->isValidHit( $item ) ) {
if ( $this->isExpired( $item ) ) {
// The cached item is stale but use it as a default in
// case of failure if the source allows that
if ( $this->source->allowsStaleLoad() ) {
$result = $item['value'];
}
} else {
$result = $item['value'];
return WaitConditionLoop::CONDITION_REACHED;
}
}
if ( $this->cache->lock( $key, 0, self::TIMEOUT ) ) {
try {
$result = $this->loadAndCache( $key );
} catch ( SettingsBuilderException $e ) {
if ( $result === null ) {
// We have a failure and no stale result to fall
// back on, so throw
throw $e;
}
} finally {
$this->cache->unlock( $key );
}
}
return $result === null
? WaitConditionLoop::CONDITION_CONTINUE
: WaitConditionLoop::CONDITION_REACHED;
},
self::TIMEOUT
);
if ( $loop->invoke() !== WaitConditionLoop::CONDITION_REACHED ) {
throw new SettingsBuilderException(
'Exceeded {timeout}s timeout attempting to load and cache source {source}',
[
'timeout' => self::TIMEOUT,
'source' => $this->source,
]
);
}
// @phan-suppress-next-line PhanTypeMismatchReturn WaitConditionLoop throws or value set
return $result;
}
/**
* Returns the string representation of the encapsulated source.
*
* @return string
*/
public function __toString(): string {
return $this->source->__toString();
}
/**
* Whether the given cache item is considered a cache hit, in other words:
* - it is not a falsey value
* - it has the correct type and structure for this cache implementation
*
* @param mixed $item Cache item.
*
* @return bool
*/
private function isValidHit( $item ): bool {
return $item &&
is_array( $item ) &&
isset( $item['expiry'] ) &&
isset( $item['generation'] ) &&
isset( $item['value'] );
}
/**
* Whether the given cache item is considered expired, in other words:
* - its expiry timestamp has passed
* - it is deemed to expire early so as to mitigate cache stampedes
*
* @param array $item Cache item.
*
* @return bool
*/
private function isExpired( $item ): bool {
return $item['expiry'] < microtime( true ) ||
$this->expiresEarly( $item, $this->source->getExpiryWeight() );
}
/**
* Decide whether the cached source should be expired early according to a
* probabilistic calculation that becomes more likely as the normal expiry
* approaches.
*
* In other words, we're going to pretend we're a bit further into the
* future than we are so that we might expire and regenerate the cached
* settings before other threads attempt to the do the same. The number of
* threads that will pretend to be far into the future (and thus will
* concurrently reload/cache the settings) will most probably be so
* exponentially fewer than the number of threads pretending to be near
* into the future that it will approach optimal stampede protection
* without the use of an exclusive lock.
*
* @param array $item Cached source with expiry metadata.
* @param float $weight Coefficient used to increase/decrease the
* likelihood of early expiration.
*
* @link https://en.wikipedia.org/wiki/Cache_stampede#Probabilistic_early_expiration
*
* @return bool
*/
private function expiresEarly( array $item, float $weight ): bool {
if ( $weight == 0 || !isset( $item['expiry'] ) || !isset( $item['generation'] ) ) {
return false;
}
// Calculate a negative expiry offset using generation time, expiry
// weight, and a random number within the exponentially distributed
// range of log n where n: (0, 1] (which is always negative)
$expiryOffset =
$item['generation'] *
$weight *
log( random_int( 1, PHP_INT_MAX ) / PHP_INT_MAX );
return ( $item['expiry'] + $expiryOffset ) <= microtime( true );
}
/**
* Loads the source and caches the result.
*
* @param string $key
*
* @return array
*/
private function loadAndCache( string $key ): array {
$ttl =
$this->source->allowsStaleLoad()
? BagOStuff::TTL_INDEFINITE
: $this->source->getExpiryTtl();
$item = $this->loadWithMetadata();
$this->cache->set( $key, $item, $ttl );
return $item['value'];
}
/**
* Wraps cached source with the metadata needed to perform probabilistic
* early expiration to help mitigate cache stampedes.
*
* @return array
*/
private function loadWithMetadata(): array {
$start = microtime( true );
$value = $this->source->load();
$finish = microtime( true );
return [
'value' => $value,
'expiry' => $start + $this->source->getExpiryTtl(),
'generation' => $finish - $start,
];
}
public function locateInclude( string $location ): string {
if ( $this->source instanceof SettingsIncludeLocator ) {
return $this->source->locateInclude( $location );
} else {
// Just return the location as-is
return $location;
}
}
}