@beignet/core
Version:
Core framework primitives for Beignet
311 lines (279 loc) • 9.69 kB
text/typescript
/**
* @beignet/core/memo
*
* Request-scoped memoization for port lookups and other async functions.
*
* `createMemo(fn)` deduplicates calls with the same arguments for the
* lifetime of one request: the server enters a memo scope around every HTTP
* request and `runServiceContext(...)` execution, and the scope's cache dies
* with it. There is no TTL and no cross-request state, so memoized reads can
* never serve data staler than the request that fetched them.
*
* Outside any scope — plain scripts, `createServiceContext(...)` callers —
* memoized functions call straight through without caching.
*/
import { AsyncLocalStorage } from "node:async_hooks";
/**
* Error thrown when a default cache key cannot be derived from arguments.
*/
export class MemoKeyError extends Error {
constructor(message: string) {
super(message);
this.name = "MemoKeyError";
}
}
/**
* Instrumentation event emitted by memoized functions inside a scope that
* carries a recorder.
*/
export interface MemoInstrumentationEvent {
/** Whether the call was served from the scope cache or filled it. */
kind: "hit" | "miss";
/** Memo name from `createMemo` options, defaulting to the function name. */
memo: string;
/** Encoded argument key for the call. */
key: string;
/** Fill duration for misses, rounded to two decimals. */
durationMs?: number;
/** Set when a miss's underlying call rejected (the entry is evicted). */
failed?: boolean;
}
type MemoScopeStore = {
entries: Map<string, unknown>;
record?: (event: MemoInstrumentationEvent) => void;
};
const memoScope = new AsyncLocalStorage<MemoScopeStore>();
/**
* Run a function inside a memo scope with an explicit recorder.
*
* Internal to the server runtime — apps should use `runMemoScope(...)`.
* Always uses `AsyncLocalStorage.run` (never `enterWith`), so callers'
* continuations never resume through a dangling frame.
*/
export function runWithMemoScope<T>(
options: { record?: (event: MemoInstrumentationEvent) => void },
fn: () => T,
): T {
return memoScope.run({ entries: new Map(), record: options.record }, fn);
}
/**
* Run a function inside a fresh memo scope.
*
* Use this in scripts and unit tests to give memoized functions a cache
* lifetime. Nested scopes start empty; an active recorder is inherited so
* instrumentation keeps flowing.
*/
export function runMemoScope<T>(fn: () => T): T {
return runWithMemoScope({ record: memoScope.getStore()?.record }, fn);
}
/**
* Memoized function returned by `createMemo(...)`.
*/
export type Memoized<F extends (...args: never[]) => unknown> = F & {
/**
* Drop the current scope's entry for these arguments, so the next call
* re-executes. Call this from mutation methods that make a memoized read
* stale within the same request.
*
* @returns Whether an entry existed.
*/
invalidate(...args: Parameters<F>): boolean;
/**
* Drop all of this function's entries in the current scope.
*/
clear(): void;
};
/**
* Options for `createMemo(...)`.
*/
export interface CreateMemoOptions<F extends (...args: never[]) => unknown> {
/**
* Name used in instrumentation events and key errors. Defaults to the
* wrapped function's name.
*/
name?: string;
/**
* Build the cache key from the call arguments. Defaults to a structural,
* type-tagged encoding of the arguments that throws `MemoKeyError` for
* values it cannot encode deterministically (functions, symbols, class
* instances, circular references).
*/
key?: (...args: Parameters<F>) => string;
}
type EncodedMemoValue = readonly [string, ...unknown[]];
function describeArgType(value: unknown): string {
if (value === null) return "null";
if (typeof value !== "object") return typeof value;
const ctor = (value as object).constructor?.name;
return ctor && ctor !== "Object" ? `class instance (${ctor})` : "object";
}
function encodeMemoValue(
value: unknown,
memoName: string,
position: number,
seen: WeakSet<object>,
): EncodedMemoValue {
if (value === null) return ["null"];
switch (typeof value) {
case "string":
return ["string", value];
case "number":
return ["number", Object.is(value, -0) ? "-0" : String(value)];
case "boolean":
return ["boolean", value ? "true" : "false"];
case "undefined":
return ["undefined"];
case "bigint":
return ["bigint", value.toString()];
case "object":
break;
default:
throw new MemoKeyError(
`[Beignet memo] Cannot build a default cache key for "${memoName}": argument ${position} is a ${typeof value}. Pass options.key to compute keys for these arguments.`,
);
}
if (value instanceof Date) {
return ["date", value.toISOString()];
}
if (seen.has(value as object)) {
throw new MemoKeyError(
`[Beignet memo] Cannot build a default cache key for "${memoName}": argument ${position} contains a circular reference. Pass options.key to compute keys for these arguments.`,
);
}
if (Array.isArray(value)) {
seen.add(value);
const encoded: EncodedMemoValue = [
"array",
value.map((item) => encodeMemoValue(item, memoName, position, seen)),
];
seen.delete(value);
return encoded;
}
const proto = Object.getPrototypeOf(value);
if (proto !== null && proto !== Object.prototype) {
throw new MemoKeyError(
`[Beignet memo] Cannot build a default cache key for "${memoName}": argument ${position} is a ${describeArgType(value)}. Pass options.key to compute keys for these arguments.`,
);
}
seen.add(value as object);
const record = value as Record<string, unknown>;
const encoded: EncodedMemoValue = [
"object",
Object.keys(record)
.sort()
.map((key) => [
key,
encodeMemoValue(record[key], memoName, position, seen),
]),
];
seen.delete(value as object);
return encoded;
}
function defaultArgsKey(memoName: string, args: readonly unknown[]): string {
return JSON.stringify(
args.map((arg, index) =>
encodeMemoValue(arg, memoName, index, new WeakSet()),
),
);
}
function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
return (
value !== null &&
(typeof value === "object" || typeof value === "function") &&
typeof (value as { then?: unknown }).then === "function"
);
}
function roundDuration(startedAt: number): number {
return Math.round((performance.now() - startedAt) * 100) / 100;
}
let nextMemoInstance = 0;
/**
* Wrap an async lookup so calls with the same arguments run once per request.
*
* Within a memo scope the first call executes and every later call with the
* same key returns the same value — including the same in-flight promise, so
* concurrent calls share one execution. Rejected promises are evicted, so a
* failed lookup is retried by the next call rather than memoized. Outside a
* scope the function calls through uncached.
*
* Memoize reads, not mutations. When a mutation in the same infra module
* makes a memoized read stale, call `memoized.invalidate(...)` with the
* read's arguments.
*
* @param fn - Function to memoize. `this` is not forwarded.
* @param options - Optional name and key builder.
* @returns The function with `invalidate(...)` and `clear()` attached.
*/
export function createMemo<F extends (...args: never[]) => unknown>(
fn: F,
options: CreateMemoOptions<F> = {},
): Memoized<F> {
const name = options.name ?? (fn.name || "anonymous");
// The map key is namespaced by instance, not name, so two memos that share
// a name can never read each other's entries. The unit separator keeps
// prefixes unambiguous: instance 1 never prefix-matches instance 11.
nextMemoInstance += 1;
const instancePrefix = `${nextMemoInstance}\u001f`;
const keyOf = (args: Parameters<F>): string =>
options.key ? options.key(...args) : defaultArgsKey(name, args);
const memoized = ((...args: Parameters<F>): unknown => {
const store = memoScope.getStore();
if (!store) return fn(...args);
const argsKey = keyOf(args);
const mapKey = instancePrefix + argsKey;
if (store.entries.has(mapKey)) {
store.record?.({ kind: "hit", memo: name, key: argsKey });
return store.entries.get(mapKey);
}
const startedAt = performance.now();
const value = fn(...args);
store.entries.set(mapKey, value);
if (isPromiseLike(value)) {
Promise.resolve(value).then(
() => {
store.record?.({
kind: "miss",
memo: name,
key: argsKey,
durationMs: roundDuration(startedAt),
});
},
() => {
if (store.entries.get(mapKey) === value) {
store.entries.delete(mapKey);
}
store.record?.({
kind: "miss",
memo: name,
key: argsKey,
durationMs: roundDuration(startedAt),
failed: true,
});
},
);
} else {
store.record?.({
kind: "miss",
memo: name,
key: argsKey,
durationMs: roundDuration(startedAt),
});
}
return value;
}) as Memoized<F>;
memoized.invalidate = (...args: Parameters<F>): boolean => {
const store = memoScope.getStore();
if (!store) return false;
return store.entries.delete(instancePrefix + keyOf(args));
};
memoized.clear = (): void => {
const store = memoScope.getStore();
if (!store) return;
for (const key of [...store.entries.keys()]) {
if (key.startsWith(instancePrefix)) {
store.entries.delete(key);
}
}
};
return memoized;
}