From 13a64c0d2d27040f7cf9cde7cfa64d50af6cc3bc Mon Sep 17 00:00:00 2001 From: James Brumond Date: Sat, 26 Aug 2023 15:59:05 -0700 Subject: [PATCH] add 3 caching implementations for various use cases --- package-lock.json | 8 +- package.json | 2 +- readme.md | 64 +++++++++++++ src/async.ts | 238 ++++++++++++++++++++++++++++++++++++++++++++++ src/full.ts | 108 +++++++++++++++++++++ src/index.ts | 5 +- src/simple.ts | 16 ++++ 7 files changed, 433 insertions(+), 8 deletions(-) create mode 100644 src/async.ts create mode 100644 src/full.ts create mode 100644 src/simple.ts diff --git a/package-lock.json b/package-lock.json index 0f29a5c..28d7d0d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,14 +9,14 @@ "version": "0.1.0", "license": "ISC", "devDependencies": { - "@js/types": "^0.2.0", + "@js/types": "^0.2.1", "typescript": "^5.1.3" } }, "node_modules/@js/types": { - "version": "0.2.0", - "resolved": "https://gitea.jbrumond.me/api/packages/js/npm/%40js%2Ftypes/-/0.2.0/types-0.2.0.tgz", - "integrity": "sha512-7CsWkTQjuP9+Y+5Fv1vK0o6QDshJzCB0b8hTld3bu1j+wELrwBuLT3iTcfdB8H3hLLdohZfDMCWMl0ZY+0bseg==", + "version": "0.2.1", + "resolved": "https://gitea.jbrumond.me/api/packages/js/npm/%40js%2Ftypes/-/0.2.1/types-0.2.1.tgz", + "integrity": "sha512-+TpX2Sfl0yBHptcDnXqdPKazL+HZDb6s/ax07sJBYt14gGvgUNlf6/QbsQHwOg53T6DkXYld0VgktQiQkRQw7w==", "dev": true, "license": "ISC" }, diff --git a/package.json b/package.json index a767a07..52a1d2f 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "author": "James Brumond ", "license": "ISC", "devDependencies": { - "@js/types": "^0.2.0", + "@js/types": "^0.2.1", "typescript": "^5.1.3" } } diff --git a/readme.md b/readme.md index 3f345e1..9f99ec2 100644 --- a/readme.md +++ b/readme.md @@ -2,3 +2,67 @@ In-memory caching/memoization utilities --- + +## Install + +```bash +npm install --save @js/memo +``` + +## Usage + +```ts +import { memo } from '@js/memo'; + +function get_data_from_slow_source(name: string) : string { + // .... +} + +const get_data_cached = memo({ + func: get_data_from_slow_source, + ttl: 30_000, +}); +``` + +### Custom cache key generation + +```ts +import { memo } from '@js/memo'; + +interface SomeObject { + id: string; + foo: string; + bar: number; + baz: { + qux: string; + }; +} + +function get_data_from_slow_source(obj: SomeObject) : string { + // .... +} + +const get_data_cached = memo({ + func: get_data_from_slow_source, + ttl: 30_000, + + // Pull just the `id` field from the first parameter to use + // as the cache key + key([ obj ]) { + return obj.id; + }, +}); +``` + +### Simple functions with no params + +```ts + +import { memo_simple } from '@js/memo'; + +function get_data_from_slow_source() : string { + // .... +} + +const get_data_cached = memo_simple(30_000, get_data_from_slow_source); +``` diff --git a/src/async.ts b/src/async.ts new file mode 100644 index 0000000..927b2df --- /dev/null +++ b/src/async.ts @@ -0,0 +1,238 @@ + +import type { AsyncFunc, AsyncResult, Params } from '@js/types'; + +export interface MemoAsyncOptions { + /** + * The function to call and cache results from + */ + func: T; + + /** + * Time-to-live for records in the cache. After this amount of time, + * a value is considered "stale", and by default, will be evicted + * from the cache. + * + * This value can be a number (ms), or a function which receives the + * params and result of the call and returns a number for each record. + */ + ttl: number | MemoAsyncTTLFunc; + + /** + * Custom function for generating a cache key. If not provided, a + * built-in default will be used instead. (At the moment, that + * default is `JSON.stringify()`, which is not guranteed to be + * stable or particularly fast; Providing a better implementation + * for most functions is recommended) + */ + key?: MemoAsyncKeyFunc; + + /** + * Callback to run when evicting a record from the cache (usually + * because it expired) + */ + on_evict?: MemoAsyncOnEvict; + + /** + * If provided and non-zero, the cache will keep values in storage + * for this long (ms) past their expiration times. During this window, + * a subsequent request for the value will result in a new call to the + * source to refresh the cache, but the stale value in cache will still + * be served immediately to reduce cache misses + */ + stale_ttl?: number; + + /** + * If provided and non-zero, outstanding (not yet fulfilled) promises + * will be cached and returned to subsequent callers for the given + * amount of time (ms). If the promise does not resolve before the TTL + * passes, it will be evicted from the cache to allow for another attempt. + * If the promise rejects, it will be dropped from the cache. If the + * promise resolves, the actual result will then be stored + */ + promise_ttl?: number; +} + +export interface MemoAsyncKeyFunc { + (params: Params) : string; +} + +export interface MemoAsyncTTLFunc { + (params: Params, result: ReturnType) : number; +} + +export interface MemoAsyncOnEvict { + (evicted: MemoAsyncStoredResult) : any; +} + +export interface MemoAsyncStoredResult { + key: string; + created: number; + resolved: number; + expires: number; + promise: ReturnType; + result: AsyncResult; + hits: number; + timer: ReturnType; +} + +export function memo(opts: MemoAsyncOptions) : T { + const get_key = opts.key ?? default_key; + const storage: Record> = Object.create(null); + const refreshing: Record> = Object.create(null); + + function memoized(...params: Params) : ReturnType { + const key = get_key(params); + + if (storage[key]) { + storage[key].hits++; + + // If the record is stale (but still stored because of `stale_ttl`), start + // a new refresh call in the background + if (Date.now() >= storage[key].expires) { + refresh_stale(storage[key], params); + } + + return storage[key].result; + } + + const promise = opts.func(...params) as ReturnType; + store_record(key, params, promise); + return promise; + } + + function evict(record: MemoAsyncStoredResult) { + if (storage[record.key] === record) { + delete storage[record.key]; + + if (refreshing[record.key]?.timer) { + // If another call is already working on refreshing this record and + // it has an active `promise_ttl` timer, promote it into the main + // storage now + storage[record.key] = refreshing[record.key]; + delete refreshing[record.key]; + } + + if (opts.on_evict) { + opts.on_evict(record); + } + } + } + + function refresh_stale(old_record: MemoAsyncStoredResult, params: Params) { + const record: MemoAsyncStoredResult = { + key: old_record.key, + created: Date.now(), + resolved: null, + expires: null, + promise: opts.func(...params) as ReturnType, + result: null, + hits: 0, + timer: null, + }; + + refreshing[record.key] = record; + + if (opts.promise_ttl) { + record.timer = setTimeout(() => on_timeout(record), opts.promise_ttl); + } + + record.promise.then( + (result) => on_refresh_resolve(record, params, result), + (reason) => on_reject(record, reason) + ); + } + + function store_record(key: string, params: Params, promise: ReturnType) { + const record: MemoAsyncStoredResult = { + key, + created: Date.now(), + resolved: null, + expires: null, + promise, + result: null, + hits: 0, + timer: null, + }; + + if (opts.promise_ttl) { + storage[key] = record; + record.timer = setTimeout(() => on_timeout(record), opts.promise_ttl); + } + + promise.then( + (result) => on_resolve(record, params, result), + (reason) => on_reject(record, reason) + ); + } + + function on_timeout(record: MemoAsyncStoredResult) { + record.timer = null; + + if (storage[record.key] === record) { + delete storage[record.key]; + } + } + + function on_resolve(record: MemoAsyncStoredResult, params: Params, result: AsyncResult) { + if (record.timer) { + clearTimeout(record.timer); + } + + const ttl = typeof opts.ttl === 'function' + ? opts.ttl(params, result) + : opts.ttl; + + const evict_after = opts.stale_ttl + ? ttl + opts.stale_ttl + : ttl; + + record.result = result; + record.resolved = Date.now(); + record.expires = record.resolved + ttl; + record.timer = setTimeout(() => evict(record), evict_after); + storage[record.key] = record; + } + + function on_refresh_resolve(record: MemoAsyncStoredResult, params: Params, result: AsyncResult) { + if (record.timer) { + clearTimeout(record.timer); + } + + const ttl = typeof opts.ttl === 'function' + ? opts.ttl(params, result) + : opts.ttl; + + const evict_after = opts.stale_ttl + ? ttl + opts.stale_ttl + : ttl; + + record.result = result; + record.resolved = Date.now(); + record.expires = record.resolved + ttl; + record.timer = setTimeout(() => evict(record), evict_after); + + if (storage[record.key]?.timer) { + clearTimeout(storage[record.key].timer); + evict(storage[record.key]); + } + + storage[record.key] = record; + } + + function on_reject(record: MemoAsyncStoredResult, reason: any) { + if (record.timer) { + // If a timeout timer was set due to `promise_ttl`, clear it now + clearTimeout(record.timer); + } + + if (storage[record.key] === record) { + delete storage[record.key]; + } + } + + return memoized as unknown as T; +} + +function default_key(...params: any[]) : string { + return JSON.stringify(params); +} diff --git a/src/full.ts b/src/full.ts new file mode 100644 index 0000000..f97df30 --- /dev/null +++ b/src/full.ts @@ -0,0 +1,108 @@ + +import type { Func, Params } from '@js/types'; + +export interface MemoOptions { + /** + * The function to call and cache results from + */ + func: T; + + /** + * Time-to-live for records in the cache. After this amount of time, + * a value is considered "stale", and by default, will be evicted + * from the cache. + * + * This value can be a number (ms), or a function which receives the + * params and result of the call and returns a number for each record. + */ + ttl: number | MemoTTLFunc; + + /** + * Custom function for generating a cache key. If not provided, a + * built-in default will be used instead. (At the moment, that + * default is `JSON.stringify()`, which is not guranteed to be + * stable or particularly fast; Providing a better implementation + * for most functions is recommended) + */ + key?: MemoKeyFunc; + + /** + * Callback to run when evicting a record from the cache (usually + * because it expired) + */ + on_evict?: MemoOnEvict; +} + +export interface MemoKeyFunc { + (params: Params) : string; +} + +export interface MemoTTLFunc { + (params: Params, result: ReturnType) : number; +} + +export interface MemoOnEvict { + (evicted: MemoStoredResult) : any; +} + +export interface MemoStoredResult { + key: string; + created: number; + expires: number; + result: ReturnType; + hits: number; + timer: ReturnType; +} + +export function memo(opts: MemoOptions) : T { + const get_key = opts.key ?? default_key; + const storage: Record> = Object.create(null); + + function memoized(...args: Params) : ReturnType { + const key = get_key(args); + + if (storage[key]) { + storage[key].hits++; + return storage[key].result; + } + + const result = opts.func(...args); + store_record(key, args, result); + return result; + } + + function evict(key: string) { + if (storage[key]) { + const evicted = storage[key]; + delete storage[key]; + + if (opts.on_evict) { + opts.on_evict(evicted); + } + } + } + + function store_record(key: string, params: Params, result: ReturnType) { + const record: MemoStoredResult = { + key, + created: Date.now(), + expires: null, + result, + hits: 0, + timer: null, + }; + + const ttl = typeof opts.ttl === 'function' + ? opts.ttl(params, result) + : opts.ttl; + + record.expires = record.created + ttl; + record.timer = setTimeout(() => evict(record.key), ttl); + } + + return memoized as unknown as T; +} + +function default_key(...params: any[]) : string { + return JSON.stringify(params); +} diff --git a/src/index.ts b/src/index.ts index 4fa933d..2fbb35f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,3 @@ -export function hello() : string { - return 'hello'; -} +export * from './simple'; +export * from './full'; diff --git a/src/simple.ts b/src/simple.ts new file mode 100644 index 0000000..47ae239 --- /dev/null +++ b/src/simple.ts @@ -0,0 +1,16 @@ + +export function memo_simple(ttl: number, func: () => T) : () => T { + let value: T; + let expires: number; + + return function memoized() : T { + const now = Date.now(); + + if (value == null || expires < now) { + value = func(); + expires = now + ttl; + } + + return value; + }; +}