Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
74.29% covered (warning)
74.29%
52 / 70
55.56% covered (warning)
55.56%
5 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
CachedSource
74.29% covered (warning)
74.29%
52 / 70
55.56% covered (warning)
55.56%
5 / 9
39.40
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 load
74.29% covered (warning)
74.29%
26 / 35
0.00% covered (danger)
0.00%
0 / 1
10.38
 __toString
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isValidHit
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
5
 isExpired
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 expiresEarly
28.57% covered (danger)
28.57%
2 / 7
0.00% covered (danger)
0.00%
0 / 1
9.83
 loadAndCache
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 loadWithMetadata
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 locateInclude
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3namespace MediaWiki\Settings\Cache;
4
5use BagOStuff;
6use MediaWiki\Settings\SettingsBuilderException;
7use MediaWiki\Settings\Source\SettingsIncludeLocator;
8use MediaWiki\Settings\Source\SettingsSource;
9use Wikimedia\WaitConditionLoop;
10
11/**
12 * Provides a caching layer for a {@link CacheableSource}.
13 *
14 * @newable
15 * @since 1.38
16 */
17class CachedSource implements SettingsSource, SettingsIncludeLocator {
18    /**
19     * Cached source generation timeout (in seconds).
20     */
21    private const TIMEOUT = 2;
22
23    /** @var BagOStuff */
24    private $cache;
25
26    /** @var CacheableSource */
27    private $source;
28
29    /**
30     * Constructs a new CachedSource using an instantiated cache and
31     * {@link CacheableSource}.
32     *
33     * @stable to call
34     *
35     * @param BagOStuff $cache
36     * @param CacheableSource $source
37     */
38    public function __construct(
39        BagOStuff $cache,
40        CacheableSource $source
41    ) {
42        $this->cache = $cache;
43        $this->source = $source;
44    }
45
46    /**
47     * Queries cache for source contents and performs loading/caching of the
48     * source contents on miss.
49     *
50     * If the load fails but the source implements {@link
51     * CacheableSource::allowsStaleLoad()} as <code>true</code>, stale results
52     * may be returned if still present in the cache store.
53     *
54     * @return array
55     */
56    public function load(): array {
57        $key = $this->cache->makeGlobalKey(
58            __CLASS__,
59            $this->source->getHashKey()
60        );
61
62        $result = null;
63        $loop = new WaitConditionLoop(
64            function () use ( $key, &$result ) {
65                $item = $this->cache->get( $key );
66
67                if ( $this->isValidHit( $item ) ) {
68                    if ( $this->isExpired( $item ) ) {
69                        // The cached item is stale but use it as a default in
70                        // case of failure if the source allows that
71                        if ( $this->source->allowsStaleLoad() ) {
72                            $result = $item['value'];
73                        }
74                    } else {
75                        $result = $item['value'];
76                        return WaitConditionLoop::CONDITION_REACHED;
77                    }
78                }
79
80                if ( $this->cache->lock( $key, 0, self::TIMEOUT ) ) {
81                    try {
82                        $result = $this->loadAndCache( $key );
83                    } catch ( SettingsBuilderException $e ) {
84                        if ( $result === null ) {
85                            // We have a failure and no stale result to fall
86                            // back on, so throw
87                            throw $e;
88                        }
89                    } finally {
90                        $this->cache->unlock( $key );
91                    }
92                }
93
94                return $result === null
95                    ? WaitConditionLoop::CONDITION_CONTINUE
96                    : WaitConditionLoop::CONDITION_REACHED;
97            },
98            self::TIMEOUT
99        );
100
101        if ( $loop->invoke() !== WaitConditionLoop::CONDITION_REACHED ) {
102            throw new SettingsBuilderException(
103                'Exceeded {timeout}s timeout attempting to load and cache source {source}',
104                [
105                    'timeout' => self::TIMEOUT,
106                    'source' => $this->source,
107                ]
108
109            );
110        }
111
112        // @phan-suppress-next-line PhanTypeMismatchReturn WaitConditionLoop throws or value set
113        return $result;
114    }
115
116    /**
117     * Returns the string representation of the encapsulated source.
118     *
119     * @return string
120     */
121    public function __toString(): string {
122        return $this->source->__toString();
123    }
124
125    /**
126     * Whether the given cache item is considered a cache hit, in other words:
127     *  - it is not a falsey value
128     *  - it has the correct type and structure for this cache implementation
129     *
130     * @param mixed $item Cache item.
131     *
132     * @return bool
133     */
134    private function isValidHit( $item ): bool {
135        return $item &&
136            is_array( $item ) &&
137            isset( $item['expiry'] ) &&
138            isset( $item['generation'] ) &&
139            isset( $item['value'] );
140    }
141
142    /**
143     * Whether the given cache item is considered expired, in other words:
144     *    - its expiry timestamp has passed
145     *    - it is deemed to expire early so as to mitigate cache stampedes
146     *
147     * @param array $item Cache item.
148     *
149     * @return bool
150     */
151    private function isExpired( $item ): bool {
152        return $item['expiry'] < microtime( true ) ||
153            $this->expiresEarly( $item, $this->source->getExpiryWeight() );
154    }
155
156    /**
157     * Decide whether the cached source should be expired early according to a
158     * probabilistic calculation that becomes more likely as the normal expiry
159     * approaches.
160     *
161     * In other words, we're going to pretend we're a bit further into the
162     * future than we are so that we might expire and regenerate the cached
163     * settings before other threads attempt to the do the same. The number of
164     * threads that will pretend to be far into the future (and thus will
165     * concurrently reload/cache the settings) will most probably be so
166     * exponentially fewer than the number of threads pretending to be near
167     * into the future that it will approach optimal stampede protection
168     * without the use of an exclusive lock.
169     *
170     * @param array $item Cached source with expiry metadata.
171     * @param float $weight Coefficient used to increase/decrease the
172     *  likelihood of early expiration.
173     *
174     * @link https://en.wikipedia.org/wiki/Cache_stampede#Probabilistic_early_expiration
175     *
176     * @return bool
177     */
178    private function expiresEarly( array $item, float $weight ): bool {
179        if ( $weight == 0 || !isset( $item['expiry'] ) || !isset( $item['generation'] ) ) {
180            return false;
181        }
182
183        // Calculate a negative expiry offset using generation time, expiry
184        // weight, and a random number within the exponentially distributed
185        // range of log n where n: (0, 1] (which is always negative)
186        $expiryOffset =
187            $item['generation'] *
188            $weight *
189            log( random_int( 1, PHP_INT_MAX ) / PHP_INT_MAX );
190
191        return ( $item['expiry'] + $expiryOffset ) <= microtime( true );
192    }
193
194    /**
195     * Loads the source and caches the result.
196     *
197     * @param string $key
198     *
199     * @return array
200     */
201    private function loadAndCache( string $key ): array {
202        $ttl =
203            $this->source->allowsStaleLoad()
204            ? BagOStuff::TTL_INDEFINITE
205            : $this->source->getExpiryTtl();
206
207        $item = $this->loadWithMetadata();
208        $this->cache->set( $key, $item, $ttl );
209        return $item['value'];
210    }
211
212    /**
213     * Wraps cached source with the metadata needed to perform probabilistic
214     * early expiration to help mitigate cache stampedes.
215     *
216     * @return array
217     */
218    private function loadWithMetadata(): array {
219        $start = microtime( true );
220        $value = $this->source->load();
221        $finish = microtime( true );
222
223        return [
224            'value' => $value,
225            'expiry' => $start + $this->source->getExpiryTtl(),
226            'generation' => $finish - $start,
227        ];
228    }
229
230    public function locateInclude( string $location ): string {
231        if ( $this->source instanceof SettingsIncludeLocator ) {
232            return $this->source->locateInclude( $location );
233        } else {
234            // Just return the location as-is
235            return $location;
236        }
237    }
238}