Files
UHD_Media_Tool/apps/server/test/cache/metadata-cache.test.ts
T
rootandClaude Opus 5.5 1dca95f883 feat(cache): TMDB / 豆瓣 / Bangumi 接入统一元数据缓存与策略表
- 策略表「来源 × 资源类型」(metaCachePolicy):搜索 / 识别 7 天(空 1 天)、详情 / 季 / 剧集组 30 天(空 1 天)、
  榜单 / 热门 / 每日放送 6 小时(空 1 小时)、人物 / 图片 90 天;取值来自 storage.cacheTtl,
  追加 tmdbListHours / tmdbListEmptyHours / imageDays / metaDetailDays / metaMissDays,metaSearchDays 缺省改 7 天
- TMDB 客户端共享缓存端口加 fetch(带请求类别与空结果判定),服务端整条读取交给 MetadataCache;
  识别缓存、douban.request / bangumi.request、douban.subject / bangumi.subject、meta.subject_miss、discover.match 都走这一层
- 豆瓣 / bgm 条目只在「不存在」时记查无,网络失败 / 被拦不缓存(本进程冷却 30 分钟)
- 关注中作品(isEntityWatched 口径,含选中的剧集组)的 TMDB 条目写入 pinned、命中续期
- 各进程启动 LISTEN 失效通知并每分钟把命中计数写 cache_stat;GET /cache/regions 给元数据 region 带近 7 天命中统计

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

184 lines
9.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { metadataCacheFor, PgCache, sql } from "@umt/db";
import { createMockTmdb, type MockTmdb } from "@umt/testkit";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { createCacheModule } from "../../src/modules/cache/index.ts";
import { SourcesRuntime } from "../../src/modules/sources-identity/runtime.ts";
import { metaRequestCache } from "../../src/modules/sources-identity/service/meta-subjects.ts";
import { metaCachePolicy, regionHitStats, resetTrackedTmdbRefs } from "../../src/shared-cache.ts";
import { bearer, createHarness, type Harness, pgAvailable } from "../auth-settings/harness.ts";
import { DEV_KEY, json } from "../helpers.ts";
// 统一元数据缓存接到 TMDB / 豆瓣 / bgm:策略表、过期先用旧值后台刷新、同请求合并、失败不缓存、
// 关注中作品 pinned 不被淘汰、按 region 统计命中(GET /cache/regions)。
const hasPg = await pgAvailable();
const KEY = "0123456789abcdef0123456789abcdef";
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("策略表(来源 × 资源类型)", () => {
it("缺省:搜索 7 天(空 1 天)、详情 30 天(空 1 天)、列表 6 小时(空 1 小时)、图片 90 天", async () => {
const { DEFAULT_STORAGE_SETTINGS } = await import("@umt/contracts");
const t = DEFAULT_STORAGE_SETTINGS.cacheTtl;
const day = 86_400;
for (const s of ["tmdb", "douban", "bangumi"] as const) {
expect(metaCachePolicy(t, s, "search")).toEqual({ ttl: 7 * day, emptyTtl: day });
expect(metaCachePolicy(t, s, "detail")).toEqual({ ttl: 30 * day, emptyTtl: day });
expect(metaCachePolicy(t, s, "list")).toEqual({ ttl: 6 * 3600, emptyTtl: 3600 });
}
expect(metaCachePolicy(t, "tmdb", "image")).toEqual({ ttl: 90 * day, emptyTtl: day });
});
});
describe.skipIf(!hasPg)("统一元数据缓存(真实 PG)", () => {
let h: Harness;
let admin: ReturnType<typeof bearer>;
let tmdb: MockTmdb;
beforeAll(async () => {
h = await createHarness({ extraModules: [createCacheModule()] });
admin = bearer(h.app, DEV_KEY);
tmdb = await createMockTmdb({ apiKey: KEY });
const r = await admin("PATCH", "/settings", {
metadata: { tmdbApiKey: KEY, tmdbBaseUrl: `${tmdb.url}/3` },
cache: { tmdbMaxSize: 16 },
});
expect(r.status).toBe(200);
});
afterAll(async () => {
await tmdb?.close();
await h?.close();
});
const client = async () => (await new SourcesRuntime(h.ctx).tmdb())!;
const row = async (like: string) =>
(
await sql<{ key: string; fresh: number; pinned: boolean }>`select key,
extract(epoch from fresh_until - now()) / 86400 as fresh, pinned
from kv_cache where region = 'tmdb.request' and key like ${like}`.execute(h.db)
).rows[0];
it("TMDB 详情过期在宽限期内:先返回旧值,后台只刷一次;刷新后是新值", async () => {
const c = await client();
await c.details("movie", 872585);
const before = tmdb.callsTo("/3/movie/872585").length;
// 改成「已过新鲜期、还在宽限期」并在值里做个记号(热度 12345)
await sql`update kv_cache set fresh_until = now() - interval '1 second', expires_at = now() + interval '1 day',
value = jsonb_set(value, '{popularity}', '12345')
where region = 'tmdb.request' and key like '/movie/872585?%'`.execute(h.db);
const keys = (
await sql<{
key: string;
}>`select key from kv_cache where region = 'tmdb.request' and key like '/movie/872585?%'`.execute(h.db)
).rows.map((r) => r.key);
expect(keys.length).toBeGreaterThan(0);
// 回源慢一点,让两个调用方都在刷新完成前读到
tmdb.fault("/3/movie/872585", { delayMs: 300, times: 10 });
const [a, b] = await Promise.all([c.details("movie", 872585), c.details("movie", 872585)]);
expect(a.popularity).toBe(12345);
expect(b.popularity).toBe(12345);
// 后台刷新:两个调用方同时拿到旧值,每个过期键只回源一次;刷新后新鲜且是新值
await until(async () => {
const r = await sql<{ n: number }>`select count(*)::int as n from kv_cache where region = 'tmdb.request'
and key like '/movie/872585?%' and fresh_until > now() + interval '29 days'`.execute(h.db);
return r.rows[0]!.n === keys.length;
});
expect(tmdb.callsTo("/3/movie/872585").length - before).toBe(keys.length);
tmdb.clearFaults();
expect((await c.details("movie", 872585)).popularity).not.toBe(12345);
});
it("同请求合并:并发同一详情只回源一次;失败(5xx)不缓存", async () => {
const c = await client();
const n0 = tmdb.callsTo("/3/tv/95396/season/1").length;
await Promise.all(Array.from({ length: 5 }, () => c.getJson("/tv/95396/season/1", { language: "zh-CN" })));
expect(tmdb.callsTo("/3/tv/95396/season/1").length - n0).toBe(1);
// 5xx 重试用尽后抛错(客户端缺省重试 3 次,退避约 3.5 秒),不写缓存
tmdb.fault("/3/tv/100088/season/1", { status: 503, times: 10 });
await expect(c.getJson("/tv/100088/season/1", { language: "zh-CN" })).rejects.toThrow();
expect(await row("/tv/100088/season/1?%")).toBeUndefined();
tmdb.clearFaults();
});
it("关注中的作品:条目 pinned,条数上限淘汰时不被清掉", async () => {
const ent = (
await sql<{
id: string;
}>`insert into media_entity (kind, title) values ('series', '人生切割术') returning id`.execute(h.db)
).rows[0]!.id;
await sql`insert into external_identity (media_entity_id, provider, entity_type, external_id, status)
values (${ent}, 'tmdb', 'tv', '95396', 'accepted')`.execute(h.db);
await sql`insert into subscription (media_entity_id, media_kind, status) values (${ent}, 'series', 'active')`.execute(
h.db,
);
resetTrackedTmdbRefs(h.ctx);
await sql`delete from kv_cache where region = 'tmdb.request'`.execute(h.db);
const c = await client();
await c.getJson("/tv/95396", { language: "zh-CN" });
expect((await row("/tv/95396?%"))?.pinned).toBe(true);
// 上限 16:再写一批别的请求,关注作品的条目留着
for (let i = 0; i < 24; i++) await c.searchRaw("movie", `不存在${i}`);
expect(await row("/tv/95396?%")).toBeDefined();
const n = await sql<{ n: number }>`select count(*)::int as n from kv_cache where region = 'tmdb.request'`.execute(
h.db,
);
expect(n.rows[0]!.n).toBeLessThanOrEqual(16);
});
it("豆瓣 / bgm 请求缓存按策略表:搜索 7 天、列表 6 小时、空结果按查无 / 列表空;失败不写", async () => {
const cache = metaRequestCache(h.ctx);
await cache.cached({ source: "douban", key: "GET s1", kind: "search", empty: () => false }, async () => [1]);
await cache.cached({ source: "douban", key: "GET s0", kind: "search", empty: () => true }, async () => []);
await cache.cached({ source: "bangumi", key: "GET l1", kind: "list", empty: () => false }, async () => ({
data: [1],
}));
await expect(
cache.cached({ source: "bangumi", key: "GET bad", kind: "list", empty: () => false }, async () => {
throw new Error("HTTP 403");
}),
).rejects.toThrow("403");
const ttl = async (region: string, key: string) =>
Number(
(
await sql<{ s: number }>`select extract(epoch from fresh_until - now()) as s from kv_cache
where region = ${region} and key = ${key}`.execute(h.db)
).rows[0]?.s ?? -1,
);
expect(await ttl("douban.request", "GET s1")).toBeGreaterThan(7 * 86400 - 60);
expect(await ttl("douban.request", "GET s0")).toBeLessThanOrEqual(86400);
expect(await ttl("bangumi.request", "GET l1")).toBeLessThanOrEqual(6 * 3600);
expect(await ttl("bangumi.request", "GET l1")).toBeGreaterThan(6 * 3600 - 60);
expect(await ttl("bangumi.request", "GET bad")).toBe(-1);
});
it("命中统计:各 region 计 L1 / L2 / 旧值 / 未命中 / 失败 / 后台刷新;写入 cache_stat 后接口合计并给命中率", async () => {
const mc = metadataCacheFor(h.db);
const pending = mc.pendingStats().get("tmdb.request");
expect(pending?.misses).toBeGreaterThan(0);
expect(pending?.staleHits).toBeGreaterThan(0);
expect(pending?.refreshes).toBeGreaterThan(0);
expect(pending?.failures).toBeGreaterThan(0);
await mc.flushStats();
expect(mc.pendingStats().size).toBe(0);
// 再来一次 L2 命中(未写入 cache_stat 的也算进接口)
await (await client()).getJson("/tv/95396", { language: "zh-CN" });
const stats = await regionHitStats(h.ctx);
expect(stats.get("tmdb.request")!.l2Hits).toBeGreaterThan(0);
const list = await json(await admin("GET", "/cache/regions"));
const tmdbRow = list.data.items.find((i: { region: string }) => i.region === "tmdb.request");
expect(tmdbRow.hits.hitRate).toBeGreaterThan(0);
expect(tmdbRow.hits.hitRate).toBeLessThan(1);
expect(tmdbRow.hits).toMatchObject({ misses: expect.any(Number), l2Hits: expect.any(Number) });
// 非元数据 region 没有命中统计
await new PgCache(h.db).set("site.detail", "x", 1, { ttl: 60 });
const again = await json(await admin("GET", "/cache/regions"));
expect(again.data.items.find((i: { region: string }) => i.region === "site.detail").hits).toBeUndefined();
});
});