Add three cache types (request-scoped, client TTL, server byte-capped LRU)
This commit is contained in:
@@ -28,6 +28,7 @@
|
||||
"i18next-icu": "^2.0.0",
|
||||
"i18next-resources-to-backend": "^1.0.0",
|
||||
"intl-messageformat": "^10.0.0",
|
||||
"lru-cache": "^10.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-i18next": "^15.0.0",
|
||||
|
||||
Generated
+3
@@ -29,6 +29,9 @@ importers:
|
||||
intl-messageformat:
|
||||
specifier: ^10.0.0
|
||||
version: 10.7.18
|
||||
lru-cache:
|
||||
specifier: ^10.0.0
|
||||
version: 10.4.3
|
||||
react:
|
||||
specifier: ^18.2.0
|
||||
version: 18.3.1
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
RequestScopedCache,
|
||||
ClientMemoryCache,
|
||||
ServerLruCache,
|
||||
cacheKey,
|
||||
} from "./cache.js";
|
||||
|
||||
/**
|
||||
* lru-cache captures the global `performance` object at module-load time.
|
||||
* `vi.useFakeTimers()` replaces `globalThis.performance` with a new object,
|
||||
* so the faked `performance.now()` never reaches lru-cache's internal `perf`.
|
||||
*
|
||||
* We work around this by patching `performance.now` in-place via `vi.hoisted`
|
||||
* (which runs before any imports) and controlling time manually.
|
||||
*/
|
||||
const mockNow = vi.hoisted(() => {
|
||||
let time = 1000;
|
||||
const original = performance.now.bind(performance);
|
||||
return {
|
||||
install: () => {
|
||||
time = 1000;
|
||||
performance.now = () => time;
|
||||
},
|
||||
advance: (ms: number) => {
|
||||
time += ms;
|
||||
},
|
||||
restore: () => {
|
||||
performance.now = original;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe("cacheKey", () => {
|
||||
it("creates a deterministic key from endpoint, query, and locale", () => {
|
||||
const k1 = cacheKey("/flights", { date: "2025-01-15", dep: "SVO" }, "ru");
|
||||
const k2 = cacheKey("/flights", { date: "2025-01-15", dep: "SVO" }, "ru");
|
||||
expect(k1).toBe(k2);
|
||||
});
|
||||
|
||||
it("produces different keys for different locales", () => {
|
||||
const k1 = cacheKey("/flights", { date: "2025-01-15" }, "ru");
|
||||
const k2 = cacheKey("/flights", { date: "2025-01-15" }, "en");
|
||||
expect(k1).not.toBe(k2);
|
||||
});
|
||||
|
||||
it("produces different keys for different queries", () => {
|
||||
const k1 = cacheKey("/flights", { a: "1" }, "ru");
|
||||
const k2 = cacheKey("/flights", { b: "2" }, "ru");
|
||||
expect(k1).not.toBe(k2);
|
||||
});
|
||||
|
||||
it("sorts query keys for stability", () => {
|
||||
const k1 = cacheKey("/flights", { b: "2", a: "1" }, "ru");
|
||||
const k2 = cacheKey("/flights", { a: "1", b: "2" }, "ru");
|
||||
expect(k1).toBe(k2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("RequestScopedCache", () => {
|
||||
it("deduplicates concurrent promises for the same key", async () => {
|
||||
const cache = new RequestScopedCache();
|
||||
let callCount = 0;
|
||||
const factory = () => {
|
||||
callCount++;
|
||||
return Promise.resolve("value");
|
||||
};
|
||||
|
||||
const p1 = cache.get<string>("k") ?? (() => { const p = factory(); cache.set("k", p); return p; })();
|
||||
const p2 = cache.get<string>("k") ?? (() => { const p = factory(); cache.set("k", p); return p; })();
|
||||
|
||||
expect(await p1).toBe("value");
|
||||
expect(await p2).toBe("value");
|
||||
expect(callCount).toBe(1);
|
||||
});
|
||||
|
||||
it("returns undefined for unknown keys", () => {
|
||||
const cache = new RequestScopedCache();
|
||||
expect(cache.get("missing")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ClientMemoryCache", () => {
|
||||
it("stores and retrieves values", () => {
|
||||
const cache = new ClientMemoryCache<string>({ max: 10, defaultTtlMs: 5000 });
|
||||
cache.set("k", "v");
|
||||
expect(cache.get("k")).toBe("v");
|
||||
});
|
||||
|
||||
it("evicts after TTL expires", () => {
|
||||
mockNow.install();
|
||||
const cache = new ClientMemoryCache<string>({ max: 10, defaultTtlMs: 100 });
|
||||
cache.set("k", "v");
|
||||
expect(cache.get("k")).toBe("v");
|
||||
|
||||
mockNow.advance(101);
|
||||
expect(cache.get("k")).toBeUndefined();
|
||||
mockNow.restore();
|
||||
});
|
||||
|
||||
it("supports per-entry TTL override", () => {
|
||||
mockNow.install();
|
||||
const cache = new ClientMemoryCache<string>({ max: 10, defaultTtlMs: 5000 });
|
||||
cache.set("k", "v", 50);
|
||||
mockNow.advance(51);
|
||||
expect(cache.get("k")).toBeUndefined();
|
||||
mockNow.restore();
|
||||
});
|
||||
|
||||
it("evicts LRU when max count reached", () => {
|
||||
const cache = new ClientMemoryCache<string>({ max: 2, defaultTtlMs: 60000 });
|
||||
cache.set("a", "1");
|
||||
cache.set("b", "2");
|
||||
cache.set("c", "3");
|
||||
expect(cache.get("a")).toBeUndefined();
|
||||
expect(cache.get("b")).toBe("2");
|
||||
expect(cache.get("c")).toBe("3");
|
||||
});
|
||||
|
||||
it("tracks size", () => {
|
||||
const cache = new ClientMemoryCache<string>({ max: 10, defaultTtlMs: 5000 });
|
||||
expect(cache.size).toBe(0);
|
||||
cache.set("k", "v");
|
||||
expect(cache.size).toBe(1);
|
||||
cache.delete("k");
|
||||
expect(cache.size).toBe(0);
|
||||
});
|
||||
|
||||
it("clear() removes all entries", () => {
|
||||
const cache = new ClientMemoryCache<string>({ max: 10, defaultTtlMs: 5000 });
|
||||
cache.set("a", "1");
|
||||
cache.set("b", "2");
|
||||
cache.clear();
|
||||
expect(cache.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ServerLruCache", () => {
|
||||
it("stores and retrieves values", () => {
|
||||
const cache = new ServerLruCache<string>({
|
||||
maxBytes: 1024,
|
||||
defaultTtlMs: 5000,
|
||||
});
|
||||
cache.set("k", "hello");
|
||||
expect(cache.get("k")).toBe("hello");
|
||||
});
|
||||
|
||||
it("evicts when byte cap is exceeded", () => {
|
||||
const cache = new ServerLruCache<string>({
|
||||
maxBytes: 50,
|
||||
defaultTtlMs: 60000,
|
||||
});
|
||||
// Each string is ~5-10 bytes when JSON.stringify'd, but the key is also counted
|
||||
cache.set("a", "x".repeat(20));
|
||||
cache.set("b", "y".repeat(20));
|
||||
cache.set("c", "z".repeat(20));
|
||||
// At least one of the earlier entries should be evicted
|
||||
const remaining = [cache.get("a"), cache.get("b"), cache.get("c")].filter(Boolean);
|
||||
expect(remaining.length).toBeLessThan(3);
|
||||
});
|
||||
|
||||
it("tracks calculatedSize", () => {
|
||||
const cache = new ServerLruCache<string>({
|
||||
maxBytes: 10000,
|
||||
defaultTtlMs: 5000,
|
||||
});
|
||||
expect(cache.calculatedSize).toBe(0);
|
||||
cache.set("k", "hello");
|
||||
expect(cache.calculatedSize).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("evicts after TTL expires", () => {
|
||||
mockNow.install();
|
||||
const cache = new ServerLruCache<string>({
|
||||
maxBytes: 10000,
|
||||
defaultTtlMs: 100,
|
||||
});
|
||||
cache.set("k", "v");
|
||||
expect(cache.get("k")).toBe("v");
|
||||
mockNow.advance(101);
|
||||
expect(cache.get("k")).toBeUndefined();
|
||||
mockNow.restore();
|
||||
});
|
||||
|
||||
it("supports custom sizeCalculation", () => {
|
||||
const cache = new ServerLruCache<{ data: string }>({
|
||||
maxBytes: 100,
|
||||
defaultTtlMs: 5000,
|
||||
sizeCalculation: (value) => value.data.length,
|
||||
});
|
||||
cache.set("k", { data: "x".repeat(50) });
|
||||
expect(cache.calculatedSize).toBe(50);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { LRUCache } from "lru-cache";
|
||||
import type { Language } from "@/i18n/resolver";
|
||||
|
||||
// --- Cache key convention ---
|
||||
|
||||
export function cacheKey(
|
||||
endpoint: string,
|
||||
query: Record<string, unknown>,
|
||||
locale: Language,
|
||||
): string {
|
||||
const sortedQuery = Object.keys(query)
|
||||
.sort()
|
||||
.map((k) => `${k}=${String(query[k])}`)
|
||||
.join("&");
|
||||
return `${locale}:${endpoint}?${sortedQuery}`;
|
||||
}
|
||||
|
||||
// --- (1) Request-scoped SSR dedup cache ---
|
||||
|
||||
export class RequestScopedCache {
|
||||
private readonly store = new Map<string, Promise<unknown>>();
|
||||
|
||||
get<T>(key: string): Promise<T> | undefined {
|
||||
return this.store.get(key) as Promise<T> | undefined;
|
||||
}
|
||||
|
||||
set<T>(key: string, promise: Promise<T>): void {
|
||||
this.store.set(key, promise);
|
||||
}
|
||||
}
|
||||
|
||||
// --- (2) Client-side per-tab in-memory TTL cache ---
|
||||
|
||||
export class ClientMemoryCache<T extends NonNullable<unknown>> {
|
||||
private readonly cache: LRUCache<string, T>;
|
||||
|
||||
constructor(options: { max: number; defaultTtlMs: number }) {
|
||||
this.cache = new LRUCache<string, T>({
|
||||
max: options.max,
|
||||
ttl: options.defaultTtlMs,
|
||||
ttlResolution: 0,
|
||||
});
|
||||
}
|
||||
|
||||
get(key: string): T | undefined {
|
||||
return this.cache.get(key);
|
||||
}
|
||||
|
||||
set(key: string, value: T, ttlMs?: number): void {
|
||||
this.cache.set(key, value, ttlMs !== undefined ? { ttl: ttlMs } : undefined);
|
||||
}
|
||||
|
||||
delete(key: string): void {
|
||||
this.cache.delete(key);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.cache.clear();
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.cache.size;
|
||||
}
|
||||
}
|
||||
|
||||
// --- (3) Shared per-VM LRU with byte cap ---
|
||||
|
||||
export class ServerLruCache<T extends NonNullable<unknown>> {
|
||||
private readonly cache: LRUCache<string, T>;
|
||||
|
||||
constructor(options: {
|
||||
maxBytes: number;
|
||||
defaultTtlMs: number;
|
||||
sizeCalculation?: (value: T, key: string) => number;
|
||||
}) {
|
||||
this.cache = new LRUCache<string, T>({
|
||||
maxSize: options.maxBytes,
|
||||
ttl: options.defaultTtlMs,
|
||||
ttlResolution: 0,
|
||||
sizeCalculation:
|
||||
options.sizeCalculation ??
|
||||
((value: T) => {
|
||||
try {
|
||||
return JSON.stringify(value).length;
|
||||
} catch {
|
||||
return 1024; // fallback for non-serializable values
|
||||
}
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
get(key: string): T | undefined {
|
||||
return this.cache.get(key);
|
||||
}
|
||||
|
||||
set(key: string, value: T, ttlMs?: number): void {
|
||||
this.cache.set(key, value, ttlMs !== undefined ? { ttl: ttlMs } : undefined);
|
||||
}
|
||||
|
||||
delete(key: string): void {
|
||||
this.cache.delete(key);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.cache.clear();
|
||||
}
|
||||
|
||||
get calculatedSize(): number {
|
||||
return this.cache.calculatedSize;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user