feat(db): 0013 tasks tables and 0014 agent_run_event; tasks uses db migrations

tasks_run_meta/tasks_fact/tasks_op_ledger move into packages/db (if not exists, old
tasks_migration tables dropped); packages/tasks/migrations and migrateTasksTables
removed, tests migrate their schema with @umt/db. agent_run_event(run_id, seq, type,
at, payload) with (run_id, seq) primary key; types regenerated, db-sync covers it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-27 19:15:50 +08:00
1 parent db5352eecd
commit da8e53bbca
13 files changed
+250 -36

No files matched your search

+1 -1
View File
@@ -65,7 +65,7 @@
"types": "mkdir -p types && deno types > types/deno.d.ts", "types": "mkdir -p types && deno types > types/deno.d.ts",
"typecheck": "deno task types && deno task typecheck:tsc && deno task typecheck:deno", "typecheck": "deno task types && deno task typecheck:tsc && deno task typecheck:deno",
"typecheck:tsc": "deno task --members -j 1 typecheck", "typecheck:tsc": "deno task --members -j 1 typecheck",
"typecheck:deno": "deno check apps/server/src/main.ts packages/contracts/src/index.ts packages/db/src/index.ts packages/db/scripts/migrate.ts packages/db/scripts/gen-types.ts packages/db/migrations/*.ts packages/domain/src/index.ts packages/tasks/src/index.ts packages/tasks/migrations/*.ts packages/agent/src/index.ts packages/adapters/src/index.ts packages/testkit/src/index.ts", "typecheck:deno": "deno check apps/server/src/main.ts packages/contracts/src/index.ts packages/db/src/index.ts packages/db/scripts/migrate.ts packages/db/scripts/gen-types.ts packages/db/migrations/*.ts packages/domain/src/index.ts packages/tasks/src/index.ts packages/agent/src/index.ts packages/adapters/src/index.ts packages/testkit/src/index.ts",
"lint": "deno run -A npm:@biomejs/biome check .", "lint": "deno run -A npm:@biomejs/biome check .",
"fmt": "deno run -A npm:@biomejs/biome check --write .", "fmt": "deno run -A npm:@biomejs/biome check --write .",
"test": "deno task --members -j 1 test", "test": "deno task --members -j 1 test",
+11 -1
View File
@@ -27,8 +27,10 @@ PostgreSQL 访问层:Kysely 客户端与连接池、表类型(`src/types.ts`)、
| `0010_agent` | agent_run、artifact、decision、approval、automation_grant | | `0010_agent` | agent_run、artifact、decision、approval、automation_grant |
| `0011_ops` | reconcile_run、server_mapping | | `0011_ops` | reconcile_run、server_mapping |
| `0012_organization` | organization_record(change_seq 序列 + 触发器)、organization_file_result;回填 asset_operation.organization_record_id | | `0012_organization` | organization_record(change_seq 序列 + 触发器)、organization_file_result;回填 asset_operation.organization_record_id |
| `0013_tasks_tables` | 并入 @umt/tasks 自有表:tasks_run_meta、tasks_fact、tasks_op_ledger(if not exists,可接管 W1 旧库) |
| `0014_agent_run_event` | agent_run_event:Agent 运行事件,主键 (run_id, seq),删 run 级联 |
## 表清单(41 张) ## 表清单(45 张)
### 底座 / 运维 ### 底座 / 运维
| 表 | 用途与关键不变量 | | 表 | 用途与关键不变量 |
@@ -100,6 +102,14 @@ PostgreSQL 访问层:Kysely 客户端与连接池、表类型(`src/types.ts`)、
| `decision` | 待处理中心条目;同一 `dedupe_key` 同时只有一个未关闭条目 | | `decision` | 待处理中心条目;同一 `dedupe_key` 同时只有一个未关闭条目 |
| `approval` | 单次审批,绑定 plan + payload_hash + expected_revision | | `approval` | 单次审批,绑定 plan + payload_hash + expected_revision |
| `automation_grant` | 持续授权(动作 + 范围 + 预算 + 策略版本),可撤销 | | `automation_grant` | 持续授权(动作 + 范围 + 预算 + 策略版本),可撤销 |
| `agent_run_event` | Agent 运行事件(9 种,见 contracts `AgentRunEventType`);**(run_id, seq) 主键**,先落库再推 SSE;payload = 事件主体去掉 type |
### 任务引擎(@umt/tasks 内部使用,不经 API 暴露)
| 表 | 用途与关键不变量 |
|---|---|
| `tasks_run_meta` | 引擎 run 的业务侧元数据(traceId/优先级/取消标志) |
| `tasks_fact` | signal 前先落库的事实,waitForFact 以它为准 |
| `tasks_op_ledger` | 操作账本 fencing:attempt_token,旧 attempt 提交被拒 |
### 整理 ### 整理
| 表 | 用途与关键不变量 | | 表 | 用途与关键不变量 |
@@ -0,0 +1,60 @@
import type { Kysely } from "kysely";
import { dropTables, execDdl, oneOf } from "../src/migration-helpers.ts";
// @umt/tasks 的三张自有表并入统一迁移(W1 审查 S2;原 packages/tasks/migrations/0001_tasks_tables)。
// 表结构与原迁移一致,tasks 包通过注入的 Kysely 访问:
// - tasks_run_meta:引擎 run 的业务侧元数据(traceId/优先级/取消标志),取消标志是 abortSignal 的来源
// - tasks_fact:signal 之前先落库的事实;waitForFact 以它为准,信号只用来加速唤醒
// - tasks_op_ledger:操作账本(fencing),记录 attempt_token,旧 attempt 的提交会被拒绝
// 用 if not exists:W1 期间用 migrateTasksTables 建过表的库可以直接接管;顺手删掉旧的独立迁移记录表。
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
export async function up(db: Kysely<any>): Promise<void> {
await execDdl(
db,
`
create table if not exists tasks_run_meta (
run_id text primary key,
task_name text not null,
task_version integer not null,
idempotency_key text not null,
trace_id text,
priority integer not null default 0,
cancel_requested_at timestamptz,
cancel_reason text,
created_at timestamptz not null default now()
);
create index if not exists tasks_run_meta_task_idx on tasks_run_meta (task_name, created_at);
create table if not exists tasks_fact (
id bigserial primary key,
run_id text not null,
name text not null,
payload jsonb,
created_at timestamptz not null default now()
);
create index if not exists tasks_fact_run_name_idx on tasks_fact (run_id, name, id);
create table if not exists tasks_op_ledger (
op_key text primary key,
run_id text,
attempt_token uuid not null,
attempts integer not null default 1,
status text not null ${oneOf("status", ["started", "succeeded"])},
result jsonb,
last_error text,
started_at timestamptz not null default now(),
completed_at timestamptz
);
do $$ begin
execute format('drop table if exists %I.tasks_migration, %I.tasks_migration_lock', current_schema(), current_schema());
end $$;
`,
);
}
// biome-ignore lint/suspicious/noExplicitAny: 同上
export async function down(db: Kysely<any>): Promise<void> {
await execDdl(db, dropTables(["tasks_op_ledger", "tasks_fact", "tasks_run_meta"]));
}
@@ -0,0 +1,40 @@
import type { Kysely } from "kysely";
import { dropTables, execDdl, oneOf } from "../src/migration-helpers.ts";
// Agent 运行事件持久化(W1 审查 B9;REQ §10.6):先落库再推 SSE,断线/跨进程按 (run_id, seq) 回放。
// type 取值与 contracts AgentRunEventType 一致;payload = 事件主体去掉 type(contracts agentRunEventToRow)。
const TYPES = [
"run_started",
"text_delta",
"thinking_delta",
"tool_call_start",
"tool_call_end",
"tool_call_rejected",
"compaction",
"run_completed",
"run_failed",
];
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
export async function up(db: Kysely<any>): Promise<void> {
await execDdl(
db,
`
create table agent_run_event (
run_id uuid not null references agent_run (id) on delete cascade,
seq bigint not null check (seq >= 0),
type text not null ${oneOf("type", TYPES)},
at timestamptz not null default now(),
payload jsonb not null default '{}'::jsonb,
constraint agent_run_event_pkey primary key (run_id, seq)
);
create index agent_run_event_at_idx on agent_run_event (at);
`,
);
}
// biome-ignore lint/suspicious/noExplicitAny: 同上
export async function down(db: Kysely<any>): Promise<void> {
await execDdl(db, dropTables(["agent_run_event"]));
}
+67
View File
@@ -37,6 +37,16 @@ export type AgentRunStatus =
| "failed" | "failed"
| "cancelled" | "cancelled"
| "budget_exceeded"; | "budget_exceeded";
export type AgentRunEventType =
| "run_started"
| "text_delta"
| "thinking_delta"
| "tool_call_start"
| "tool_call_end"
| "tool_call_rejected"
| "compaction"
| "run_completed"
| "run_failed";
export type ApprovalStatus = "pending" | "approved" | "rejected" | "revoked" | "expired" | "consumed"; export type ApprovalStatus = "pending" | "approved" | "rejected" | "revoked" | "expired" | "consumed";
export type ArtifactKind = "evidence" | "plan" | "proposal" | "report" | "snapshot" | "log"; export type ArtifactKind = "evidence" | "plan" | "proposal" | "report" | "snapshot" | "log";
export type AssetLocationLocationKind = "library" | "download_cache" | "hardlink" | "external"; export type AssetLocationLocationKind = "library" | "download_cache" | "hardlink" | "external";
@@ -175,6 +185,7 @@ export type SubscriptionOrigin = "user" | "agent" | "policy" | "import";
export type SubscriptionPriority = "immediate" | "tracking" | "new_work" | "upgrade"; export type SubscriptionPriority = "immediate" | "tracking" | "new_work" | "upgrade";
export type SubscriptionStatus = "active" | "paused" | "completed" | "cancelled"; export type SubscriptionStatus = "active" | "paused" | "completed" | "cancelled";
export type TaskStatus = "pending" | "running" | "waiting" | "succeeded" | "failed" | "cancelled" | "dead"; export type TaskStatus = "pending" | "running" | "waiting" | "succeeded" | "failed" | "cancelled" | "dead";
export type TasksOpLedgerStatus = "started" | "succeeded";
export type TitleAliasKind = "original" | "translated" | "short" | "site_title" | "romanized" | "other"; export type TitleAliasKind = "original" | "translated" | "short" | "site_title" | "romanized" | "other";
export type TitleAliasSource = "tmdb" | "user" | "site" | "agent" | "nfo" | "import"; export type TitleAliasSource = "tmdb" | "user" | "site" | "agent" | "nfo" | "import";
@@ -250,6 +261,14 @@ export interface AgentRunTable {
updated_at: GeneratedTimestamptz; updated_at: GeneratedTimestamptz;
} }
export interface AgentRunEventTable {
run_id: string;
seq: number;
type: AgentRunEventType;
at: GeneratedTimestamptz;
payload: Generated<Json>;
}
export interface ApiKeyTable { export interface ApiKeyTable {
id: Generated<string>; id: Generated<string>;
name: string; name: string;
@@ -951,6 +970,38 @@ export interface TaskTable {
updated_at: GeneratedTimestamptz; updated_at: GeneratedTimestamptz;
} }
export interface TasksFactTable {
id: Generated<number>;
run_id: string;
name: string;
payload: Json | null;
created_at: GeneratedTimestamptz;
}
export interface TasksOpLedgerTable {
op_key: string;
run_id: string | null;
attempt_token: string;
attempts: Generated<number>;
status: TasksOpLedgerStatus;
result: Json | null;
last_error: string | null;
started_at: GeneratedTimestamptz;
completed_at: Timestamptz | null;
}
export interface TasksRunMetaTable {
run_id: string;
task_name: string;
task_version: number;
idempotency_key: string;
trace_id: string | null;
priority: Generated<number>;
cancel_requested_at: Timestamptz | null;
cancel_reason: string | null;
created_at: GeneratedTimestamptz;
}
export interface TitleAliasTable { export interface TitleAliasTable {
id: Generated<string>; id: Generated<string>;
media_entity_id: string; media_entity_id: string;
@@ -969,6 +1020,7 @@ export interface DB {
acquisition_plan: AcquisitionPlanTable; acquisition_plan: AcquisitionPlanTable;
acquisition_slot: AcquisitionSlotTable; acquisition_slot: AcquisitionSlotTable;
agent_run: AgentRunTable; agent_run: AgentRunTable;
agent_run_event: AgentRunEventTable;
api_key: ApiKeyTable; api_key: ApiKeyTable;
approval: ApprovalTable; approval: ApprovalTable;
artifact: ArtifactTable; artifact: ArtifactTable;
@@ -1006,6 +1058,9 @@ export interface DB {
source_release: SourceReleaseTable; source_release: SourceReleaseTable;
subscription: SubscriptionTable; subscription: SubscriptionTable;
task: TaskTable; task: TaskTable;
tasks_fact: TasksFactTable;
tasks_op_ledger: TasksOpLedgerTable;
tasks_run_meta: TasksRunMetaTable;
title_alias: TitleAliasTable; title_alias: TitleAliasTable;
} }
@@ -1018,6 +1073,9 @@ export type AcquisitionSlotUpdate = Updateable<AcquisitionSlotTable>;
export type AgentRunRow = Selectable<AgentRunTable>; export type AgentRunRow = Selectable<AgentRunTable>;
export type NewAgentRun = Insertable<AgentRunTable>; export type NewAgentRun = Insertable<AgentRunTable>;
export type AgentRunUpdate = Updateable<AgentRunTable>; export type AgentRunUpdate = Updateable<AgentRunTable>;
export type AgentRunEventRow = Selectable<AgentRunEventTable>;
export type NewAgentRunEvent = Insertable<AgentRunEventTable>;
export type AgentRunEventUpdate = Updateable<AgentRunEventTable>;
export type ApiKeyRow = Selectable<ApiKeyTable>; export type ApiKeyRow = Selectable<ApiKeyTable>;
export type NewApiKey = Insertable<ApiKeyTable>; export type NewApiKey = Insertable<ApiKeyTable>;
export type ApiKeyUpdate = Updateable<ApiKeyTable>; export type ApiKeyUpdate = Updateable<ApiKeyTable>;
@@ -1129,6 +1187,15 @@ export type SubscriptionUpdate = Updateable<SubscriptionTable>;
export type TaskRow = Selectable<TaskTable>; export type TaskRow = Selectable<TaskTable>;
export type NewTask = Insertable<TaskTable>; export type NewTask = Insertable<TaskTable>;
export type TaskUpdate = Updateable<TaskTable>; export type TaskUpdate = Updateable<TaskTable>;
export type TasksFactRow = Selectable<TasksFactTable>;
export type NewTasksFact = Insertable<TasksFactTable>;
export type TasksFactUpdate = Updateable<TasksFactTable>;
export type TasksOpLedgerRow = Selectable<TasksOpLedgerTable>;
export type NewTasksOpLedger = Insertable<TasksOpLedgerTable>;
export type TasksOpLedgerUpdate = Updateable<TasksOpLedgerTable>;
export type TasksRunMetaRow = Selectable<TasksRunMetaTable>;
export type NewTasksRunMeta = Insertable<TasksRunMetaTable>;
export type TasksRunMetaUpdate = Updateable<TasksRunMetaTable>;
export type TitleAliasRow = Selectable<TitleAliasTable>; export type TitleAliasRow = Selectable<TitleAliasTable>;
export type NewTitleAlias = Insertable<TitleAliasTable>; export type NewTitleAlias = Insertable<TitleAliasTable>;
export type TitleAliasUpdate = Updateable<TitleAliasTable>; export type TitleAliasUpdate = Updateable<TitleAliasTable>;
+34 -1
View File
@@ -63,8 +63,24 @@ const EXPECTED_TABLES = [
"server_mapping", "server_mapping",
"organization_record", "organization_record",
"organization_file_result", "organization_file_result",
// tasks 包自有表(0013)与 agent 事件(0014)
"tasks_run_meta",
"tasks_fact",
"tasks_op_ledger",
"agent_run_event",
].sort(); ].sort();
/** 不走 entityTable() 的表:追加型/引擎自有表,没有 uuid id + created_at/updated_at 三件套 */
const NON_ENTITY_TABLES = [
"settings",
"audit_event",
"outbox_event",
"tasks_run_meta",
"tasks_fact",
"tasks_op_ledger",
"agent_run_event",
];
describe.skipIf(skip)("schema migrations (real PG)", () => { describe.skipIf(skip)("schema migrations (real PG)", () => {
let admin: Kysely<unknown>; let admin: Kysely<unknown>;
let db: Kysely<DB>; let db: Kysely<DB>;
@@ -108,7 +124,7 @@ describe.skipIf(skip)("schema migrations (real PG)", () => {
select table_name, column_name, column_default from information_schema.columns select table_name, column_name, column_default from information_schema.columns
where table_schema = current_schema() and column_name in ('id', 'created_at', 'updated_at')`.execute(db) where table_schema = current_schema() and column_name in ('id', 'created_at', 'updated_at')`.execute(db)
).rows; ).rows;
for (const t of EXPECTED_TABLES.filter((x) => !["settings", "audit_event", "outbox_event"].includes(x))) { for (const t of EXPECTED_TABLES.filter((x) => !NON_ENTITY_TABLES.includes(x))) {
const cols = rows.filter((r) => r.table_name === t); const cols = rows.filter((r) => r.table_name === t);
expect(cols.map((c) => c.column_name).sort(), t).toEqual(["created_at", "id", "updated_at"]); expect(cols.map((c) => c.column_name).sort(), t).toEqual(["created_at", "id", "updated_at"]);
expect(cols.find((c) => c.column_name === "id")?.column_default, t).toBe("gen_random_uuid()"); expect(cols.find((c) => c.column_name === "id")?.column_default, t).toBe("gen_random_uuid()");
@@ -291,6 +307,23 @@ describe.skipIf(skip)("schema migrations (real PG)", () => {
expect(hit.rows.map((r) => r.id)).toContain(ent.id); expect(hit.rows.map((r) => r.id)).toContain(ent.id);
}); });
it("agent_run_event:(run_id, seq) 唯一,删 run 级联删事件", async () => {
const run = await db
.insertInto("agent_run")
.values({ trigger: "user", goal: "g" })
.returning("id")
.executeTakeFirstOrThrow();
const ev = { run_id: run.id, seq: 0, type: "run_started" as const, payload: JSON.stringify({ goal: "g" }) };
await db.insertInto("agent_run_event").values(ev).execute();
await expect(db.insertInto("agent_run_event").values(ev).execute()).rejects.toThrow(/agent_run_event_pkey/);
await expect(
sql`insert into agent_run_event (run_id, seq, type) values (${run.id}, 1, 'bogus')`.execute(db),
).rejects.toThrow(/check constraint/);
await db.deleteFrom("agent_run").where("id", "=", run.id).execute();
const left = await db.selectFrom("agent_run_event").select("seq").where("run_id", "=", run.id).execute();
expect(left).toEqual([]);
});
it("迁移可完整 down 再 up(往返)", async () => { it("迁移可完整 down 再 up(往返)", async () => {
const migrator = createMigrator(db, { migrationTableSchema: schema }); const migrator = createMigrator(db, { migrationTableSchema: schema });
const down = await migrator.migrateTo(NO_MIGRATIONS); const down = await migrator.migrateTo(NO_MIGRATIONS);
+2 -2
View File
@@ -16,7 +16,7 @@
| `defineTask` / `TaskRegistry` / `createDefaultRegistry()` | 任务定义与注册表;默认注册表里有 §3.1-8 的 9 个任务(目前是空壳) | | `defineTask` / `TaskRegistry` / `createDefaultRegistry()` | 任务定义与注册表;默认注册表里有 §3.1-8 的 9 个任务(目前是空壳) |
| `OutboxRelay` | 用 `FOR UPDATE SKIP LOCKED` 认领 `outbox_event` 中 `status=pending` 且 `next_attempt_at` 已到的行,按 type 路由后 enqueue(幂等键取行的 `idempotency_key`,缺省 `outbox:<id>`),成功标记 `dispatched` + `published_at`;失败 attempts+1 指数退避(上限 5 分钟),达 `maxAttempts` 置 `dead`;多进程并发时不会重复 | | `OutboxRelay` | 用 `FOR UPDATE SKIP LOCKED` 认领 `outbox_event` 中 `status=pending` 且 `next_attempt_at` 已到的行,按 type 路由后 enqueue(幂等键取行的 `idempotency_key`,缺省 `outbox:<id>`),成功标记 `dispatched` + `published_at`;失败 attempts+1 指数退避(上限 5 分钟),达 `maxAttempts` 置 `dead`;多进程并发时不会重复 |
| `withAttemptToken(db, opKey, fn, { commit })` | 操作账本 fencing,表是 `tasks_op_ledger` | | `withAttemptToken(db, opKey, fn, { commit })` | 操作账本 fencing,表是 `tasks_op_ledger` |
| `migrateTasksTables(db)` | 本包迁移(`migrations/`):`tasks_run_meta`、`tasks_fact`、`tasks_op_ledger`。W2 并入 `packages/db` | | 表 | `tasks_run_meta`、`tasks_fact`、`tasks_op_ledger` 由 `packages/db` 迁移 `0013_tasks_tables` 建立(类型见 `@umt/db` 的 `DB`);本包不再自带迁移,只通过注入的 Kysely 访问 |
`TaskContext` 包含 `input`(已经过 zod 校验)、`runId`、`traceId`、`priority`、`attemptToken`(每次执行 handler 都会重新生成)、`abortSignal`、`logger`(自动脱敏)、`step.run / step.sleep / step.waitForFact`、`throwIfCanceled()`。 `TaskContext` 包含 `input`(已经过 zod 校验)、`runId`、`traceId`、`priority`、`attemptToken`(每次执行 handler 都会重新生成)、`abortSignal`、`logger`(自动脱敏)、`step.run / step.sleep / step.waitForFact`、`throwIfCanceled()`。
@@ -54,4 +54,4 @@
## 测试 ## 测试
`deno task test`(或本包 `deno task test`)会连接开发库(`DATABASE_URL`,默认 `postgres://umt:umt@localhost:55433/umt`)。每个测试文件使用独立的 `tasks_test_<名>_<pid+随机>` schema(并发跑多份也不冲突),测试前后都会清理;库不可达时跳过集成测试。覆盖范围:kill -9 后续跑(子进程 worker)、取消、幂等 enqueue、两个 relay 并发、事实先到达、fencing。 `deno task test`(或本包 `deno task test`)会连接开发库,每个测试 schema 先用 `@umt/db` 的 `migrateToLatest` 建全部业务表(`DATABASE_URL`,默认 `postgres://umt:umt@localhost:55433/umt`)。每个测试文件使用独立的 `tasks_test_<名>_<pid+随机>` schema(并发跑多份也不冲突),测试前后都会清理;库不可达时跳过集成测试。覆盖范围:kill -9 后续跑(子进程 worker)、取消、幂等 enqueue、两个 relay 并发、事实先到达、fencing。
-1
View File
@@ -26,7 +26,6 @@ export {
silentSink, silentSink,
type TaskLogger, type TaskLogger,
} from "./logger.ts"; } from "./logger.ts";
export { migrateTasksTables, TASKS_MIGRATIONS_DIR } from "./migrate.ts";
export { export {
type AttemptInfo, type AttemptInfo,
assertAttemptToken, assertAttemptToken,
@@ -6,6 +6,7 @@ import {
connectDb, connectDb,
createSideEffectTable, createSideEffectTable,
dropSchemas, dropSchemas,
migrateBizSchema,
pgAvailable, pgAvailable,
resetSchemas, resetSchemas,
setupEngine, setupEngine,
@@ -46,6 +47,7 @@ describe.skipIf(!hasPg)("崩溃恢复(worker 子进程 kill -9)", () => {
beforeAll(async () => { beforeAll(async () => {
await resetSchemas(schemas); await resetSchemas(schemas);
db = connectDb(schemas.biz); db = connectDb(schemas.biz);
await migrateBizSchema(db, schemas.biz);
await createSideEffectTable(db); await createSideEffectTable(db);
engine = await setupEngine(db, schemas, createTestRegistry(db)); // 只做客户端,不起 worker engine = await setupEngine(db, schemas, createTestRegistry(db)); // 只做客户端,不起 worker
}, 30_000); }, 30_000);
+2
View File
@@ -6,6 +6,7 @@ import {
connectDb, connectDb,
createSideEffectTable, createSideEffectTable,
dropSchemas, dropSchemas,
migrateBizSchema,
pgAvailable, pgAvailable,
resetSchemas, resetSchemas,
setupEngine, setupEngine,
@@ -25,6 +26,7 @@ describe.skipIf(!hasPg)("OpenWorkflowEngine(进程内 worker)", () => {
beforeAll(async () => { beforeAll(async () => {
await resetSchemas(schemas); await resetSchemas(schemas);
db = connectDb(schemas.biz); db = connectDb(schemas.biz);
await migrateBizSchema(db, schemas.biz);
await createSideEffectTable(db); await createSideEffectTable(db);
engine = await setupEngine(db, schemas, createTestRegistry(db)); engine = await setupEngine(db, schemas, createTestRegistry(db));
await engine.startWorker({ concurrency: 4 }); await engine.startWorker({ concurrency: 4 });
+10 -18
View File
@@ -1,6 +1,7 @@
import { migrateToLatest } from "@umt/db";
import { Kysely, PostgresDialect, sql } from "kysely"; import { Kysely, PostgresDialect, sql } from "kysely";
import pg from "pg"; import pg from "pg";
import { migrateTasksTables, OpenWorkflowEngine, silentSink, type TaskRegistry } from "../src/index.ts"; import { OpenWorkflowEngine, silentSink, type TaskRegistry } from "../src/index.ts";
export const PG_URL = export const PG_URL =
process.env.TASKS_TEST_DATABASE_URL ?? process.env.DATABASE_URL ?? "postgres://umt:umt@localhost:55433/umt"; process.env.TASKS_TEST_DATABASE_URL ?? process.env.DATABASE_URL ?? "postgres://umt:umt@localhost:55433/umt";
@@ -32,11 +33,17 @@ export function testSchemas(suffix: string) {
return { biz, ow: `${biz}_ow` }; return { biz, ow: `${biz}_ow` };
} }
/** search_path 带上 public:db 迁移用到的 pg_trgm 操作符类装在 public */
export function connectDb(schema: string): TestDb { export function connectDb(schema: string): TestDb {
const pool = new pg.Pool({ connectionString: PG_URL, max: 6, options: `-c search_path=${schema}` }); const pool = new pg.Pool({ connectionString: PG_URL, max: 6, options: `-c search_path=${schema},public` });
return new Kysely({ dialect: new PostgresDialect({ pool }) }); return new Kysely({ dialect: new PostgresDialect({ pool }) });
} }
/** 用 @umt/db 的统一迁移在测试 schema 里建全部业务表(含 tasks_* 与 outbox_event) */
export async function migrateBizSchema(db: TestDb, schema: string) {
await migrateToLatest(db, { migrationTableSchema: schema });
}
export async function resetSchemas(schemas: { biz: string; ow: string }) { export async function resetSchemas(schemas: { biz: string; ow: string }) {
const admin = connectDb("public"); const admin = connectDb("public");
try { try {
@@ -58,21 +65,6 @@ export async function dropSchemas(schemas: { biz: string; ow: string }) {
} }
} }
/** 与 packages/db 0001_init + 0002_foundation 的 outbox_event 同构 */
export async function createOutboxTable(db: TestDb) {
await sql`CREATE TABLE outbox_event (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
at timestamptz NOT NULL DEFAULT now(),
type text NOT NULL,
payload jsonb NOT NULL DEFAULT '{}'::jsonb,
published_at timestamptz,
idempotency_key text UNIQUE,
status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'dispatched', 'failed', 'dead')),
attempts integer NOT NULL DEFAULT 0,
next_attempt_at timestamptz NOT NULL DEFAULT now(),
last_error jsonb)`.execute(db);
}
export async function createSideEffectTable(db: TestDb) { export async function createSideEffectTable(db: TestDb) {
await sql`CREATE TABLE IF NOT EXISTS side_effect ( await sql`CREATE TABLE IF NOT EXISTS side_effect (
id bigserial PRIMARY KEY, run_id text NOT NULL, step text NOT NULL, note text, pid int, id bigserial PRIMARY KEY, run_id text NOT NULL, step text NOT NULL, note text, pid int,
@@ -85,8 +77,8 @@ export async function sideEffects(db: TestDb, runId: string) {
return r.rows; return r.rows;
} }
/** 只连接引擎;业务表由调用方先 migrateBizSchema 建好 */
export async function setupEngine(db: TestDb, schemas: { ow: string }, registry: TaskRegistry) { export async function setupEngine(db: TestDb, schemas: { ow: string }, registry: TaskRegistry) {
await migrateTasksTables(db);
return OpenWorkflowEngine.connect({ return OpenWorkflowEngine.connect({
databaseUrl: PG_URL, databaseUrl: PG_URL,
db, db,
+16 -8
View File
@@ -1,7 +1,15 @@
import { sql } from "kysely"; import { sql } from "kysely";
import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { FencingError, getOperation, migrateTasksTables, withAttemptToken } from "../src/index.ts"; import { FencingError, getOperation, withAttemptToken } from "../src/index.ts";
import { connectDb, dropSchemas, pgAvailable, resetSchemas, type TestDb, testSchemas } from "./helpers.ts"; import {
connectDb,
dropSchemas,
migrateBizSchema,
pgAvailable,
resetSchemas,
type TestDb,
testSchemas,
} from "./helpers.ts";
const schemas = testSchemas("ledger"); const schemas = testSchemas("ledger");
const hasPg = await pgAvailable(); const hasPg = await pgAvailable();
@@ -12,8 +20,8 @@ describe.skipIf(!hasPg)("withAttemptToken(操作账本 fencing)", () => {
beforeAll(async () => { beforeAll(async () => {
await resetSchemas(schemas); await resetSchemas(schemas);
db = connectDb(schemas.biz); db = connectDb(schemas.biz);
await migrateTasksTables(db); await migrateBizSchema(db, schemas.biz);
await sql`CREATE TABLE download_task (id text PRIMARY KEY, hash text)`.execute(db); await sql`CREATE TABLE ledger_probe (id text PRIMARY KEY, hash text)`.execute(db);
}); });
afterAll(async () => { afterAll(async () => {
@@ -34,7 +42,7 @@ describe.skipIf(!hasPg)("withAttemptToken(操作账本 fencing)", () => {
{ {
runId: "r1", runId: "r1",
commit: async (trx, result) => { commit: async (trx, result) => {
await sql`INSERT INTO download_task (id, hash) VALUES ('d1', ${result.hash})`.execute(trx); await sql`INSERT INTO ledger_probe (id, hash) VALUES ('d1', ${result.hash})`.execute(trx);
}, },
}, },
); );
@@ -65,7 +73,7 @@ describe.skipIf(!hasPg)("withAttemptToken(操作账本 fencing)", () => {
return "old"; return "old";
}, },
{ {
commit: async (trx) => void (await sql`INSERT INTO download_task (id, hash) VALUES ('d2', 'old')`.execute(trx)), commit: async (trx) => void (await sql`INSERT INTO ledger_probe (id, hash) VALUES ('d2', 'old')`.execute(trx)),
}, },
); );
await oldStartedP; await oldStartedP;
@@ -80,7 +88,7 @@ describe.skipIf(!hasPg)("withAttemptToken(操作账本 fencing)", () => {
return "new"; return "new";
}, },
{ {
commit: async (trx) => void (await sql`INSERT INTO download_task (id, hash) VALUES ('d2', 'new')`.execute(trx)), commit: async (trx) => void (await sql`INSERT INTO ledger_probe (id, hash) VALUES ('d2', 'new')`.execute(trx)),
}, },
); );
expect(fresh).toBe("new"); expect(fresh).toBe("new");
@@ -88,7 +96,7 @@ describe.skipIf(!hasPg)("withAttemptToken(操作账本 fencing)", () => {
releaseOld(); releaseOld();
await expect(oldAttempt).rejects.toBeInstanceOf(FencingError); await expect(oldAttempt).rejects.toBeInstanceOf(FencingError);
const r = await sql<{ hash: string }>`SELECT hash FROM download_task WHERE id = 'd2'`.execute(db); const r = await sql<{ hash: string }>`SELECT hash FROM ledger_probe WHERE id = 'd2'`.execute(db);
expect(r.rows).toEqual([{ hash: "new" }]); expect(r.rows).toEqual([{ hash: "new" }]);
expect(await getOperation(db, "op-2")).toMatchObject({ status: "succeeded", attempts: 2, result: "new" }); expect(await getOperation(db, "op-2")).toMatchObject({ status: "succeeded", attempts: 2, result: "new" });
}); });
+5 -4
View File
@@ -4,9 +4,9 @@ import { type EnqueueOptions, OutboxRelay, type TaskEngine } from "../src/index.
import { createTestRegistry } from "./fixtures/tasks.ts"; import { createTestRegistry } from "./fixtures/tasks.ts";
import { import {
connectDb, connectDb,
createOutboxTable,
createSideEffectTable, createSideEffectTable,
dropSchemas, dropSchemas,
migrateBizSchema,
pgAvailable, pgAvailable,
resetSchemas, resetSchemas,
setupEngine, setupEngine,
@@ -24,7 +24,7 @@ describe.skipIf(!hasPg)("OutboxRelay", () => {
beforeAll(async () => { beforeAll(async () => {
await resetSchemas(schemas); await resetSchemas(schemas);
db = connectDb(schemas.biz); db = connectDb(schemas.biz);
await createOutboxTable(db); await migrateBizSchema(db, schemas.biz);
await createSideEffectTable(db); await createSideEffectTable(db);
engine = await setupEngine(db, schemas, createTestRegistry(db)); engine = await setupEngine(db, schemas, createTestRegistry(db));
}, 30_000); }, 30_000);
@@ -80,11 +80,12 @@ describe.skipIf(!hasPg)("OutboxRelay", () => {
SELECT count(*) n FROM outbox_event WHERE status = 'dispatched' AND published_at IS NOT NULL AND attempts = 1`.execute( SELECT count(*) n FROM outbox_event WHERE status = 'dispatched' AND published_at IS NOT NULL AND attempts = 1`.execute(
db, db,
); );
expect(dispatched.rows[0]!.n).toBe("60"); // @umt/db 把 int8 解析为 number(全局 pg 类型解析器),这里统一转成数字比较
expect(Number(dispatched.rows[0]!.n)).toBe(60);
const runs = await sql<{ n: string; keys: string }>` const runs = await sql<{ n: string; keys: string }>`
SELECT count(*) n, count(DISTINCT idempotency_key) keys FROM tasks_run_meta WHERE task_name = 'echo'`.execute(db); SELECT count(*) n, count(DISTINCT idempotency_key) keys FROM tasks_run_meta WHERE task_name = 'echo'`.execute(db);
expect(runs.rows[0]).toEqual({ n: "60", keys: "60" }); expect([Number(runs.rows[0]!.n), Number(runs.rows[0]!.keys)]).toEqual([60, 60]);
const traced = await sql<{ trace_id: string }>` const traced = await sql<{ trace_id: string }>`
SELECT m.trace_id FROM tasks_run_meta m SELECT m.trace_id FROM tasks_run_meta m
JOIN outbox_event o ON m.idempotency_key = 'outbox:' || o.id::text JOIN outbox_event o ON m.idempotency_key = 'outbox:' || o.id::text