240 lines
7.1 KiB
TypeScript
240 lines
7.1 KiB
TypeScript
export interface CacheEntry<V> {
|
|
value: V;
|
|
createdAt: number;
|
|
expiresAt: number;
|
|
staleUntil: number;
|
|
tags: string[];
|
|
}
|
|
|
|
export type CacheLookup<V> = { state: "miss" } | { state: "fresh" | "stale"; entry: CacheEntry<V> };
|
|
|
|
export interface CacheSetOptions {
|
|
ttlMs?: number;
|
|
staleWhileRevalidateMs?: number;
|
|
tags?: string[];
|
|
}
|
|
|
|
export interface TagCacheOptions {
|
|
ttlMs?: number;
|
|
staleWhileRevalidateMs?: number;
|
|
maxEntries?: number;
|
|
clock?: () => number;
|
|
onEvent?: (event: CacheEvent) => void;
|
|
}
|
|
|
|
export interface CacheEvent {
|
|
operation: "hit" | "stale" | "miss" | "set" | "delete" | "invalidate" | "clear" | "load";
|
|
key?: string;
|
|
tags?: string[];
|
|
at: number;
|
|
}
|
|
|
|
export interface CacheSnapshotEntry {
|
|
key: string;
|
|
state: "fresh" | "stale";
|
|
createdAt: number;
|
|
expiresAt: number;
|
|
staleUntil: number;
|
|
tags: string[];
|
|
}
|
|
|
|
export class TagCache<V = unknown> {
|
|
private entries = new Map<string, CacheEntry<V>>();
|
|
private tagIndex = new Map<string, Set<string>>();
|
|
private pending = new Map<string, Promise<V>>();
|
|
private locks = new Map<string, Promise<void>>();
|
|
private revisions = new Map<string, number>();
|
|
private readonly ttlMs: number;
|
|
private readonly staleMs: number;
|
|
private readonly maxEntries: number;
|
|
private readonly clock: () => number;
|
|
private readonly onEvent?: (event: CacheEvent) => void;
|
|
|
|
constructor(options: TagCacheOptions = {}) {
|
|
this.ttlMs = options.ttlMs ?? 60_000;
|
|
this.staleMs = options.staleWhileRevalidateMs ?? 0;
|
|
this.maxEntries = Math.max(1, options.maxEntries ?? 10_000);
|
|
this.clock = options.clock ?? Date.now;
|
|
this.onEvent = options.onEvent;
|
|
}
|
|
|
|
private emit(event: Omit<CacheEvent, "at">): void {
|
|
this.onEvent?.({ ...event, at: this.clock() });
|
|
}
|
|
|
|
lookup(key: string): CacheLookup<V> {
|
|
const entry = this.entries.get(key);
|
|
if (!entry) {
|
|
this.emit({ operation: "miss", key });
|
|
return { state: "miss" };
|
|
}
|
|
const now = this.clock();
|
|
if (entry.staleUntil <= now) {
|
|
this.delete(key);
|
|
this.emit({ operation: "miss", key });
|
|
return { state: "miss" };
|
|
}
|
|
this.entries.delete(key);
|
|
this.entries.set(key, entry);
|
|
const state = entry.expiresAt > now ? "fresh" : "stale";
|
|
this.emit({ operation: state === "fresh" ? "hit" : "stale", key, tags: entry.tags });
|
|
return { state, entry };
|
|
}
|
|
|
|
get(key: string): V | undefined {
|
|
const hit = this.lookup(key);
|
|
return hit.state === "miss" ? undefined : hit.entry.value;
|
|
}
|
|
|
|
set(key: string, value: V, options: CacheSetOptions = {}): void {
|
|
this.bump(key);
|
|
this.store(key, value, options);
|
|
}
|
|
|
|
private store(key: string, value: V, options: CacheSetOptions): void {
|
|
this.removeEntry(key);
|
|
const now = this.clock();
|
|
const ttlMs = Math.max(0, options.ttlMs ?? this.ttlMs);
|
|
const staleMs = Math.max(0, options.staleWhileRevalidateMs ?? this.staleMs);
|
|
const tags = [...new Set(options.tags ?? [])];
|
|
const entry: CacheEntry<V> = {
|
|
value,
|
|
createdAt: now,
|
|
expiresAt: now + ttlMs,
|
|
staleUntil: now + ttlMs + staleMs,
|
|
tags,
|
|
};
|
|
this.entries.set(key, entry);
|
|
this.emit({ operation: "set", key, tags });
|
|
for (const tag of tags) {
|
|
const keys = this.tagIndex.get(tag) ?? new Set<string>();
|
|
keys.add(key);
|
|
this.tagIndex.set(tag, keys);
|
|
}
|
|
while (this.entries.size > this.maxEntries) {
|
|
const oldest = this.entries.keys().next().value as string | undefined;
|
|
if (oldest === undefined) break;
|
|
this.delete(oldest);
|
|
}
|
|
}
|
|
|
|
async getOrLoad(
|
|
key: string,
|
|
loader: () => V | Promise<V>,
|
|
options: CacheSetOptions = {},
|
|
): Promise<V> {
|
|
const hit = this.lookup(key);
|
|
if (hit.state === "fresh") return hit.entry.value;
|
|
if (hit.state === "stale") {
|
|
if (!this.pending.has(key)) {
|
|
const revision = this.revision(key);
|
|
this.emit({ operation: "load", key, tags: options.tags });
|
|
const refresh = Promise.resolve()
|
|
.then(loader)
|
|
.then((value) => {
|
|
if (this.revision(key) === revision) this.store(key, value, options);
|
|
return value;
|
|
})
|
|
.finally(() => this.pending.delete(key));
|
|
this.pending.set(key, refresh);
|
|
}
|
|
return hit.entry.value;
|
|
}
|
|
const existing = this.pending.get(key);
|
|
if (existing) return existing;
|
|
const revision = this.revision(key);
|
|
this.emit({ operation: "load", key, tags: options.tags });
|
|
const pending = Promise.resolve()
|
|
.then(loader)
|
|
.then((value) => {
|
|
if (this.revision(key) === revision) this.store(key, value, options);
|
|
return value;
|
|
})
|
|
.finally(() => this.pending.delete(key));
|
|
this.pending.set(key, pending);
|
|
return pending;
|
|
}
|
|
|
|
/** Serialize arbitrary cache-adjacent work for a key without storing its result. */
|
|
async withLock<T>(key: string, task: () => T | Promise<T>): Promise<T> {
|
|
const previous = this.locks.get(key) ?? Promise.resolve();
|
|
let release!: () => void;
|
|
const current = new Promise<void>((resolve) => (release = resolve));
|
|
const queued = previous.then(() => current);
|
|
this.locks.set(key, queued);
|
|
await previous;
|
|
try {
|
|
return await task();
|
|
} finally {
|
|
release();
|
|
if (this.locks.get(key) === queued) this.locks.delete(key);
|
|
}
|
|
}
|
|
|
|
delete(key: string): boolean {
|
|
const removed = this.removeEntry(key);
|
|
if (removed || this.pending.has(key)) this.bump(key);
|
|
if (removed) this.emit({ operation: "delete", key });
|
|
return removed;
|
|
}
|
|
|
|
private removeEntry(key: string): boolean {
|
|
const entry = this.entries.get(key);
|
|
if (!entry) return false;
|
|
this.entries.delete(key);
|
|
for (const tag of entry.tags) {
|
|
const keys = this.tagIndex.get(tag);
|
|
keys?.delete(key);
|
|
if (keys?.size === 0) this.tagIndex.delete(tag);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
invalidateTag(tag: string): number {
|
|
const keys = [...(this.tagIndex.get(tag) ?? [])];
|
|
for (const key of keys) this.delete(key);
|
|
this.emit({ operation: "invalidate", tags: [tag] });
|
|
return keys.length;
|
|
}
|
|
|
|
invalidateTags(tags: Iterable<string>): number {
|
|
const requested = [...tags];
|
|
const keys = new Set<string>();
|
|
for (const tag of requested) for (const key of this.tagIndex.get(tag) ?? []) keys.add(key);
|
|
for (const key of keys) this.delete(key);
|
|
this.emit({ operation: "invalidate", tags: requested });
|
|
return keys.size;
|
|
}
|
|
|
|
clear(): void {
|
|
for (const key of new Set([...this.entries.keys(), ...this.pending.keys()])) this.bump(key);
|
|
this.entries.clear();
|
|
this.tagIndex.clear();
|
|
this.emit({ operation: "clear" });
|
|
}
|
|
|
|
get size(): number {
|
|
return this.entries.size;
|
|
}
|
|
|
|
snapshot(): CacheSnapshotEntry[] {
|
|
const now = this.clock();
|
|
return [...this.entries.entries()].map(([key, entry]) => ({
|
|
key,
|
|
state: entry.expiresAt > now ? "fresh" : "stale",
|
|
createdAt: entry.createdAt,
|
|
expiresAt: entry.expiresAt,
|
|
staleUntil: entry.staleUntil,
|
|
tags: [...entry.tags],
|
|
}));
|
|
}
|
|
|
|
private revision(key: string): number {
|
|
return this.revisions.get(key) ?? 0;
|
|
}
|
|
|
|
private bump(key: string): void {
|
|
this.revisions.set(key, this.revision(key) + 1);
|
|
}
|
|
}
|