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