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