Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 122 additions & 2 deletions src/lru-memo-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,28 @@ export interface MemoCacheMetrics {
measuredSpeedupPercent: number | null;
}

/** One cache entry as written to JSON. `expiresAt` is epoch ms, or `null` for no TTL. */
export interface SerializedCacheEntry<K, V> {
key: K;
value: V;
expiresAt: number | null;
}

/** Envelope written by {@link LruMemoCache.serialize}. Versioned so a future
* shape change can be rejected instead of mis-read. */
export interface SerializedCache<K, V> {
version: 1;
maxSize: number;
entries: SerializedCacheEntry<K, V>[];
}

/** Cache keys are used as JSON object-free tuples, so only primitives are allowed. */
function assertSerializableKey(key: unknown): asserts key is string | number {
if (typeof key !== 'string' && typeof key !== 'number') {
throw new TypeError(`LruMemoCache can only serialize string/number keys, got ${typeof key}`);
}
}

export interface LruMemoCacheOptions {
/** Entries kept before the least-recently-used one is evicted. */
maxEntries: number;
Expand Down Expand Up @@ -94,14 +116,23 @@ export class LruMemoCache<K, V> {
if (ttlMs !== undefined && (!Number.isFinite(ttlMs) || ttlMs <= 0)) {
throw new RangeError('LruMemoCache ttlMs must be a positive finite number');
}
this.insert(key, value, ttlMs === undefined ? null : Date.now() + ttlMs);
}

/**
* Shared insertion path for {@link set} and {@link hydrate}. `expiresAt` is an
* absolute epoch-ms deadline (or `null`), which lets hydration restore the
* original TTL instead of restarting it.
*/
private insert(key: K, value: V, expiresAt: number | null): void {
if (this.store.has(key)) {
this.remove(key);
} else if (this.store.size >= this.maxSize) {
const oldest = this.store.keys().next().value;
if (oldest !== undefined) this.remove(oldest);
}
this.store.set(key, value);
if (ttlMs !== undefined) this.expiresAt.set(key, Date.now() + ttlMs);
if (expiresAt !== null) this.expiresAt.set(key, expiresAt);
}

recordHit(elapsedMs: number): void {
Expand All @@ -123,6 +154,74 @@ export class LruMemoCache<K, V> {
this.missExecutionTimeMs = 0;
}

/**
* Snapshot the cache as JSON so it can be persisted between processes — the
* Next.js SSR case: the server component builds the cache on one request and
* the next request hydrates it instead of re-querying the chain (#977).
*
* Expired entries are dropped, and the order is preserved (least recently
* used first) so {@link hydrate} restores the same eviction order.
*
* Values must be JSON-serializable; anything else (a `Map`, a class instance
* with private fields, a function) is silently flattened by `JSON.stringify`,
* so keep cached values to plain data.
*/
serialize(now: number = Date.now()): string {
const entries: SerializedCacheEntry<K, V>[] = [];
for (const [key, value] of this.store) {
assertSerializableKey(key);
const expiresAt = this.expiresAt.get(key) ?? null;
if (expiresAt !== null && now >= expiresAt) continue;
entries.push({ key, value, expiresAt });
}
const payload: SerializedCache<K, V> = { version: 1, maxSize: this.maxSize, entries };
return JSON.stringify(payload);
}

/**
* Load entries produced by {@link serialize}. Expired entries are skipped and
* the cache's own `maxSize` still applies, so a snapshot taken with a larger
* limit cannot overflow this instance.
*
* Metrics are left untouched: a hydrated cache has no measured hit/miss
* timings of its own, and inventing them would make `measuredSpeedupPercent`
* a lie.
*
* @returns the number of entries actually restored.
*/
hydrate(json: string, now: number = Date.now()): number {
let payload: unknown;
try {
payload = JSON.parse(json);
} catch {
throw new SyntaxError('LruMemoCache.hydrate: input is not valid JSON');
}
if (typeof payload !== 'object' || payload === null) {
throw new TypeError('LruMemoCache.hydrate: expected an object');
}
const { version, entries } = payload as { version?: unknown; entries?: unknown };
if (version !== 1) {
throw new TypeError(`LruMemoCache.hydrate: unsupported snapshot version ${String(version)}`);
}
if (!Array.isArray(entries)) {
throw new TypeError('LruMemoCache.hydrate: entries must be an array');
}

let restored = 0;
for (const raw of entries) {
const entry = raw as Partial<SerializedCacheEntry<K, V>> | null;
if (!entry || typeof entry !== 'object' || !('key' in entry)) continue;
assertSerializableKey(entry.key);
const expiresAt = entry.expiresAt ?? null;
if (typeof expiresAt === 'number' && now >= expiresAt) continue;

// Insert directly so the absolute deadline survives the round trip.
this.insert(entry.key, entry.value as V, typeof expiresAt === 'number' ? expiresAt : null);
restored += 1;
}
return restored;
}

metrics(): MemoCacheMetrics {
const avgHitMs = this.cacheHits > 0 ? this.hitExecutionTimeMs / this.cacheHits : null;
const avgMissMs = this.cacheMisses > 0 ? this.missExecutionTimeMs / this.cacheMisses : null;
Expand All @@ -133,4 +232,25 @@ export class LruMemoCache<K, V> {

return { cacheHits: this.cacheHits, cacheMisses: this.cacheMisses, measuredSpeedupPercent };
}
}
}

/**
* Persist `cache` as JSON. Thin wrapper so callers that only hold the cache
* through an interface can serialize without importing the class.
*/
export function serializeCache<K, V>(cache: LruMemoCache<K, V>, now: number = Date.now()): string {
return cache.serialize(now);
}

/**
* Restore a snapshot produced by {@link serializeCache} into `cache`.
*
* @returns the number of entries restored.
*/
export function hydrateCache<K, V>(
cache: LruMemoCache<K, V>,
json: string,
now: number = Date.now(),
): number {
return cache.hydrate(json, now);
}
131 changes: 131 additions & 0 deletions src/tests/lru-memo-cache-serialize.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { describe, it, expect, vi } from 'vitest';
import { LruMemoCache, serializeCache, hydrateCache } from '../lru-memo-cache.js';

interface Meta {
name: string;
decimals: number;
}

const TTL = 60_000;

describe('LruMemoCache serialize/hydrate (#977)', () => {
it('round-trips values, including their remaining TTL', () => {
const source = new LruMemoCache<string, Meta>(10);
source.set('USDC', { name: 'USD Coin', decimals: 7 });
source.set('XLM', { name: 'Lumen', decimals: 7 }, TTL);

const snapshot = source.serialize();
const restored = new LruMemoCache<string, Meta>(10);
expect(restored.hydrate(snapshot)).toBe(2);

expect(restored.get('USDC')).toEqual({ name: 'USD Coin', decimals: 7 });
expect(restored.get('XLM')).toEqual({ name: 'Lumen', decimals: 7 });
expect(restored.size).toBe(2);
});

it('keeps the absolute deadline instead of restarting the TTL', () => {
vi.useFakeTimers();
try {
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
const source = new LruMemoCache<string, number>(5);
source.set('a', 1, 10_000);

// Snapshot 9s later: only 1s of life is left.
vi.setSystemTime(new Date('2026-01-01T00:00:09Z'));
const snapshot = source.serialize();

const restored = new LruMemoCache<string, number>(5);
restored.hydrate(snapshot);

vi.setSystemTime(new Date('2026-01-01T00:00:10.5Z'));
expect(restored.get('a')).toBeUndefined();
} finally {
vi.useRealTimers();
}
});

it('drops entries that already expired before the snapshot', () => {
const source = new LruMemoCache<string, number>(5);
source.set('fresh', 1, TTL);
source.set('stale', 2, TTL);

// Snapshot as if 2 minutes had passed: both are gone.
const snapshot = source.serialize(Date.now() + 120_000);
const restored = new LruMemoCache<string, number>(5);
expect(restored.hydrate(snapshot)).toBe(0);
expect(restored.size).toBe(0);
});

it('preserves LRU order so eviction still drops the coldest entry', () => {
const source = new LruMemoCache<string, number>(3);
source.set('a', 1);
source.set('b', 2);
source.set('c', 3);
source.get('a'); // 'a' becomes most recently used → order is b, c, a

const restored = new LruMemoCache<string, number>(3);
restored.hydrate(source.serialize());

restored.set('d', 4); // evicts 'b', the least recently used
expect(restored.get('b')).toBeUndefined();
expect(restored.get('a')).toBe(1);
expect(restored.get('d')).toBe(4);
});

it('respects the destination cache capacity', () => {
const source = new LruMemoCache<string, number>(10);
for (let i = 0; i < 10; i++) source.set(`k${i}`, i);

const restored = new LruMemoCache<string, number>(3);
expect(restored.hydrate(source.serialize())).toBe(10);
expect(restored.size).toBe(3);
// The newest three survive.
expect([restored.get('k7'), restored.get('k8'), restored.get('k9')]).toEqual([7, 8, 9]);
});

it('does not fabricate hit/miss metrics while hydrating', () => {
const source = new LruMemoCache<string, number>(5);
source.set('a', 1);
const restored = new LruMemoCache<string, number>(5);
restored.hydrate(source.serialize());

expect(restored.metrics()).toEqual({
cacheHits: 0,
cacheMisses: 0,
measuredSpeedupPercent: null,
});
});

it('rejects malformed input instead of returning a half-filled cache', () => {
const cache = new LruMemoCache<string, number>(5);
expect(() => cache.hydrate('{not json')).toThrow(SyntaxError);
expect(() => cache.hydrate('[]')).toThrow(TypeError);
expect(() => cache.hydrate(JSON.stringify({ version: 99, entries: [] }))).toThrow(/version/);
expect(() => cache.hydrate(JSON.stringify({ version: 1, entries: 'nope' }))).toThrow(/entries/);
});

it('refuses keys that JSON would silently mangle', () => {
const cache = new LruMemoCache<object, number>(5);
cache.set({ id: 1 }, 1);
expect(() => cache.serialize()).toThrow(/string\/number keys/);
});

it('exposes the same behaviour through the exported helpers', () => {
const source = new LruMemoCache<string, number>(5);
source.set('a', 1);
const json = serializeCache(source);

const restored = new LruMemoCache<string, number>(5);
expect(hydrateCache(restored, json)).toBe(1);
expect(restored.get('a')).toBe(1);
});

it('produces JSON a server can pass to another request verbatim', () => {
const source = new LruMemoCache<string, Meta>(5);
source.set('USDC', { name: 'USD Coin', decimals: 7 }, TTL);
const json = source.serialize();

expect(typeof json).toBe('string');
expect(JSON.parse(json)).toMatchObject({ version: 1, maxSize: 5 });
});
});
Loading