Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.30% covered (success)
96.30%
104 / 108
85.00% covered (warning)
85.00%
17 / 20
CRAP
0.00% covered (danger)
0.00%
0 / 1
MapCacheLRU
97.20% covered (success)
97.20%
104 / 107
85.00% covered (warning)
85.00%
17 / 20
54
0.00% covered (danger)
0.00%
0 / 1
 __construct
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 newFromArray
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 toArray
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 set
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
5
 has
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
6
 get
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 setField
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
5.02
 hasField
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
7
 getField
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getAllKeys
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getWithSetCallback
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 makeKey
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 clear
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 getMaxSize
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setMaxSize
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 ping
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getKeyAge
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getKeyFieldAge
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 __serialize
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 __unserialize
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getCurrentTime
n/a
0 / 0
n/a
0 / 0
2
 setMockTime
n/a
0 / 0
n/a
0 / 0
1
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 */
6
7namespace Wikimedia\MapCacheLRU;
8
9use InvalidArgumentException;
10use UnexpectedValueException;
11use Wikimedia\LightweightObjectStore\ExpirationAwareness;
12
13/**
14 * Store key-value entries in a size-limited in-memory LRU cache.
15 *
16 * The last-modification timestamp of entries is internally tracked so that callers can
17 * specify the maximum acceptable age of entries in calls to the has() method. As a convenience,
18 * the hasField(), getField(), and setField() methods can be used for entries that are field/value
19 * maps themselves; such fields will have their own internally tracked last-modification timestamp.
20 *
21 * @ingroup Cache
22 * @since 1.23
23 */
24class MapCacheLRU implements ExpirationAwareness {
25    /** @var array Map of (key => value) */
26    private $cache = [];
27    /** @var array Map of (key => (UNIX timestamp, (field => UNIX timestamp))) */
28    private $timestamps = [];
29    /** @var float Default entry timestamp if not specified */
30    private $epoch;
31
32    /** @var int Max number of entries */
33    private $maxCacheKeys;
34
35    /** @var float|null */
36    private $wallClockOverride;
37
38    /** @var float */
39    private const RANK_TOP = 1.0;
40
41    /** @var int Array key that holds the entry's main timestamp (flat key use) */
42    private const SIMPLE = 0;
43    /** @var int Array key that holds the entry's field timestamps (nested key use) */
44    private const FIELDS = 1;
45
46    /**
47     * @param int $maxKeys Maximum number of entries allowed (min 1)
48     */
49    public function __construct( int $maxKeys ) {
50        if ( $maxKeys <= 0 ) {
51            throw new InvalidArgumentException( '$maxKeys must be above zero' );
52        }
53
54        $this->maxCacheKeys = $maxKeys;
55        // Use the current time as the default "as of" timestamp of entries
56        $this->epoch = $this->getCurrentTime();
57    }
58
59    /**
60     * @param array $values Key/value map in order of increasingly recent access
61     * @param int $maxKeys
62     * @return MapCacheLRU
63     * @since 1.30
64     */
65    public static function newFromArray( array $values, $maxKeys ) {
66        $mapCache = new self( $maxKeys );
67        $mapCache->cache = ( count( $values ) > $maxKeys )
68            ? array_slice( $values, -$maxKeys, null, true )
69            : $values;
70
71        return $mapCache;
72    }
73
74    /**
75     * @return array Key/value map in order of increasingly recent access
76     * @since 1.30
77     */
78    public function toArray() {
79        return $this->cache;
80    }
81
82    /**
83     * Set a key/value pair.
84     * This will prune the cache if it gets too large based on LRU.
85     * If the item is already set, it will be pushed to the top of the cache.
86     *
87     * To reduce evictions due to one-off use of many new keys, $rank can be
88     * set to have keys start at the top of a bottom fraction of the list. A value
89     * of 3/8 means values start at the top of the bottom 3/8s of the list and are
90     * moved to the top of the list when accessed a second time.
91     *
92     * @param string|int $key
93     * @param mixed $value
94     * @param float $rank Bottom fraction of the list where keys start off [default: 1.0]
95     * @return void
96     */
97    public function set( $key, $value, $rank = self::RANK_TOP ) {
98        if ( $this->has( $key ) ) {
99            $this->ping( $key );
100        } elseif ( count( $this->cache ) >= $this->maxCacheKeys ) {
101            $evictKey = array_key_first( $this->cache );
102            unset( $this->cache[$evictKey] );
103            unset( $this->timestamps[$evictKey] );
104        }
105
106        if ( $rank < 1.0 && $rank > 0 ) {
107            $offset = intval( $rank * count( $this->cache ) );
108            $this->cache = array_slice( $this->cache, 0, $offset, true )
109                + [ $key => $value ]
110                + array_slice( $this->cache, $offset, null, true );
111        } else {
112            $this->cache[$key] = $value;
113        }
114
115        $this->timestamps[$key] = [
116            self::SIMPLE => $this->getCurrentTime(),
117            self::FIELDS => []
118        ];
119    }
120
121    /**
122     * Check if a key exists
123     *
124     * @param string|int $key
125     * @param float $maxAge Ignore items older than this many seconds [default: INF]
126     * @return bool
127     * @since 1.32 Added $maxAge
128     */
129    public function has( $key, $maxAge = INF ) {
130        if ( !is_int( $key ) && !is_string( $key ) ) {
131            throw new UnexpectedValueException(
132                __METHOD__ . ': invalid key; must be string or integer.' );
133        }
134        return array_key_exists( $key, $this->cache )
135            && (
136                // Optimization: Avoid expensive getAge/getCurrentTime for common case (T275673)
137                $maxAge === INF
138                || $maxAge <= 0
139                || $this->getKeyAge( $key ) <= $maxAge
140            );
141    }
142
143    /**
144     * Get the value for a key.
145     * This returns null if the key is not set.
146     * If the item is already set, it will be pushed to the top of the cache.
147     *
148     * @param string|int $key
149     * @param float $maxAge Ignore items older than this many seconds [default: INF]
150     * @param mixed|null $default Value to return if no key is found [default: null]
151     * @return mixed Returns $default if the key was not found or is older than $maxAge
152     * @since 1.32 Added $maxAge
153     * @since 1.34 Added $default
154     *
155     * Although sometimes this can be tainted, taint-check doesn't distinguish separate instances
156     * of MapCacheLRU, so assume untainted to cut down on false positives. See T272134.
157     * @return-taint none
158     */
159    public function get( $key, $maxAge = INF, $default = null ) {
160        if ( !$this->has( $key, $maxAge ) ) {
161            return $default;
162        }
163
164        $this->ping( $key );
165
166        return $this->cache[$key];
167    }
168
169    /**
170     * @param string|int $key
171     * @param string|int $field
172     * @param mixed $value
173     * @param float $initRank
174     */
175    public function setField( $key, $field, $value, $initRank = self::RANK_TOP ) {
176        if ( $this->has( $key ) ) {
177            $this->ping( $key );
178
179            if ( !is_array( $this->cache[$key] ) ) {
180                $type = get_debug_type( $this->cache[$key] );
181                throw new UnexpectedValueException( "Cannot add field to non-array value ('$key' is $type)" );
182            }
183        } else {
184            $this->set( $key, [], $initRank );
185        }
186
187        if ( !is_string( $field ) && !is_int( $field ) ) {
188            throw new UnexpectedValueException(
189                __METHOD__ . ": invalid field for '$key'; must be string or integer." );
190        }
191
192        $this->cache[$key][$field] = $value;
193        $this->timestamps[$key][self::FIELDS][$field] = $this->getCurrentTime();
194    }
195
196    /**
197     * @param string|int $key
198     * @param string|int $field
199     * @param float $maxAge Ignore items older than this many seconds [default: INF]
200     * @return bool
201     * @since 1.32 Added $maxAge
202     */
203    public function hasField( $key, $field, $maxAge = INF ) {
204        $value = $this->get( $key );
205
206        if ( !is_int( $field ) && !is_string( $field ) ) {
207            throw new UnexpectedValueException(
208                __METHOD__ . ": invalid field for '$key'; must be string or integer." );
209        }
210        return is_array( $value )
211            && array_key_exists( $field, $value )
212            && (
213                $maxAge === INF
214                || $maxAge <= 0
215                || $this->getKeyFieldAge( $key, $field ) <= $maxAge
216            );
217    }
218
219    /**
220     * @param string|int $key
221     * @param string|int $field
222     * @param float $maxAge Ignore items older than this many seconds [default: INF]
223     * @return mixed Returns null if the key was not found or is older than $maxAge
224     * @since 1.32 Added $maxAge
225     */
226    public function getField( $key, $field, $maxAge = INF ) {
227        if ( !$this->hasField( $key, $field, $maxAge ) ) {
228            return null;
229        }
230
231        return $this->cache[$key][$field];
232    }
233
234    /**
235     * @return array
236     * @since 1.25
237     */
238    public function getAllKeys() {
239        return array_keys( $this->cache );
240    }
241
242    /**
243     * Get an item with the given key, producing and setting it if not found.
244     *
245     * If the callback returns false, then nothing is stored.
246     *
247     * @since 1.28
248     * @param string|int $key
249     * @param callable $callback Callback that will produce the value
250     * @param float $rank Bottom fraction of the list where keys start off [default: 1.0]
251     * @param float $maxAge Ignore items older than this many seconds [default: INF]
252     * @return mixed The cached value if found or the result of $callback otherwise
253     * @since 1.32 Added $maxAge
254     */
255    public function getWithSetCallback(
256        $key, callable $callback, $rank = self::RANK_TOP, $maxAge = INF
257    ) {
258        if ( $this->has( $key, $maxAge ) ) {
259            $value = $this->get( $key );
260        } else {
261            $value = $callback();
262            if ( $value !== false ) {
263                $this->set( $key, $value, $rank );
264            }
265        }
266
267        return $value;
268    }
269
270    /**
271     * Format a cache key string
272     *
273     * @since 1.41
274     * @param string|int ...$components Key components
275     * @return string
276     */
277    public function makeKey( ...$components ) {
278        // Based on BagOStuff::makeKeyInternal, except without a required
279        // $keygroup prefix. While MapCacheLRU can and is used as cache for
280        // multiple groups of keys, it is equally common for the instance itself
281        // to represent a single group, and thus have keys where the first component
282        // can directly be a user-controlled variable.
283        $key = '';
284        foreach ( $components as $i => $component ) {
285            if ( $i > 0 ) {
286                $key .= ':';
287            }
288            $key .= strtr( $component, [ '%' => '%25', ':' => '%3A' ] );
289        }
290        return $key;
291    }
292
293    /**
294     * Clear one or several cache entries, or all cache entries
295     *
296     * @param string|int|array|null $keys
297     * @return void
298     */
299    public function clear( $keys = null ) {
300        if ( func_num_args() == 0 ) {
301            $this->cache = [];
302            $this->timestamps = [];
303        } else {
304            foreach ( (array)$keys as $key ) {
305                unset( $this->cache[$key] );
306                unset( $this->timestamps[$key] );
307            }
308        }
309    }
310
311    /**
312     * Get the maximum number of keys allowed
313     *
314     * @return int
315     * @since 1.32
316     */
317    public function getMaxSize() {
318        return $this->maxCacheKeys;
319    }
320
321    /**
322     * Resize the maximum number of cache entries, removing older entries as needed
323     *
324     * @param int $maxKeys Maximum number of entries allowed (min 1)
325     * @return void
326     * @since 1.32
327     */
328    public function setMaxSize( int $maxKeys ) {
329        if ( $maxKeys <= 0 ) {
330            throw new InvalidArgumentException( '$maxKeys must be above zero' );
331        }
332
333        $this->maxCacheKeys = $maxKeys;
334        while ( count( $this->cache ) > $this->maxCacheKeys ) {
335            $evictKey = array_key_first( $this->cache );
336            unset( $this->cache[$evictKey] );
337            unset( $this->timestamps[$evictKey] );
338        }
339    }
340
341    /**
342     * Push an entry to the top of the cache
343     *
344     * @param string|int $key
345     */
346    private function ping( $key ) {
347        $item = $this->cache[$key];
348        unset( $this->cache[$key] );
349        $this->cache[$key] = $item;
350    }
351
352    /**
353     * @param string|int $key
354     * @return float UNIX timestamp; the age of the given entry
355     */
356    private function getKeyAge( $key ) {
357        $mtime = $this->timestamps[$key][self::SIMPLE] ?? $this->epoch;
358
359        return ( $this->getCurrentTime() - $mtime );
360    }
361
362    /**
363     * @param string|int $key
364     * @param string|int|null $field
365     * @return float UNIX timestamp; the age of the given entry field
366     */
367    private function getKeyFieldAge( $key, $field ) {
368        $mtime = $this->timestamps[$key][self::FIELDS][$field] ?? $this->epoch;
369
370        return ( $this->getCurrentTime() - $mtime );
371    }
372
373    public function __serialize() {
374        return [
375            'entries' => $this->cache,
376            'timestamps' => $this->timestamps,
377            'maxCacheKeys' => $this->maxCacheKeys,
378        ];
379    }
380
381    public function __unserialize( $data ) {
382        $this->cache = $data['entries'] ?? [];
383        $this->timestamps = $data['timestamps'] ?? [];
384        // Fallback needed for serializations prior to T218511
385        $this->maxCacheKeys = $data['maxCacheKeys'] ?? ( count( $this->cache ) + 1 );
386        $this->epoch = $this->getCurrentTime();
387    }
388
389    /**
390     * @return float UNIX timestamp
391     * @codeCoverageIgnore
392     */
393    protected function getCurrentTime() {
394        return $this->wallClockOverride ?: microtime( true );
395    }
396
397    /**
398     * @param float|null &$time Mock UNIX timestamp for testing
399     * @codeCoverageIgnore
400     */
401    public function setMockTime( &$time ) {
402        $this->wallClockOverride =& $time;
403    }
404}
405
406/** @deprecated class alias since 1.44 */
407class_alias( MapCacheLRU::class, 'MapCacheLRU' );