- 策略表「来源 × 资源类型」(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
184 lines
9.3 KiB
TypeScript
184 lines
9.3 KiB
TypeScript
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();
|
||
});
|
||
});
|