feat(cache): capacity LRU for image cache (CACHE-2)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
85b352b8eb
commit
d13c18b4b1
3 files changed
+186
-22
No files matched your search
Vendored
+112
-16
@@ -1,14 +1,14 @@
|
||||
import { mkdtempSync, rmSync, utimesSync } from "node:fs";
|
||||
import { existsSync, mkdtempSync, rmSync, statSync, utimesSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { PgCache, sql } from "@umt/db";
|
||||
import { createMockTmdb, type MockTmdb } from "@umt/testkit";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { imageCachePath } from "../../src/modules/cache/images.ts";
|
||||
import { ImageCache, imageCachePath } from "../../src/modules/cache/images.ts";
|
||||
import { createCacheModule } from "../../src/modules/cache/index.ts";
|
||||
import { scheduleRegistry } from "../../src/modules/schedules/registry.ts";
|
||||
import { SourcesRuntime } from "../../src/modules/sources-identity/runtime.ts";
|
||||
import { sharedCache } from "../../src/shared-cache.ts";
|
||||
import { resetTrackedTmdbRefs, sharedCache } from "../../src/shared-cache.ts";
|
||||
import { bearer, createHarness, type Harness, pgAvailable } from "../auth-settings/harness.ts";
|
||||
import { DEV_KEY, json } from "../helpers.ts";
|
||||
|
||||
@@ -53,31 +53,77 @@ describe.skipIf(!hasPg)("C2-A 缓存(真实 PG)", () => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("TMDB 请求响应进 kv_cache(tmdb.request,TTL = cache.tmdbTtlHours,条数上限 = cache.tmdbMaxSize),另一个进程直接命中", async () => {
|
||||
it("TMDB 请求响应进 kv_cache(tmdb.request,TTL 取 storage.cacheTtl:详情 / 搜索 / 未命中,条数上限 = cache.tmdbMaxSize),另一个进程直接命中", async () => {
|
||||
const r = await admin("PATCH", "/settings", {
|
||||
metadata: { tmdbApiKey: "0123456789abcdef0123456789abcdef", tmdbBaseUrl: `${tmdb.url}/3` },
|
||||
cache: { tmdbTtlHours: 6, tmdbMaxSize: 16 },
|
||||
cache: { tmdbMaxSize: 16 },
|
||||
storage: { cacheTtl: { tmdbDetailDays: 20, tmdbSearchDays: 3, tmdbMissDays: 2 } },
|
||||
});
|
||||
expect(r.status).toBe(200);
|
||||
// 部分更新只改给出的子字段,其余保持缺省
|
||||
const st = (await json(await admin("GET", "/settings"))).data.storage;
|
||||
expect(st.cacheTtl).toMatchObject({ tmdbDetailDays: 20, tmdbSearchDays: 3, tmdbMissDays: 2, siteDetailDays: 7 });
|
||||
expect(st.budgets.imagesGb).toBe(20);
|
||||
await (await new SourcesRuntime(h.ctx).tmdb())!.details("movie", 872585);
|
||||
const calls = tmdb.calls.length;
|
||||
const rows = (
|
||||
await sql<{ key: string; hours: number }>`select key, extract(epoch from expires_at - now()) / 3600 as hours
|
||||
await sql<{ key: string; days: number }>`select key, extract(epoch from expires_at - now()) / 86400 as days
|
||||
from kv_cache where region = 'tmdb.request'`.execute(h.db)
|
||||
).rows;
|
||||
const key = rows.find((x) => x.key.startsWith("/movie/872585?"))!.key;
|
||||
const detail = rows.find((x) => x.key.startsWith("/movie/872585?"))!;
|
||||
expect(rows.every((x) => !x.key.includes("0123456789abcdef"))).toBe(true);
|
||||
expect(Number(rows[0]!.hours)).toBeGreaterThan(5.9);
|
||||
expect(await new PgCache(h.db).get("tmdb.request", key)).toMatchObject({ id: 872585 });
|
||||
expect(Number(detail.days)).toBeGreaterThan(19.9);
|
||||
expect(Number(detail.days)).toBeLessThanOrEqual(20);
|
||||
expect(await new PgCache(h.db).get("tmdb.request", detail.key)).toMatchObject({ id: 872585 });
|
||||
await (await new SourcesRuntime(h.ctx).tmdb())!.details("movie", 872585);
|
||||
expect(tmdb.calls.length).toBe(calls);
|
||||
// 空搜索结果按 EMPTY_RESULT_CACHE_TTL(30 分钟)
|
||||
const days = async (like: string) =>
|
||||
Number(
|
||||
(
|
||||
await sql<{ d: number }>`select extract(epoch from expires_at - now()) / 86400 as d
|
||||
from kv_cache where region = 'tmdb.request' and key like ${like}`.execute(h.db)
|
||||
).rows[0]!.d,
|
||||
);
|
||||
// 有结果的搜索按 tmdbSearchDays
|
||||
await (await new SourcesRuntime(h.ctx).tmdb())!.search("Oppenheimer", { type: "movie" });
|
||||
const hit = await days("/search/movie?%Oppenheimer%");
|
||||
expect(hit).toBeGreaterThan(2.9);
|
||||
expect(hit).toBeLessThanOrEqual(3);
|
||||
// 空搜索结果按 EMPTY_RESULT_CACHE_TTL 语义,取 tmdbMissDays
|
||||
await (await new SourcesRuntime(h.ctx).tmdb())!.search("完全不存在的片名c2a", { type: "movie" });
|
||||
const empty = (
|
||||
await sql<{ m: number }>`select extract(epoch from expires_at - now()) / 60 as m
|
||||
from kv_cache where region = 'tmdb.request' and key like '/search/movie?%'`.execute(h.db)
|
||||
).rows[0];
|
||||
expect(Number(empty!.m)).toBeLessThanOrEqual(30);
|
||||
const empty = await days("/search/movie?%c2a%");
|
||||
expect(empty).toBeGreaterThan(1.9);
|
||||
expect(empty).toBeLessThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("追踪中的作品:详情缓存命中时续期到 now + 详情 TTL;未追踪的不动", async () => {
|
||||
const ent = (
|
||||
await sql<{
|
||||
id: string;
|
||||
}>`insert into media_entity (kind, title) values ('movie', '奥本海默') 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', 'movie', '872585', 'accepted')`.execute(h.db);
|
||||
await sql`insert into subscription (media_entity_id, media_kind, status) values (${ent}, 'movie', 'active')`.execute(
|
||||
h.db,
|
||||
);
|
||||
resetTrackedTmdbRefs(h.ctx);
|
||||
const client = (await new SourcesRuntime(h.ctx).tmdb())!;
|
||||
await client.details("movie", 872585);
|
||||
await client.details("movie", 693134);
|
||||
await sql`update kv_cache set expires_at = now() + interval '1 day'
|
||||
where region = 'tmdb.request' and (key like '/movie/872585?%' or key like '/movie/693134?%')`.execute(h.db);
|
||||
await client.details("movie", 872585);
|
||||
await client.details("movie", 693134);
|
||||
const left = async (like: string) =>
|
||||
Number(
|
||||
(
|
||||
await sql<{ d: number }>`select extract(epoch from expires_at - now()) / 86400 as d
|
||||
from kv_cache where region = 'tmdb.request' and key like ${like}`.execute(h.db)
|
||||
).rows[0]!.d,
|
||||
);
|
||||
expect(await left("/movie/872585?%")).toBeGreaterThan(19.9);
|
||||
expect(await left("/movie/693134?%")).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("GET /cache/tmdb 列出条目(键 / 过期 / 大小)与上限;POST /cache/tmdb/clear 清空", async () => {
|
||||
@@ -88,7 +134,7 @@ describe.skipIf(!hasPg)("C2-A 缓存(真实 PG)", () => {
|
||||
{ ttl: 3600 },
|
||||
);
|
||||
const list = await json(await admin("GET", "/cache/tmdb"));
|
||||
expect(list.data).toMatchObject({ maxSize: 16, ttlHours: 6, emptyResultTtlSec: 1800 });
|
||||
expect(list.data).toMatchObject({ maxSize: 16, ttlHours: 20 * 24, emptyResultTtlSec: 2 * 86400 });
|
||||
expect(list.data.counts.request).toBeGreaterThan(0);
|
||||
const rec = list.data.items.find((i: { kind: string }) => i.kind === "recognize");
|
||||
expect(rec).toMatchObject({ tmdbId: 872585, title: "奥本海默", mediaType: "movie", posterPath: "/p.jpg" });
|
||||
@@ -143,4 +189,54 @@ describe.skipIf(!hasPg)("C2-A 缓存(真实 PG)", () => {
|
||||
expect((await json(await admin("GET", "/cache/images"))).data.files).toBe(0);
|
||||
expect((await json(await admin("POST", "/cache/images/clear"))).data.deleted).toBe(0);
|
||||
});
|
||||
|
||||
it("图片容量 LRU:超过预算按最久未访问(atime)删到预算的 90%;读盘命中刷新 atime、不动 mtime", async () => {
|
||||
const lruDir = mkdtempSync(join(tmpdir(), "umt-c2-lru-"));
|
||||
try {
|
||||
const cache = new ImageCache({
|
||||
dir: lruDir,
|
||||
upstreamBase: "https://image.tmdb.org/t/p",
|
||||
fetch: async () => new Response(new Uint8Array([...JPEG, ...new Uint8Array(92)])),
|
||||
});
|
||||
// 4 张 100 字节的图,访问时间从旧到新 a < b < c < d
|
||||
for (const f of ["a", "b", "c", "d"]) await cache.serve("w92", `${f}.jpg`, false, null);
|
||||
const p = (f: string) => join(lruDir, "t/p/w92", `${f}.jpg`);
|
||||
const base = Date.now() - 10 * 86_400_000;
|
||||
["a", "b", "c", "d"].forEach((f, i) => {
|
||||
utimesSync(p(f), new Date(base + i * 3600_000), new Date(base));
|
||||
});
|
||||
// 读盘命中 a:atime 变成现在(最近使用),mtime 仍是写入时间
|
||||
await cache.serve("w92", "a.jpg", true, null);
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
const st = statSync(p("a"));
|
||||
expect(st.atimeMs).toBeGreaterThan(Date.now() - 60_000);
|
||||
expect(Math.abs(st.mtimeMs - base)).toBeLessThan(1000);
|
||||
// 没超预算不动
|
||||
expect(await cache.enforceBudget(1000)).toMatchObject({ deleted: 0, usedBytes: 400 });
|
||||
// 预算 300 → 删到 ≤ 270:最久未访问的 b、c 被删,a(刚读过)与 d 保留
|
||||
const r = await cache.enforceBudget(300);
|
||||
expect(r).toMatchObject({ deleted: 2, freedBytes: 200, usedBytes: 200 });
|
||||
expect(existsSync(p("a"))).toBe(true);
|
||||
expect(existsSync(p("b"))).toBe(false);
|
||||
expect(existsSync(p("c"))).toBe(false);
|
||||
expect(existsSync(p("d"))).toBe(true);
|
||||
} finally {
|
||||
rmSync(lruDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("cache.images_lru 登记为维护任务,预算取 storage.budgets.imagesGb", async () => {
|
||||
await mod.start?.(h.ctx);
|
||||
const job = scheduleRegistry(h.ctx).get("cache.images_lru");
|
||||
expect(job?.defaults).toMatchObject({ group: "maintain", schedule: { everyMs: 30 * 60_000 } });
|
||||
const out = await job!.run({
|
||||
jobId: "cache.images_lru",
|
||||
runId: "r",
|
||||
trigger: "manual",
|
||||
now: new Date(),
|
||||
config: {},
|
||||
slot: "1",
|
||||
});
|
||||
expect(out?.summary).toMatch(/未超过预算 20 GB/);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user