- root deno.json workspace (nodeModulesDir auto), npm versions registered once in imports, deno.lock; remove pnpm-lock/pnpm-workspace/root package.json; members keep minimal package.json (name/type/exports) - tasks: dev, typecheck (tsc 7.0.2 per member + deno check), lint/fmt (biome, useImportExtensions), test (vitest via deno), check:tokens, build (web + deno compile), ci, db:migrate/reset, gen:types - tsconfig base moved to config/ (no root tsconfig.json); deno types -> types/deno.d.ts; web drops baseUrl - server: Deno.serve entry with graceful shutdown, --role migrate|preflight|probe, WEB_ROOT static SPA hosting; drop @hono/node-server, tsx, esbuild build.mjs - logger: redact child() bindings and serialize err by message/stack; sync stdout destination; tests - relative imports get explicit extensions (sites/mp-sync paths left for follow-up) - crash-recovery tests spawn `deno run -A`; tasks tests use unique per-file schemas - single-container Dockerfile + entrypoint (migrate -> preflight -> api+worker watchdog), docker-compose.yml - docs: README, runbook, ADR-001 superseded, plan §1 validation row Co-Authored-By: Claude Fable 5.1 <[email protected]>
106 lines
3.5 KiB
TypeScript
106 lines
3.5 KiB
TypeScript
import { sql } from "kysely";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import { FencingError, getOperation, migrateTasksTables, withAttemptToken } from "../src/index.ts";
|
|
import { connectDb, dropSchemas, pgAvailable, resetSchemas, type TestDb, testSchemas } from "./helpers.ts";
|
|
|
|
const schemas = testSchemas("ledger");
|
|
const hasPg = await pgAvailable();
|
|
|
|
describe.skipIf(!hasPg)("withAttemptToken(操作账本 fencing)", () => {
|
|
let db: TestDb;
|
|
|
|
beforeAll(async () => {
|
|
await resetSchemas(schemas);
|
|
db = connectDb(schemas.biz);
|
|
await migrateTasksTables(db);
|
|
await sql`CREATE TABLE download_task (id text PRIMARY KEY, hash text)`.execute(db);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await db?.destroy();
|
|
await dropSchemas(schemas);
|
|
});
|
|
|
|
it("成功后记录结果;再次调用直接返回结果不重做", async () => {
|
|
let calls = 0;
|
|
const run = () =>
|
|
withAttemptToken(
|
|
db,
|
|
"op-1",
|
|
async (info) => {
|
|
calls++;
|
|
return { hash: "h1", attempt: info.attempt };
|
|
},
|
|
{
|
|
runId: "r1",
|
|
commit: async (trx, result) => {
|
|
await sql`INSERT INTO download_task (id, hash) VALUES ('d1', ${result.hash})`.execute(trx);
|
|
},
|
|
},
|
|
);
|
|
expect(await run()).toEqual({ hash: "h1", attempt: 1 });
|
|
expect(await run()).toEqual({ hash: "h1", attempt: 1 });
|
|
expect(calls).toBe(1);
|
|
const row = await getOperation(db, "op-1");
|
|
expect(row).toMatchObject({ status: "succeeded", attempts: 1, runId: "r1" });
|
|
});
|
|
|
|
it("租约丢失:旧 attempt 执行完后提交被拒绝,新 attempt 的结果生效", async () => {
|
|
let releaseOld!: () => void;
|
|
const oldGate = new Promise<void>((r) => {
|
|
releaseOld = r;
|
|
});
|
|
let oldStarted!: () => void;
|
|
const oldStartedP = new Promise<void>((r) => {
|
|
oldStarted = r;
|
|
});
|
|
|
|
// 旧 worker(被 SIGSTOP 之类卡住):已开始执行,尚未提交
|
|
const oldAttempt = withAttemptToken(
|
|
db,
|
|
"op-2",
|
|
async () => {
|
|
oldStarted();
|
|
await oldGate;
|
|
return "old";
|
|
},
|
|
{
|
|
commit: async (trx) => void (await sql`INSERT INTO download_task (id, hash) VALUES ('d2', 'old')`.execute(trx)),
|
|
},
|
|
);
|
|
await oldStartedP;
|
|
|
|
// 新 worker 接管同一操作:resumed=true,需要先对账外部状态
|
|
let resumed = false;
|
|
const fresh = await withAttemptToken(
|
|
db,
|
|
"op-2",
|
|
async (info) => {
|
|
resumed = info.resumed;
|
|
return "new";
|
|
},
|
|
{
|
|
commit: async (trx) => void (await sql`INSERT INTO download_task (id, hash) VALUES ('d2', 'new')`.execute(trx)),
|
|
},
|
|
);
|
|
expect(fresh).toBe("new");
|
|
expect(resumed).toBe(true);
|
|
|
|
releaseOld();
|
|
await expect(oldAttempt).rejects.toBeInstanceOf(FencingError);
|
|
const r = await sql<{ hash: string }>`SELECT hash FROM download_task WHERE id = 'd2'`.execute(db);
|
|
expect(r.rows).toEqual([{ hash: "new" }]);
|
|
expect(await getOperation(db, "op-2")).toMatchObject({ status: "succeeded", attempts: 2, result: "new" });
|
|
});
|
|
|
|
it("fn 抛错:记录 last_error,不写结果,可重试", async () => {
|
|
await expect(
|
|
withAttemptToken(db, "op-3", async () => {
|
|
throw new Error("qb timeout");
|
|
}),
|
|
).rejects.toThrow("qb timeout");
|
|
expect(await getOperation(db, "op-3")).toMatchObject({ status: "started", lastError: "qb timeout" });
|
|
expect(await withAttemptToken(db, "op-3", async (i) => i.attempt)).toBe(2);
|
|
});
|
|
});
|