add 3 caching implementations for various use cases
All checks were successful
Build and test / build-and-test (18.x) (push) Successful in 13s
Build and test / build-and-test (20.x) (push) Successful in 12s

This commit is contained in:
James Brumond 2023-08-26 15:59:05 -07:00
parent 5acbfd4555
commit 13a64c0d2d
Signed by: james
GPG Key ID: E8F2FC44BAA3357A
7 changed files with 433 additions and 8 deletions

8
package-lock.json generated
View File

@ -9,14 +9,14 @@
"version": "0.1.0", "version": "0.1.0",
"license": "ISC", "license": "ISC",
"devDependencies": { "devDependencies": {
"@js/types": "^0.2.0", "@js/types": "^0.2.1",
"typescript": "^5.1.3" "typescript": "^5.1.3"
} }
}, },
"node_modules/@js/types": { "node_modules/@js/types": {
"version": "0.2.0", "version": "0.2.1",
"resolved": "https://gitea.jbrumond.me/api/packages/js/npm/%40js%2Ftypes/-/0.2.0/types-0.2.0.tgz", "resolved": "https://gitea.jbrumond.me/api/packages/js/npm/%40js%2Ftypes/-/0.2.1/types-0.2.1.tgz",
"integrity": "sha512-7CsWkTQjuP9+Y+5Fv1vK0o6QDshJzCB0b8hTld3bu1j+wELrwBuLT3iTcfdB8H3hLLdohZfDMCWMl0ZY+0bseg==", "integrity": "sha512-+TpX2Sfl0yBHptcDnXqdPKazL+HZDb6s/ax07sJBYt14gGvgUNlf6/QbsQHwOg53T6DkXYld0VgktQiQkRQw7w==",
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },

View File

@ -18,7 +18,7 @@
"author": "James Brumond <https://jbrumond.me>", "author": "James Brumond <https://jbrumond.me>",
"license": "ISC", "license": "ISC",
"devDependencies": { "devDependencies": {
"@js/types": "^0.2.0", "@js/types": "^0.2.1",
"typescript": "^5.1.3" "typescript": "^5.1.3"
} }
} }

View File

@ -2,3 +2,67 @@
In-memory caching/memoization utilities 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);
```

238
src/async.ts Normal file
View File

@ -0,0 +1,238 @@
import type { AsyncFunc, AsyncResult, Params } from '@js/types';
export interface MemoAsyncOptions<T extends AsyncFunc> {
/**
* 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<T>;
/**
* 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<T>;
/**
* Callback to run when evicting a record from the cache (usually
* because it expired)
*/
on_evict?: MemoAsyncOnEvict<T>;
/**
* 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<T extends AsyncFunc> {
(params: Params<T>) : string;
}
export interface MemoAsyncTTLFunc<T extends AsyncFunc> {
(params: Params<T>, result: ReturnType<T>) : number;
}
export interface MemoAsyncOnEvict<T extends AsyncFunc> {
(evicted: MemoAsyncStoredResult<T>) : any;
}
export interface MemoAsyncStoredResult<T extends AsyncFunc> {
key: string;
created: number;
resolved: number;
expires: number;
promise: ReturnType<T>;
result: AsyncResult<T>;
hits: number;
timer: ReturnType<typeof setTimeout>;
}
export function memo<T extends AsyncFunc>(opts: MemoAsyncOptions<T>) : T {
const get_key = opts.key ?? default_key;
const storage: Record<string, MemoAsyncStoredResult<T>> = Object.create(null);
const refreshing: Record<string, MemoAsyncStoredResult<T>> = Object.create(null);
function memoized(...params: Params<T>) : ReturnType<T> {
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<T>;
store_record(key, params, promise);
return promise;
}
function evict(record: MemoAsyncStoredResult<T>) {
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<T>, params: Params<T>) {
const record: MemoAsyncStoredResult<T> = {
key: old_record.key,
created: Date.now(),
resolved: null,
expires: null,
promise: opts.func(...params) as ReturnType<T>,
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<T>, promise: ReturnType<T>) {
const record: MemoAsyncStoredResult<T> = {
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<T>) {
record.timer = null;
if (storage[record.key] === record) {
delete storage[record.key];
}
}
function on_resolve(record: MemoAsyncStoredResult<T>, params: Params<T>, result: AsyncResult<T>) {
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<T>, params: Params<T>, result: AsyncResult<T>) {
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<T>, 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);
}

108
src/full.ts Normal file
View File

@ -0,0 +1,108 @@
import type { Func, Params } from '@js/types';
export interface MemoOptions<T extends Func> {
/**
* 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<T>;
/**
* 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<T>;
/**
* Callback to run when evicting a record from the cache (usually
* because it expired)
*/
on_evict?: MemoOnEvict<T>;
}
export interface MemoKeyFunc<T extends Func> {
(params: Params<T>) : string;
}
export interface MemoTTLFunc<T extends Func> {
(params: Params<T>, result: ReturnType<T>) : number;
}
export interface MemoOnEvict<T extends Func> {
(evicted: MemoStoredResult<T>) : any;
}
export interface MemoStoredResult<T extends Func> {
key: string;
created: number;
expires: number;
result: ReturnType<T>;
hits: number;
timer: ReturnType<typeof setTimeout>;
}
export function memo<T extends Func>(opts: MemoOptions<T>) : T {
const get_key = opts.key ?? default_key;
const storage: Record<string, MemoStoredResult<T>> = Object.create(null);
function memoized(...args: Params<T>) : ReturnType<T> {
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<T>, result: ReturnType<T>) {
const record: MemoStoredResult<T> = {
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);
}

View File

@ -1,4 +1,3 @@
export function hello() : string { export * from './simple';
return 'hello'; export * from './full';
}

16
src/simple.ts Normal file
View File

@ -0,0 +1,16 @@
export function memo_simple<T>(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;
};
}