Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
97.20% |
104 / 107 |
|
85.00% |
17 / 20 |
CRAP | |
0.00% |
0 / 1 |
MapCacheLRU | |
97.20% |
104 / 107 |
|
85.00% |
17 / 20 |
54 | |
0.00% |
0 / 1 |
__construct | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
2.06 | |||
newFromArray | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
toArray | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
set | |
100.00% |
16 / 16 |
|
100.00% |
1 / 1 |
5 | |||
has | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
6 | |||
get | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
setField | |
90.91% |
10 / 11 |
|
0.00% |
0 / 1 |
5.02 | |||
hasField | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
7 | |||
getField | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
getAllKeys | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getWithSetCallback | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
makeKey | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
clear | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
getMaxSize | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
setMaxSize | |
85.71% |
6 / 7 |
|
0.00% |
0 / 1 |
3.03 | |||
ping | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
getKeyAge | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
getKeyFieldAge | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
__serialize | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
__unserialize | |
100.00% |
4 / 4 |
|
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 | */ |
21 | use 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 | */ |
34 | class 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 = get_debug_type( $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 | } |