| b69ab31 | | | 1 | /** |
| b69ab31 | | | 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. |
| b69ab31 | | | 3 | * |
| b69ab31 | | | 4 | * This source code is licensed under the MIT license found in the |
| b69ab31 | | | 5 | * LICENSE file in the root directory of this source tree. |
| b69ab31 | | | 6 | */ |
| b69ab31 | | | 7 | |
| b69ab31 | | | 8 | import type {ValueObject} from 'immutable'; |
| b69ab31 | | | 9 | |
| b69ab31 | | | 10 | import deepEqual from 'fast-deep-equal'; |
| b69ab31 | | | 11 | import {is, isValueObject, List} from 'immutable'; |
| b69ab31 | | | 12 | import {isPromise} from './utils'; |
| b69ab31 | | | 13 | |
| b69ab31 | | | 14 | type LRUKey = LRUHashKey | ValueObject; |
| b69ab31 | | | 15 | type LRUHashKey = string | number | boolean | null | undefined | object; |
| b69ab31 | | | 16 | |
| b69ab31 | | | 17 | /** |
| b69ab31 | | | 18 | * Simple least-recently-used cache which holds at most `max` entries. |
| b69ab31 | | | 19 | * |
| b69ab31 | | | 20 | * The map uses `Immutable.is` [1] instead of `Object.is` to compare keys. |
| b69ab31 | | | 21 | * So it can support complex key types (ex. a tuple of immutable |
| b69ab31 | | | 22 | * collections and other types). Right now this is done by using data |
| b69ab31 | | | 23 | * structures from `immutable`. If the native `Map` supports customizing |
| b69ab31 | | | 24 | * the compare function we can replace `Object.is` with `Immutable.is` |
| b69ab31 | | | 25 | * and use native `Map` for performance. |
| b69ab31 | | | 26 | * |
| b69ab31 | | | 27 | * [1]: https://immutable-js.com/docs/v4.3.0/is()/. |
| b69ab31 | | | 28 | */ |
| b69ab31 | | | 29 | export class LRU<K extends LRUKey, V> { |
| b69ab31 | | | 30 | // Implementation is based on Map having stable insertion order and O(1). |
| b69ab31 | | | 31 | // To support immutable objects, the cache map uses hashCode of "key" as |
| b69ab31 | | | 32 | // the first key, then the actual key in the nested map. |
| b69ab31 | | | 33 | private cache = new Map<LRUHashKey, Map<K, V>>(); |
| b69ab31 | | | 34 | |
| b69ab31 | | | 35 | constructor( |
| b69ab31 | | | 36 | private maxItems: number, |
| b69ab31 | | | 37 | private maxHashCollision = 3, |
| b69ab31 | | | 38 | ) {} |
| b69ab31 | | | 39 | |
| b69ab31 | | | 40 | get(key: K): V | undefined { |
| b69ab31 | | | 41 | let result = undefined; |
| b69ab31 | | | 42 | const hashKey = getHashKey(key); |
| b69ab31 | | | 43 | const valueMap = this.cache.get(hashKey); |
| b69ab31 | | | 44 | if (valueMap !== undefined) { |
| b69ab31 | | | 45 | // Fast path: by object reference. |
| b69ab31 | | | 46 | const maybeValue = valueMap.get(key); |
| b69ab31 | | | 47 | if (maybeValue !== undefined) { |
| b69ab31 | | | 48 | result = maybeValue; |
| b69ab31 | | | 49 | } else { |
| b69ab31 | | | 50 | // Slower path: immutable.is |
| b69ab31 | | | 51 | for (const [k, v] of valueMap) { |
| b69ab31 | | | 52 | // The order matters. `is(key, k)` updates `key` (user-provided) to |
| b69ab31 | | | 53 | // the `k` (cache) reference. See `immutableExt.withSelfUpdateEquals`. |
| b69ab31 | | | 54 | if (is(key, k)) { |
| b69ab31 | | | 55 | result = v; |
| b69ab31 | | | 56 | break; |
| b69ab31 | | | 57 | } |
| b69ab31 | | | 58 | } |
| b69ab31 | | | 59 | } |
| b69ab31 | | | 60 | this.cache.delete(hashKey); |
| b69ab31 | | | 61 | this.cache.set(hashKey, valueMap); |
| b69ab31 | | | 62 | } |
| b69ab31 | | | 63 | return result; |
| b69ab31 | | | 64 | } |
| b69ab31 | | | 65 | |
| b69ab31 | | | 66 | set(key: K, value: V) { |
| b69ab31 | | | 67 | const hashKey = getHashKey(key); |
| b69ab31 | | | 68 | let valueMap = this.cache.get(hashKey); |
| b69ab31 | | | 69 | if (valueMap === undefined || valueMap.size >= this.maxHashCollision) { |
| b69ab31 | | | 70 | valueMap = new Map([[key, value]]); |
| b69ab31 | | | 71 | } else { |
| b69ab31 | | | 72 | valueMap.set(key, value); |
| b69ab31 | | | 73 | } |
| b69ab31 | | | 74 | // ensure refresh by deleting before setting |
| b69ab31 | | | 75 | this.cache.delete(hashKey); |
| b69ab31 | | | 76 | |
| b69ab31 | | | 77 | if (value !== undefined) { |
| b69ab31 | | | 78 | this.cache.set(hashKey, valueMap); |
| b69ab31 | | | 79 | if (this.cache.size > this.maxItems) { |
| b69ab31 | | | 80 | // evict oldest |
| b69ab31 | | | 81 | // iteration order guarantees oldest item is first |
| b69ab31 | | | 82 | const next = this.cache.keys().next(); |
| b69ab31 | | | 83 | // undefined is a valid value, so use iterator `done` to know whether to delete or not. |
| b69ab31 | | | 84 | if (!next.done) { |
| b69ab31 | | | 85 | this.cache.delete(next.value); |
| b69ab31 | | | 86 | } |
| b69ab31 | | | 87 | } |
| b69ab31 | | | 88 | } |
| b69ab31 | | | 89 | } |
| b69ab31 | | | 90 | |
| b69ab31 | | | 91 | delete(key: K) { |
| b69ab31 | | | 92 | const hashKey = getHashKey(key); |
| b69ab31 | | | 93 | this.cache.delete(hashKey); |
| b69ab31 | | | 94 | } |
| b69ab31 | | | 95 | |
| b69ab31 | | | 96 | clear() { |
| b69ab31 | | | 97 | this.cache.clear(); |
| b69ab31 | | | 98 | } |
| b69ab31 | | | 99 | } |
| b69ab31 | | | 100 | |
| b69ab31 | | | 101 | function getHashKey<K extends LRUKey>(key: K): LRUHashKey { |
| b69ab31 | | | 102 | // @ts-expect-error (string)?.hashCode is valid JavaScript. |
| b69ab31 | | | 103 | const hashCodeFunc = key?.hashCode; |
| b69ab31 | | | 104 | if (hashCodeFunc !== undefined) { |
| b69ab31 | | | 105 | return hashCodeFunc.apply(key); |
| b69ab31 | | | 106 | } |
| b69ab31 | | | 107 | return key; |
| b69ab31 | | | 108 | } |
| b69ab31 | | | 109 | |
| b69ab31 | | | 110 | // Neither `unknown` nor `never` works here. |
| b69ab31 | | | 111 | // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| b69ab31 | | | 112 | type AnyFunction<T> = (this: T, ...args: any[]) => any; |
| b69ab31 | | | 113 | |
| b69ab31 | | | 114 | type CacheStats = { |
| b69ab31 | | | 115 | hit?: number; |
| b69ab31 | | | 116 | miss?: number; |
| b69ab31 | | | 117 | skip?: number; |
| b69ab31 | | | 118 | }; |
| b69ab31 | | | 119 | |
| b69ab31 | | | 120 | export interface LRUWithStats extends LRU<LRUKey, unknown> { |
| b69ab31 | | | 121 | stats?: CacheStats; |
| b69ab31 | | | 122 | } |
| b69ab31 | | | 123 | |
| b69ab31 | | | 124 | export interface WithCache { |
| b69ab31 | | | 125 | cache: LRUWithStats; |
| b69ab31 | | | 126 | } |
| b69ab31 | | | 127 | |
| b69ab31 | | | 128 | /** Cache options. */ |
| b69ab31 | | | 129 | type CacheOpts<This> = { |
| b69ab31 | | | 130 | /** |
| b69ab31 | | | 131 | * If set, use the specified cache. |
| b69ab31 | | | 132 | * |
| b69ab31 | | | 133 | * Callsite can assign `cache.stats = {hit: 0, miss: 0, skip: 0}` |
| b69ab31 | | | 134 | * to collect statistics. |
| b69ab31 | | | 135 | */ |
| b69ab31 | | | 136 | cache?: LRUWithStats; |
| b69ab31 | | | 137 | |
| b69ab31 | | | 138 | /** |
| b69ab31 | | | 139 | * If set, and cache is not set, create cache of the given size. |
| b69ab31 | | | 140 | * Default value: 200. |
| b69ab31 | | | 141 | */ |
| b69ab31 | | | 142 | cacheSize?: number; |
| b69ab31 | | | 143 | |
| b69ab31 | | | 144 | /** If set, use the returned values as extra cache keys. */ |
| b69ab31 | | | 145 | getExtraKeys?: (this: This) => unknown[]; |
| b69ab31 | | | 146 | |
| b69ab31 | | | 147 | /** |
| b69ab31 | | | 148 | * Cached functions are expected to be "pure" - give same output |
| b69ab31 | | | 149 | * for the same inputs. If set to true, compare the cached value |
| b69ab31 | | | 150 | * with a fresh recalculation and throws on mismatch. |
| b69ab31 | | | 151 | */ |
| b69ab31 | | | 152 | audit?: boolean; |
| b69ab31 | | | 153 | |
| b69ab31 | | | 154 | /** |
| b69ab31 | | | 155 | * Track the `cache` so it can be cleared by `clearTrackedCache`. |
| b69ab31 | | | 156 | * Default: true. |
| b69ab31 | | | 157 | */ |
| b69ab31 | | | 158 | track?: boolean; |
| b69ab31 | | | 159 | }; |
| b69ab31 | | | 160 | |
| b69ab31 | | | 161 | type DecoratorFunc = (target: unknown, propertyKey: string, descriptor: PropertyDescriptor) => void; |
| b69ab31 | | | 162 | |
| b69ab31 | | | 163 | /** |
| b69ab31 | | | 164 | * Decorator to make a class method cached. |
| b69ab31 | | | 165 | * |
| b69ab31 | | | 166 | * This is similar to calling `cached` on the function, with |
| b69ab31 | | | 167 | * an auto-generated `opts.getExtraKeys` function that turns |
| b69ab31 | | | 168 | * `this` into extra cache keys. Immutable `this` is used |
| b69ab31 | | | 169 | * as the extra cache key directly. Otherwise, cacheable |
| b69ab31 | | | 170 | * properties of `this` are used as extra cache keys. |
| b69ab31 | | | 171 | */ |
| b69ab31 | | | 172 | export function cached<T>(opts?: CacheOpts<T>): DecoratorFunc; |
| b69ab31 | | | 173 | |
| b69ab31 | | | 174 | /** |
| b69ab31 | | | 175 | * Wraps the given function with a LRU cache. |
| b69ab31 | | | 176 | * Returns the wrapped function. |
| b69ab31 | | | 177 | * |
| b69ab31 | | | 178 | * If the function depends on extra inputs outside the |
| b69ab31 | | | 179 | * parameters, use `opts.getExtraKeys` to provide them. |
| b69ab31 | | | 180 | * |
| b69ab31 | | | 181 | * The cache can be accessed via `returnedFunction.cache`. |
| b69ab31 | | | 182 | * |
| b69ab31 | | | 183 | * Cache is used only when all parameters are cacheable [1]. |
| b69ab31 | | | 184 | * For example, if a parameter is a function or `null`, |
| b69ab31 | | | 185 | * then cache is only used when that parameter is `null`, |
| b69ab31 | | | 186 | * since functions are not cacheable. |
| b69ab31 | | | 187 | * |
| b69ab31 | | | 188 | * [1]: See `isCachable` for cacheable types. |
| b69ab31 | | | 189 | */ |
| b69ab31 | | | 190 | export function cached<T, F extends AnyFunction<T>>(func: F, opts?: CacheOpts<T>): F & WithCache; |
| b69ab31 | | | 191 | |
| b69ab31 | | | 192 | // union of the above |
| b69ab31 | | | 193 | export function cached<T, F extends AnyFunction<T>>( |
| b69ab31 | | | 194 | arg1?: F | CacheOpts<T>, |
| b69ab31 | | | 195 | arg2?: CacheOpts<T>, |
| b69ab31 | | | 196 | ): (F & WithCache) | DecoratorFunc { |
| b69ab31 | | | 197 | if (typeof arg1 === 'function') { |
| b69ab31 | | | 198 | // cached(func) |
| b69ab31 | | | 199 | return cachedFunction(arg1, arg2); |
| b69ab31 | | | 200 | } else { |
| b69ab31 | | | 201 | // @cached(opts) |
| b69ab31 | | | 202 | return cacheDecorator(arg1); |
| b69ab31 | | | 203 | } |
| b69ab31 | | | 204 | } |
| b69ab31 | | | 205 | |
| b69ab31 | | | 206 | const trackedCaches = new Set<WeakRef<LRUWithStats>>(); |
| b69ab31 | | | 207 | |
| b69ab31 | | | 208 | /** Clear tracked LRU caches. By default, `@cached` */ |
| b69ab31 | | | 209 | export function clearTrackedCache() { |
| b69ab31 | | | 210 | for (const weakRef of trackedCaches) { |
| b69ab31 | | | 211 | const cache = weakRef.deref(); |
| b69ab31 | | | 212 | if (cache === undefined) { |
| b69ab31 | | | 213 | trackedCaches.delete(weakRef); |
| b69ab31 | | | 214 | } else { |
| b69ab31 | | | 215 | cache.clear(); |
| b69ab31 | | | 216 | } |
| b69ab31 | | | 217 | } |
| b69ab31 | | | 218 | } |
| b69ab31 | | | 219 | |
| b69ab31 | | | 220 | function cachedFunction<T, F extends AnyFunction<T>>(func: F, opts?: CacheOpts<T>): F & WithCache { |
| b69ab31 | | | 221 | const cache: LRUWithStats = opts?.cache ?? new LRU(opts?.cacheSize ?? 200); |
| b69ab31 | | | 222 | const audit = opts?.audit ?? false; |
| b69ab31 | | | 223 | const getExtraKeys = opts?.getExtraKeys; |
| b69ab31 | | | 224 | const track = opts?.track ?? true; |
| b69ab31 | | | 225 | if (track) { |
| b69ab31 | | | 226 | trackedCaches.add(new WeakRef(cache)); |
| b69ab31 | | | 227 | } |
| b69ab31 | | | 228 | const cachedFunc = function (this: T, ...args: Parameters<F>): ReturnType<F> { |
| b69ab31 | | | 229 | const stats = cache.stats; |
| b69ab31 | | | 230 | if (!args.every(isCachable)) { |
| b69ab31 | | | 231 | if (stats != null) { |
| b69ab31 | | | 232 | stats.skip = (stats.skip ?? 0) + 1; |
| b69ab31 | | | 233 | } |
| b69ab31 | | | 234 | return func.apply(this, args) as ReturnType<F>; |
| b69ab31 | | | 235 | } |
| b69ab31 | | | 236 | const cacheKey = List(getExtraKeys ? [...getExtraKeys.apply(this), ...args] : args); |
| b69ab31 | | | 237 | const cachedValue = cache.get(cacheKey); |
| b69ab31 | | | 238 | if (cachedValue !== undefined) { |
| b69ab31 | | | 239 | if (stats != null) { |
| b69ab31 | | | 240 | stats.hit = (stats.hit ?? 0) + 1; |
| b69ab31 | | | 241 | } |
| b69ab31 | | | 242 | if (audit) { |
| b69ab31 | | | 243 | const result = func.apply(this, args) as ReturnType<F>; |
| b69ab31 | | | 244 | const equal = isValueObject(result) |
| b69ab31 | | | 245 | ? is(result, cachedValue) |
| b69ab31 | | | 246 | : deepEqual(result, cachedValue); |
| b69ab31 | | | 247 | if (!equal) { |
| b69ab31 | | | 248 | const argsStr = args.map(a => a.toString()).join(', '); |
| b69ab31 | | | 249 | throw new Error( |
| b69ab31 | | | 250 | `cached value mismatch on ${func.name}(${argsStr}): cached ${cachedValue}, actual ${result}`, |
| b69ab31 | | | 251 | ); |
| b69ab31 | | | 252 | } |
| b69ab31 | | | 253 | } |
| b69ab31 | | | 254 | return cachedValue as ReturnType<F>; |
| b69ab31 | | | 255 | } |
| b69ab31 | | | 256 | if (stats != null) { |
| b69ab31 | | | 257 | stats.miss = (stats.miss ?? 0) + 1; |
| b69ab31 | | | 258 | } |
| b69ab31 | | | 259 | const result = func.apply(this, args) as ReturnType<F>; |
| b69ab31 | | | 260 | if (isPromise(result)) { |
| b69ab31 | | | 261 | return result.then((value: unknown) => { |
| b69ab31 | | | 262 | cache.set(cacheKey, value); |
| b69ab31 | | | 263 | return value; |
| b69ab31 | | | 264 | }); |
| b69ab31 | | | 265 | } |
| b69ab31 | | | 266 | cache.set(cacheKey, result); |
| b69ab31 | | | 267 | return result; |
| b69ab31 | | | 268 | }; |
| b69ab31 | | | 269 | cachedFunc.cache = cache; |
| b69ab31 | | | 270 | return cachedFunc as WithCache & F; |
| b69ab31 | | | 271 | } |
| b69ab31 | | | 272 | |
| b69ab31 | | | 273 | // See https://www.typescriptlang.org/docs/handbook/decorators.html. |
| b69ab31 | | | 274 | function cacheDecorator<T>(opts?: CacheOpts<T>) { |
| b69ab31 | | | 275 | const getExtraKeys = |
| b69ab31 | | | 276 | opts?.getExtraKeys ?? |
| b69ab31 | | | 277 | function (this: T): unknown[] { |
| b69ab31 | | | 278 | // Use `this` as extra key if it's a value object (hash + eq). |
| b69ab31 | | | 279 | if (isValueObject(this)) { |
| b69ab31 | | | 280 | return [this]; |
| b69ab31 | | | 281 | } |
| b69ab31 | | | 282 | // Scan through cacheable properties. |
| b69ab31 | | | 283 | if (this != null && typeof this === 'object') { |
| b69ab31 | | | 284 | return Object.values(this).filter(isCachable); |
| b69ab31 | | | 285 | } |
| b69ab31 | | | 286 | // Give up - do not add extra cache keys. |
| b69ab31 | | | 287 | return []; |
| b69ab31 | | | 288 | }; |
| b69ab31 | | | 289 | return function (_target: unknown, _propertyKey: string, descriptor: PropertyDescriptor) { |
| b69ab31 | | | 290 | const originalFunc = descriptor.value; |
| b69ab31 | | | 291 | descriptor.value = cachedFunction(originalFunc, {...opts, getExtraKeys}); |
| b69ab31 | | | 292 | }; |
| b69ab31 | | | 293 | } |
| b69ab31 | | | 294 | |
| b69ab31 | | | 295 | /** Cache an "instance" function like `this.foo`. */ |
| b69ab31 | | | 296 | export function cachedMethod<T, F extends AnyFunction<T>>(originalFunc: F, opts?: CacheOpts<T>): F { |
| b69ab31 | | | 297 | const getExtraKeys = |
| b69ab31 | | | 298 | opts?.getExtraKeys ?? |
| b69ab31 | | | 299 | function (this: T): unknown[] { |
| b69ab31 | | | 300 | // Use `this` as extra key if it's a value object (hash + eq). |
| b69ab31 | | | 301 | if (isValueObject(this)) { |
| b69ab31 | | | 302 | return [this]; |
| b69ab31 | | | 303 | } |
| b69ab31 | | | 304 | // Scan through cacheable properties. |
| b69ab31 | | | 305 | if (this != null && typeof this === 'object') { |
| b69ab31 | | | 306 | return Object.values(this).filter(isCachable); |
| b69ab31 | | | 307 | } |
| b69ab31 | | | 308 | // Give up - do not add extra cache keys. |
| b69ab31 | | | 309 | return []; |
| b69ab31 | | | 310 | }; |
| b69ab31 | | | 311 | return cachedFunction(originalFunc, {...opts, getExtraKeys}); |
| b69ab31 | | | 312 | } |
| b69ab31 | | | 313 | |
| b69ab31 | | | 314 | const cachableTypeNames = new Set([ |
| b69ab31 | | | 315 | 'number', |
| b69ab31 | | | 316 | 'string', |
| b69ab31 | | | 317 | 'boolean', |
| b69ab31 | | | 318 | 'symbol', |
| b69ab31 | | | 319 | 'bigint', |
| b69ab31 | | | 320 | 'undefined', |
| b69ab31 | | | 321 | 'null', |
| b69ab31 | | | 322 | ]); |
| b69ab31 | | | 323 | |
| b69ab31 | | | 324 | /** |
| b69ab31 | | | 325 | * Returns true if `arg` can be used as cache keys. |
| b69ab31 | | | 326 | * Primitive types (string, number, boolean, null, undefined) |
| b69ab31 | | | 327 | * can be used as cache keys. |
| b69ab31 | | | 328 | * Objects can be used as cache keys if they are immutable. |
| b69ab31 | | | 329 | */ |
| b69ab31 | | | 330 | function isCachable(arg: unknown): boolean { |
| b69ab31 | | | 331 | // null is a special case, since typeof(null) returns 'object'. |
| b69ab31 | | | 332 | if (arg == null) { |
| b69ab31 | | | 333 | return true; |
| b69ab31 | | | 334 | } |
| b69ab31 | | | 335 | const typeName = typeof arg; |
| b69ab31 | | | 336 | if (cachableTypeNames.has(typeName)) { |
| b69ab31 | | | 337 | return true; |
| b69ab31 | | | 338 | } |
| b69ab31 | | | 339 | if (typeName === 'object' && isValueObject(arg)) { |
| b69ab31 | | | 340 | return true; |
| b69ab31 | | | 341 | } |
| b69ab31 | | | 342 | return false; |
| b69ab31 | | | 343 | } |