Files
UHD_Media_Tool/packages/db/test/metadata-cache.test.ts
T
rootandClaude Opus 5.5 ad472a1a19 feat(db): 统一元数据缓存层 MetadataCache
- 两级:L1 进程内存(按 region 限条数的 LRU、5 分钟)+ L2 PG kv_cache;读 L1 → L2 → 源,写两级
- 迁移 0086:kv_cache 加 fresh_until(过期先用旧值的宽限期)与 pinned(LRU 淘汰跳过);新表 cache_stat 按天 × region 汇总命中
- stale-while-revalidate:宽限期 = TTL 的 50%(上限 7 天),旧值先返回、同 key 后台只刷一次,刷新失败继续用旧值并记警告
- 同进程同 key 并发只回源一次;回源抛错不缓存;PG 读写失败按未命中 / 不写处理
- PgCache.delete / clear 发 pg_notify(umt_cache_invalidate),listenCacheInvalidation 收到后丢 L1;监听在线才启用 L1,断线停用、重连后整体清空
- PgCache.get 只认新鲜条目,getEntry 带回旧值;renew 只续新鲜条目并可标 pinned

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-06 19:37:06 +08:00

210 lines
9.8 KiB
TypeScript

import { Kysely, PostgresDialect, sql } from "kysely";
import pg from "pg";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { type DB, listenCacheInvalidation, MetadataCache, migrateToLatest, PgCache } from "../src/index.ts";
// 统一元数据缓存(0086):两级读写、跨进程失效、过期先用旧值、同请求合并、失败不缓存、pinned 不被淘汰、命中统计。
const url = process.env.DATABASE_URL ?? "postgres://umt:umt@localhost:55433/umt";
const skip = process.env.UMT_SKIP_DB_TESTS === "1";
const schema = `umt_mcache_${process.pid}_${Date.now().toString(36)}`;
const until = async (cond: () => boolean | Promise<boolean>, ms = 5000) => {
const end = Date.now() + ms;
while (!(await cond())) {
if (Date.now() > end) throw new Error("等待超时");
await new Promise((r) => setTimeout(r, 20));
}
};
describe.skipIf(skip)("MetadataCache(真实 PG)", () => {
let admin: Kysely<unknown>;
let db: Kysely<DB>;
/** 另一个「进程」:独立连接池 */
let db2: Kysely<DB>;
const pool = () =>
new Kysely<DB>({
dialect: new PostgresDialect({
pool: new pg.Pool({ connectionString: url, max: 4, options: `-c search_path=${schema},public` }),
}),
});
beforeAll(async () => {
admin = new Kysely({ dialect: new PostgresDialect({ pool: new pg.Pool({ connectionString: url, max: 1 }) }) });
await sql.raw(`create schema ${schema}`).execute(admin);
db = pool();
db2 = pool();
await migrateToLatest(db, { migrationTableSchema: schema });
});
afterAll(async () => {
await db?.destroy();
await db2?.destroy();
await sql.raw(`drop schema if exists ${schema} cascade`).execute(admin);
await admin?.destroy();
});
it("读 L1 → L2 → 源,写两级;另一进程从 L2 命中;统计各级命中", async () => {
const a = new MetadataCache(new PgCache(db), { l1: true });
const b = new MetadataCache(new PgCache(db2), { l1: true });
let n = 0;
const load = async () => ({ v: ++n });
const p = { ttl: 600 };
expect(await a.fetch("r1", "k", p, load)).toEqual({ v: 1 });
expect(await a.fetch("r1", "k", p, load)).toEqual({ v: 1 });
expect(await b.fetch("r1", "k", p, load)).toEqual({ v: 1 });
expect(await b.fetch("r1", "k", p, load)).toEqual({ v: 1 });
expect(n).toBe(1);
expect(a.pendingStats().get("r1")).toMatchObject({ misses: 1, l1Hits: 1, l2Hits: 0 });
expect(b.pendingStats().get("r1")).toMatchObject({ misses: 0, l2Hits: 1, l1Hits: 1 });
// L1 返回副本:调用方改了不污染缓存
const got = await a.fetch<{ v: number }>("r1", "k", p, load);
got.v = 99;
expect(await a.fetch("r1", "k", p, load)).toEqual({ v: 1 });
// 有宽限期:fresh_until = now + ttl,expires_at 再加 ttl 的 50%
const r = await sql<{ g: number }>`select extract(epoch from expires_at - fresh_until)::int as g
from kv_cache where region = 'r1' and key = 'k'`.execute(db);
expect(r.rows[0]!.g).toBe(300);
// 统计写进 cache_stat 后清零
expect(await a.flushStats()).toBe(1);
await b.flushStats();
expect(a.pendingStats().size).toBe(0);
const s = await sql<{ l1: number; l2: number; m: number }>`select l1_hits::int as l1, l2_hits::int as l2,
misses::int as m from cache_stat where region = 'r1'`.execute(db);
expect(s.rows[0]).toEqual({ l1: 4, l2: 1, m: 1 });
});
it("跨进程失效:删除 / 清空通知其它进程丢 L1;监听断开时 L1 停用,重连后清空再启用", async () => {
const a = new MetadataCache(new PgCache(db));
const b = new MetadataCache(new PgCache(db2));
const la = await listenCacheInvalidation(a, { connectionString: url });
const lb = await listenCacheInvalidation(b, { connectionString: url });
try {
expect(a.l1Enabled && b.l1Enabled).toBe(true);
const p = { ttl: 600 };
await a.fetch("inv", "x", p, async () => 1);
await a.fetch("inv", "y", p, async () => 2);
await b.fetch("inv", "x", p, async () => 0);
await b.fetch("inv", "y", p, async () => 0);
expect(b.l1Size("inv")).toBe(2);
// A 删 x:B 的 L1 只丢 x
await a.delete("inv", "x");
await until(() => b.l1Size("inv") === 1);
expect(await b.fetch("inv", "x", p, async () => 10)).toBe(10);
// A 清空整个 region
await a.clear("inv");
await until(() => b.l1Size("inv") === 0);
expect(await b.fetch("inv", "y", p, async () => 20)).toBe(20);
// 手动刷新(fresh)写入后也通知
await a.fetch("inv", "y", { ...p, fresh: true }, async () => 21);
await until(() => b.l1Size("inv") === 0);
expect(await b.fetch("inv", "y", p, async () => 0)).toBe(21);
// 断开 B 的监听连接:L1 停用;自动重连后重新启用且是空的
expect(b.l1Size("inv")).toBe(1);
await sql`select pg_terminate_backend(pid) from pg_stat_activity
where query ilike 'listen umt_cache_invalidate%' and pid <> pg_backend_pid()`.execute(db2);
await until(() => !b.l1Enabled || b.l1Size("inv") === 0);
expect(b.l1Size("inv")).toBe(0);
await until(() => b.l1Enabled && lb.connected(), 10_000);
expect(b.l1Size("inv")).toBe(0);
} finally {
await la.stop();
await lb.stop();
}
expect(a.l1Enabled).toBe(false);
});
it("过期在宽限期内:先返回旧值、后台只刷一次;源失败继续用旧值并记警告", async () => {
const warns: string[] = [];
const c = new MetadataCache(new PgCache(db), { l1: false, logger: { warn: (_o, m) => warns.push(m) } });
let n = 0;
let gate: () => void = () => {};
const slow = () =>
new Promise<number>((ok) => {
n++;
gate = () => ok(100 + n);
});
await c.fetch("swr", "k", { ttl: 0.2, grace: 60 }, async () => 1);
await new Promise((r) => setTimeout(r, 300));
const p = { ttl: 600 };
const [x, y] = await Promise.all([c.fetch("swr", "k", p, slow), c.fetch("swr", "k", p, slow)]);
expect([x, y]).toEqual([1, 1]);
await until(() => n === 1);
gate();
await until(async () => (await new PgCache(db).get("swr", "k")) === 101);
expect(n).toBe(1);
expect(c.pendingStats().get("swr")).toMatchObject({ staleHits: 2, refreshes: 1 });
// 再次过期,源失败:返回旧值,不删旧值,记一次警告
await c.fetch("swr", "k2", { ttl: 0.2, grace: 60 }, async () => "old");
await new Promise((r) => setTimeout(r, 300));
const failing = async (): Promise<string> => {
throw new Error("HTTP 503");
};
expect(await c.fetch("swr", "k2", p, failing)).toBe("old");
await until(() => warns.length === 1);
expect(warns[0]).toContain("继续用旧值");
expect(await c.fetch("swr", "k2", p, async () => "new")).toBe("old");
// 过了宽限期 = 硬过期,回源
await c.fetch("swr", "k3", { ttl: 0.1, grace: 0.1 }, async () => "a");
await new Promise((r) => setTimeout(r, 300));
expect(await c.fetch("swr", "k3", p, async () => "b")).toBe("b");
// PgCache.get 只认新鲜条目
await c.fetch("swr", "k4", { ttl: 0.1, grace: 60 }, async () => "x");
await new Promise((r) => setTimeout(r, 200));
expect(await new PgCache(db).get("swr", "k4")).toBeNull();
expect(await new PgCache(db).exists("swr", "k4")).toBe(true);
});
it("同请求合并:并发同 key 只回源一次;失败不缓存并计数", async () => {
const c = new MetadataCache(new PgCache(db), { l1: true });
let n = 0;
const load = async () => {
n++;
await new Promise((r) => setTimeout(r, 50));
return { ok: true };
};
const all = await Promise.all(Array.from({ length: 5 }, () => c.fetch("sf", "k", { ttl: 60 }, load)));
expect(n).toBe(1);
expect(all.every((v) => v.ok)).toBe(true);
let m = 0;
const fail = async () => {
m++;
throw new Error("ECONNRESET");
};
await expect(c.fetch("sf", "bad", { ttl: 60 }, fail)).rejects.toThrow("ECONNRESET");
await expect(c.fetch("sf", "bad", { ttl: 60 }, fail)).rejects.toThrow("ECONNRESET");
expect(m).toBe(2);
expect(await new PgCache(db).exists("sf", "bad")).toBe(false);
expect(c.pendingStats().get("sf")).toMatchObject({ failures: 2 });
// skipIf / null 不缓存;空结果按 emptyTtl
await c.fetch("sf", "none", { ttl: 60 }, async () => null);
expect(await new PgCache(db).exists("sf", "none")).toBe(false);
await c.fetch("sf", "empty", { ttl: 3600, emptyTtl: 60, isEmpty: (v: number[]) => !v.length }, async () => []);
const t = await sql<{ s: number }>`select extract(epoch from fresh_until - now())::int as s
from kv_cache where region = 'sf' and key = 'empty'`.execute(db);
expect(t.rows[0]!.s).toBeLessThanOrEqual(60);
});
it("pinned:按条数上限淘汰时跳过;L2 命中时续期", async () => {
const pgc = new PgCache(db);
const c = new MetadataCache(pgc, { l1: false });
const tick = () => new Promise((r) => setTimeout(r, 5));
await c.fetch("pin", "watched", { ttl: 100, maxsize: 2, pin: async () => true }, async () => "w");
for (const k of ["a", "b", "c", "d"]) {
await tick();
await c.fetch("pin", k, { ttl: 100, maxsize: 2 }, async () => k);
}
expect((await pgc.items("pin")).map((i) => i.key).sort()).toEqual(["d", "watched"]);
// 续期:新鲜条目命中且 pin 为真 → 过期时间推到 now + ttl(保留宽限长度)
await sql`update kv_cache set fresh_until = now() + interval '10 seconds', expires_at = now() + interval '60 seconds'
where region = 'pin' and key = 'watched'`.execute(db);
expect(await c.fetch("pin", "watched", { ttl: 3600, pin: async () => true }, async () => "x")).toBe("w");
const r = await sql<{ f: number; g: number }>`select extract(epoch from fresh_until - now())::int as f,
extract(epoch from expires_at - fresh_until)::int as g from kv_cache where region = 'pin' and key = 'watched'`.execute(
db,
);
expect(r.rows[0]!.f).toBeGreaterThan(3500);
expect(r.rows[0]!.g).toBe(50);
});
});