Merge branch 'cache2'
This commit is contained in:
commit
eb9cfaa63d
32 files changed
+1927
-87
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/);
|
||||
});
|
||||
});
|
||||
@@ -468,18 +468,21 @@ describe.skipIf(!up)("resolveObservation / 身份与元数据", () => {
|
||||
).rows;
|
||||
const opp = (await rows()).find((r) => r.key === "[未知][zh-CN]Oppenheimer-2023-None");
|
||||
expect(opp?.value).toMatchObject({ id: 872585, type: "movie", title: "奥本海默" });
|
||||
expect(Number(opp!.minutes)).toBeGreaterThan(23 * 60);
|
||||
// 正常条目按搜索类 TTL(storage.cacheTtl.tmdbSearchDays,缺省 7 天)
|
||||
expect(Number(opp!.minutes)).toBeGreaterThan(7 * 24 * 60 - 5);
|
||||
expect(Number(opp!.minutes)).toBeLessThanOrEqual(7 * 24 * 60);
|
||||
// 同名同年的另一条资源:命中识别缓存,不再调 TMDB 搜索
|
||||
const searches = tmdb.callsTo(/\/3\/search\//).length;
|
||||
const again = await release("Oppenheimer.2023.1080p.BluRay.x264-OTHER");
|
||||
await resolveRelease(h.rt, { sourceReleaseId: again.id });
|
||||
expect(tmdb.callsTo(/\/3\/search\//).length).toBe(searches);
|
||||
// 查无此项 → 负缓存 {id: 0},TTL = 空结果短 TTL(30 分钟)
|
||||
// 查无此项 → 负缓存 {id: 0},TTL = 未命中 TTL(storage.cacheTtl.tmdbMissDays,缺省 1 天)
|
||||
const none = await release("Zzqxv.Nonexistent.Title.2019.1080p.WEB-DL-NOGRP");
|
||||
await resolveRelease(h.rt, { sourceReleaseId: none.id });
|
||||
const neg = (await rows()).find((r) => r.key === "[未知][zh-CN]Zzqxv Nonexistent Title-2019-None");
|
||||
expect(neg?.value).toEqual({ id: 0 });
|
||||
expect(Number(neg!.minutes)).toBeLessThanOrEqual(30);
|
||||
expect(Number(neg!.minutes)).toBeLessThanOrEqual(24 * 60);
|
||||
expect(Number(neg!.minutes)).toBeGreaterThan(24 * 60 - 5);
|
||||
const s2 = tmdb.callsTo(/\/3\/search\//).length;
|
||||
await resolveRelease(h.rt, { sourceReleaseId: none.id });
|
||||
expect(tmdb.callsTo(/\/3\/search\//).length).toBe(s2);
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { DEFAULT_STORAGE_SETTINGS, SystemHealth, SystemStorage } from "@umt/contracts";
|
||||
import { type RawBuilder, sql } from "@umt/db";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { scheduleRegistry } from "../../src/modules/schedules/registry.ts";
|
||||
import { createSystemModule } from "../../src/modules/system/index.ts";
|
||||
import { CLEANUP_JOB_IDS, deleteInBatches, RETENTION_JOBS } from "../../src/modules/system/retention.ts";
|
||||
import { budgetLevel, resetDirScans } from "../../src/modules/system/storage.ts";
|
||||
import { bearer, createHarness, type Harness, pgAvailable } from "../auth-settings/harness.ts";
|
||||
import { DEV_KEY, json } from "../helpers.ts";
|
||||
|
||||
// CACHE-2 存储预算与保留:保留清理分批与边界、存储面板形状、health 存储档位、手动触发清理。
|
||||
|
||||
const hasPg = await pgAvailable();
|
||||
const DAY = 86_400_000;
|
||||
|
||||
describe("预算档位", () => {
|
||||
it("≥ 预算 critical,≥ 告警线 warn,取不到 unknown", () => {
|
||||
expect(budgetLevel(null, 100, 80)).toBe("unknown");
|
||||
expect(budgetLevel(79, 100, 80)).toBe("ok");
|
||||
expect(budgetLevel(80, 100, 80)).toBe("warn");
|
||||
expect(budgetLevel(100, 100, 80)).toBe("critical");
|
||||
});
|
||||
|
||||
it("每个清理目标都对应一个定时任务", () => {
|
||||
expect(Object.keys(CLEANUP_JOB_IDS).sort()).toEqual(
|
||||
[
|
||||
"observation",
|
||||
"sse_event",
|
||||
"heartbeat",
|
||||
"task_run",
|
||||
"agent_event",
|
||||
"audit",
|
||||
"capability_stats",
|
||||
"identity_pool",
|
||||
"analyze",
|
||||
"images",
|
||||
"kv_cache",
|
||||
].sort(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!hasPg)("CACHE-2 存储(真实 PG)", () => {
|
||||
let h: Harness;
|
||||
let admin: ReturnType<typeof bearer>;
|
||||
const dir = mkdtempSync(join(tmpdir(), "umt-cache2-"));
|
||||
|
||||
beforeAll(async () => {
|
||||
h = await createHarness({ engine: true });
|
||||
const { createApp } = await import("../../src/http/app.ts");
|
||||
const { createAuthSettingsModule } = await import("../../src/modules/auth-settings/index.ts");
|
||||
const sys = createSystemModule({
|
||||
engineSchema: h.engineSchema,
|
||||
apiHeartbeat: false,
|
||||
storagePaths: { dataDir: dir, sessionDir: join(dir, "agent-sessions") },
|
||||
retentionBatch: { batchSize: 3, pauseMs: 0 },
|
||||
healthDeps: {
|
||||
dataDir: dir,
|
||||
diskUsage: async (path) => ({
|
||||
status: "ok",
|
||||
path,
|
||||
totalBytes: 1000,
|
||||
freeBytes: 500,
|
||||
availableBytes: 500,
|
||||
usedBytes: 500,
|
||||
usedRatio: 0.5,
|
||||
}),
|
||||
},
|
||||
});
|
||||
h.app = createApp({
|
||||
config: h.ctx.config,
|
||||
logger: h.ctx.logger,
|
||||
bus: h.bus,
|
||||
db: h.db,
|
||||
role: "all",
|
||||
version: "0.0.0-test",
|
||||
modules: [createAuthSettingsModule({ setupTokenOnStart: false, engineSchema: h.engineSchema }), sys],
|
||||
ctx: h.ctx,
|
||||
}) as Harness["app"];
|
||||
admin = bearer(h.app, DEV_KEY);
|
||||
await sys.start?.(h.ctx);
|
||||
// 目录:图片 3 KB、备份 2 份、会话 1 KB、证据 1 KB
|
||||
mkdirSync(join(dir, "cache", "images", "t", "p", "w92"), { recursive: true });
|
||||
writeFileSync(join(dir, "cache", "images", "t", "p", "w92", "a.jpg"), new Uint8Array(3072));
|
||||
for (const b of ["20260901T000000Z", "20260902T000000Z", ".20260903T000000Z.partial"]) {
|
||||
mkdirSync(join(dir, "backups", b), { recursive: true });
|
||||
writeFileSync(join(dir, "backups", b, "db.dump"), new Uint8Array(1024));
|
||||
}
|
||||
mkdirSync(join(dir, "agent-sessions"), { recursive: true });
|
||||
writeFileSync(join(dir, "agent-sessions", "s.jsonl"), new Uint8Array(1024));
|
||||
mkdirSync(join(dir, "tmp", "evidence"), { recursive: true });
|
||||
writeFileSync(join(dir, "tmp", "evidence", "e.jpg"), new Uint8Array(1024));
|
||||
resetDirScans();
|
||||
}, 60_000);
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const job = (target: string) => {
|
||||
const j = RETENTION_JOBS.find((x) => x.target === target)!;
|
||||
return (now = new Date(), settings = DEFAULT_STORAGE_SETTINGS) =>
|
||||
j.run({ db: h.db, engineSchema: h.engineSchema, settings, now, batch: { batchSize: 3, pauseMs: 0 } });
|
||||
};
|
||||
const count = async (q: RawBuilder<{ n: number }>) => Number((await q.execute(h.db)).rows[0]!.n);
|
||||
|
||||
it("保留任务登记在维护组,各自一个 key", () => {
|
||||
const reg = scheduleRegistry(h.ctx);
|
||||
for (const j of RETENTION_JOBS) {
|
||||
expect(reg.get(j.jobId)?.defaults, j.jobId).toMatchObject({ group: "maintain" });
|
||||
}
|
||||
expect(reg.get("storage.analyze")?.defaults.schedule).toMatchObject({ everyMs: 7 * DAY });
|
||||
expect(reg.get("system.heartbeat_cleanup")?.defaults.group).toBe("maintain");
|
||||
});
|
||||
|
||||
it("deleteInBatches:按批删除,只删满足条件的行;总时限到了停在中途,下次接着删", async () => {
|
||||
await sql`insert into sse_event (type, at) select 'x', now() - interval '30 days' from generate_series(1, 10)`.execute(
|
||||
h.db,
|
||||
);
|
||||
await sql`insert into sse_event (type, at) values ('keep', now())`.execute(h.db);
|
||||
// 时钟每次调用前进 1 秒,预算 1.5 秒:删完 2 批就停
|
||||
let t = 0;
|
||||
const r1 = await deleteInBatches(h.db, sql.raw("sse_event"), sql`at < now() - interval '7 days'`, {
|
||||
batchSize: 3,
|
||||
pauseMs: 0,
|
||||
budgetMs: 1500,
|
||||
now: () => (t += 1000),
|
||||
});
|
||||
expect(r1).toEqual({ deleted: 6, batches: 2, truncated: true });
|
||||
const r2 = await deleteInBatches(h.db, sql.raw("sse_event"), sql`at < now() - interval '7 days'`, {
|
||||
batchSize: 3,
|
||||
pauseMs: 0,
|
||||
});
|
||||
expect(r2).toEqual({ deleted: 4, batches: 2, truncated: false });
|
||||
expect(await count(sql<{ n: number }>`select count(*)::int as n from sse_event`)).toBe(1);
|
||||
});
|
||||
|
||||
it("每批语句超时:停止本次运行,不抛错", async () => {
|
||||
await sql`insert into sse_event (type, at) values ('slow', now() - interval '30 days')`.execute(h.db);
|
||||
const r = await deleteInBatches(
|
||||
h.db,
|
||||
sql.raw("sse_event"),
|
||||
sql`at < now() - interval '7 days' and pg_sleep(0.3) is not null`,
|
||||
{ batchSize: 3, statementTimeoutMs: 50 },
|
||||
);
|
||||
expect(r).toEqual({ deleted: 0, batches: 0, truncated: true });
|
||||
await sql`delete from sse_event where type = 'slow'`.execute(h.db);
|
||||
});
|
||||
|
||||
it("观察记录:保留边界(90 天前一刻删,之后保留)", async () => {
|
||||
const feed = (
|
||||
await sql<{
|
||||
id: string;
|
||||
}>`insert into feed_source (name, url_display) values ('rss', 'https://x/rss') returning id`.execute(h.db)
|
||||
).rows[0]!.id;
|
||||
const now = new Date();
|
||||
const ins = (guid: string, at: Date) =>
|
||||
sql`insert into observation (feed_id, guid, content_digest, title, created_at)
|
||||
values (${feed}, ${guid}, ${guid}, ${guid}, ${at})`.execute(h.db);
|
||||
for (let i = 0; i < 5; i++) await ins(`old${i}`, new Date(now.getTime() - 90 * DAY - 60_000 - i));
|
||||
await ins("edge", new Date(now.getTime() - 90 * DAY + 60_000));
|
||||
await ins("new", now);
|
||||
expect(await job("observation")(now)).toMatch(/删除 5 行(2 批)/);
|
||||
const left = await sql<{ guid: string }>`select guid from observation order by guid`.execute(h.db);
|
||||
expect(left.rows.map((r) => r.guid)).toEqual(["edge", "new"]);
|
||||
});
|
||||
|
||||
it("任务运行:只删过期且已结束的业务任务与引擎 run", async () => {
|
||||
const now = new Date();
|
||||
const old = new Date(now.getTime() - 61 * DAY);
|
||||
const ins = (key: string, status: string, at: Date) =>
|
||||
sql`insert into task (kind, idempotency_key, status, created_at) values ('k', ${key}, ${status}, ${at})`.execute(
|
||||
h.db,
|
||||
);
|
||||
await ins("t1", "succeeded", old);
|
||||
await ins("t2", "failed", old);
|
||||
await ins("t3", "running", old);
|
||||
await ins("t4", "succeeded", now);
|
||||
const t = (n: string) => sql.raw(`"${h.engineSchema}".${n}`);
|
||||
const run = (id: string, status: string, at: Date) =>
|
||||
sql`insert into ${t("workflow_runs")} (namespace_id, id, workflow_name, version, status, config, attempts, created_at, updated_at)
|
||||
values ('default', ${id}, 'w', 'v1', ${status}, '{}'::jsonb, 1, ${at}, ${at})`.execute(h.db);
|
||||
await run("r-old-done", "completed", old);
|
||||
await run("r-old-running", "running", old);
|
||||
await run("r-new", "completed", now);
|
||||
expect(await job("task_run")(now)).toMatch(/业务任务 2、引擎 run 1/);
|
||||
expect(
|
||||
(await sql<{ idempotency_key: string }>`select idempotency_key from task order by 1`.execute(h.db)).rows.map(
|
||||
(r) => r.idempotency_key,
|
||||
),
|
||||
).toEqual(["t3", "t4"]);
|
||||
expect(
|
||||
(await sql<{ id: string }>`select id from ${t("workflow_runs")} order by id`.execute(h.db)).rows.map((r) => r.id),
|
||||
).toEqual(expect.arrayContaining(["r-old-running", "r-new"]));
|
||||
expect(
|
||||
await count(sql<{ n: number }>`select count(*)::int as n from ${t("workflow_runs")} where id = 'r-old-done'`),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it("Agent 事件:只删已结束会话里过期的事件;审计与能力统计分开保留", async () => {
|
||||
const now = new Date();
|
||||
const runs = await sql<{ id: string; status: string }>`insert into agent_run (trigger, goal, status)
|
||||
values ('user', 'g', 'succeeded'), ('user', 'g', 'running') returning id, status`.execute(h.db);
|
||||
const done = runs.rows.find((r) => r.status === "succeeded")!.id;
|
||||
const live = runs.rows.find((r) => r.status === "running")!.id;
|
||||
const old = new Date(now.getTime() - 91 * DAY);
|
||||
await sql`insert into agent_run_event (run_id, seq, type, at) values
|
||||
(${done}, 1, 'run_started', ${old}), (${done}, 2, 'run_started', ${now}), (${live}, 1, 'run_started', ${old})`.execute(
|
||||
h.db,
|
||||
);
|
||||
expect(await job("agent_event")(now)).toMatch(/Agent 事件 1、/);
|
||||
expect(await count(sql<{ n: number }>`select count(*)::int as n from agent_run_event`)).toBe(2);
|
||||
|
||||
await sql`delete from audit_event`.execute(h.db);
|
||||
const a = (days: number, targetType: string | null) =>
|
||||
sql`insert into audit_event (actor_type, action, target_type, at)
|
||||
values ('system', 'x', ${targetType}, ${new Date(now.getTime() - days * DAY)})`.execute(h.db);
|
||||
await a(181, null);
|
||||
await a(179, null);
|
||||
await a(100, "capability");
|
||||
await a(80, "capability");
|
||||
await a(181, "capability");
|
||||
expect(await job("audit")(now)).toMatch(/审计事件删除 1 行/);
|
||||
expect(await job("capability_stats")(now)).toMatch(/能力审计 2、/);
|
||||
const left = await sql<{ t: string | null; d: number }>`select target_type as t,
|
||||
round(extract(epoch from now() - at) / 86400)::int as d from audit_event order by d`.execute(h.db);
|
||||
expect(left.rows).toEqual([
|
||||
{ t: "capability", d: 80 },
|
||||
{ t: null, d: 179 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("待识别池:unmatched 超过保留期没有新证据的归档,其余不动", async () => {
|
||||
const site = (
|
||||
await sql<{ id: string }>`insert into site (key, name, adapter, base_url)
|
||||
values ('s-cache2', 'S', 'nexusphp', 'https://s.example') returning id`.execute(h.db)
|
||||
).rows[0]!.id;
|
||||
const now = new Date();
|
||||
const cand = async (rid: string, reason: string, lastAttemptDays: number) => {
|
||||
const sr = (
|
||||
await sql<{ id: string }>`insert into source_release (site_id, site_release_id, title)
|
||||
values (${site}, ${rid}, ${rid}) returning id`.execute(h.db)
|
||||
).rows[0]!.id;
|
||||
await sql`insert into identity_candidate (source_release_id, reason, evidence_version, last_attempt_at)
|
||||
values (${sr}, ${reason}, 'v1', ${new Date(now.getTime() - lastAttemptDays * DAY)})`.execute(h.db);
|
||||
};
|
||||
await cand("r1", "unmatched", 31);
|
||||
await cand("r2", "unmatched", 29);
|
||||
await cand("r3", "ambiguous", 31);
|
||||
expect(await job("identity_pool")(now)).toBe("归档 1 条待识别条目");
|
||||
const st = await sql<{ t: string; s: string }>`select sr.title as t, c.status as s from identity_candidate c
|
||||
join source_release sr on sr.id = c.source_release_id order by sr.title`.execute(h.db);
|
||||
expect(st.rows).toEqual([
|
||||
{ t: "r1", s: "archived" },
|
||||
{ t: "r2", s: "pending" },
|
||||
{ t: "r3", s: "pending" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("ANALYZE 任务", async () => {
|
||||
expect(await job("analyze")()).toMatch(/ANALYZE 完成/);
|
||||
});
|
||||
|
||||
it("GET /system/storage:表体积 top 15、库体积对照预算、kv_cache、目录用量、清理任务", async () => {
|
||||
await sql`insert into kv_cache (region, key, value, expires_at) values
|
||||
('tmdb.request', 'a', '{}'::jsonb, now() + interval '1 day'),
|
||||
('tmdb.request', 'b', '{}'::jsonb, now() - interval '1 day')`.execute(h.db);
|
||||
const res = await admin("GET", "/system/storage");
|
||||
expect(res.status).toBe(200);
|
||||
const d = SystemStorage.parse((await json(res)).data);
|
||||
expect(d.db.tables.length).toBeGreaterThan(0);
|
||||
expect(d.db.tables.length).toBeLessThanOrEqual(15);
|
||||
expect(d.db.usedBytes).toBeGreaterThan(0);
|
||||
expect(d.db.budgetBytes).toBe(25 * 1024 ** 3);
|
||||
expect(d.db.warnBytes).toBe(20 * 1024 ** 3);
|
||||
expect(d.kvCache.find((r) => r.region === "tmdb.request")).toMatchObject({ entries: 2, expired: 1 });
|
||||
expect(d.images).toMatchObject({ usedBytes: 3072, files: 1, level: "ok", budgetBytes: 20 * 1024 ** 3 });
|
||||
expect(d.backups).toMatchObject({ usedBytes: 3072, count: 2, level: "ok" });
|
||||
expect(d.sessionsEvidence).toMatchObject({ usedBytes: 2048, level: "ok" });
|
||||
expect(d.dataDir.map((x) => x.name)).toEqual(
|
||||
expect.arrayContaining(["agent-sessions", "backups", "cache/images", "tmp/evidence"]),
|
||||
);
|
||||
const obs = d.cleanup.find((c) => c.target === "observation")!;
|
||||
expect(obs).toMatchObject({ jobId: "storage.retention.observation", retention: "90 天" });
|
||||
expect(d.cleanup.find((c) => c.target === "heartbeat")?.retention).toBe("24 小时");
|
||||
expect(d.level).toBe("ok");
|
||||
});
|
||||
|
||||
it("预算超了:存储面板与 health 都报出来", async () => {
|
||||
const r = await admin("PATCH", "/settings", {
|
||||
storage: { budgets: { pgDataWarnGb: 0.01, pgDataGb: 50 } },
|
||||
});
|
||||
expect(r.status).toBe(200);
|
||||
// 把 PG 告警线压到库体积之下验证 db warn
|
||||
const d = SystemStorage.parse((await json(await admin("GET", "/system/storage"))).data);
|
||||
expect(d.db.level).toBe("warn");
|
||||
expect(d.level).toBe("warn");
|
||||
const hl = SystemHealth.parse((await json(await admin("GET", "/system/health"))).data);
|
||||
expect(hl.storage.level).toBe("warn");
|
||||
expect(hl.storage.alerts.join("")).toMatch(/数据库/);
|
||||
expect(["warn", "critical"]).toContain(hl.level);
|
||||
await admin("PATCH", "/settings", { storage: { budgets: { pgDataWarnGb: 20, pgDataGb: 25 } } });
|
||||
});
|
||||
|
||||
it("POST /system/storage/cleanup/{target}:排入对应定时任务;重复请求合并;未知目标 422", async () => {
|
||||
await sql`insert into scheduled_job (id, name, board_group, kind, enabled, default_enabled, schedule, default_schedule)
|
||||
values ('storage.retention.audit', '审计清理', 'maintain', 'task', true, true,
|
||||
'{"type":"interval","everyMs":86400000}'::jsonb, '{"type":"interval","everyMs":86400000}'::jsonb)
|
||||
on conflict (id) do nothing`.execute(h.db);
|
||||
const r1 = await json(await admin("POST", "/system/storage/cleanup/audit"));
|
||||
expect(r1.data).toEqual({ target: "audit", jobId: "storage.retention.audit", queued: true });
|
||||
const r2 = await json(await admin("POST", "/system/storage/cleanup/audit"));
|
||||
expect(r2.data.queued).toBe(false);
|
||||
expect((await admin("POST", "/system/storage/cleanup/nope")).status).toBe(422);
|
||||
expect((await admin("POST", "/system/storage/cleanup/observation")).status).toBe(404);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user