feat(tasks): OpenWorkflow-backed TaskEngine, task registry, outbox relay and op-ledger fencing (W1-4)

- TaskEngine port + OpenWorkflowEngine ([email protected].1, schema openworkflow):
  enqueue/signal/cancel/get/list/startWorker/stop
- defineTask registry with zod input schema; 9 stub task definitions (§3.1-8)
- TaskContext: step.run/sleep/waitForFact, redacting logger, traceId,
  attemptToken, abortSignal driven by PG cancel flag
- signal persists fact to tasks_fact first; waitForFact checks DB before waiting
- OutboxRelay: FOR UPDATE SKIP LOCKED, idempotencyKey outbox:<id>
- withAttemptToken fencing on tasks_op_ledger (own migration, merged in W2)
- tests: kill -9 crash recovery (child worker), cancel, idempotency,
  concurrent relays, early fact, lease-loss fencing

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
rootandClaude Fable 5.1 committed 2026-09-27 17:51:21 +08:00
1 parent ec023a8405
commit 1262340856
22 files changed
+1818 -8

No files matched your search

+112
View File
@@ -0,0 +1,112 @@
import { randomUUID } from "node:crypto";
import { type Kysely, sql } from "kysely";
import { createLogger, type LogSink } from "./logger";
import type { TaskEngine } from "./types";
// biome-ignore lint/suspicious/noExplicitAny: 只用原生 SQL 读写 outbox_event
type AnyDb = Kysely<any>;
export interface OutboxEvent {
id: string;
type: string;
payload: unknown;
at: Date;
}
export interface OutboxRoute {
task: string;
input: unknown;
/** 缺省用事件 payload.traceId,再缺省生成新的 */
traceId?: string;
priority?: number;
}
/** 返回 null 表示该事件无需建任务,直接标记已投递 */
export type OutboxRouteFn = (event: OutboxEvent) => OutboxRoute | null;
export interface OutboxRelayOptions {
db: AnyDb;
engine: Pick<TaskEngine, "enqueue">;
/** 事件 type → 任务映射;只认领 routes 里列出的 type */
routes: Record<string, OutboxRouteFn>;
batchSize?: number;
intervalMs?: number;
logSink?: LogSink;
}
/**
* Outbox 中继:业务事务写 outbox_event,中继在自己的事务里
* `SELECT … FOR UPDATE SKIP LOCKED` 认领未投递行 → enqueue(幂等键 = outbox:<行 id>)→ 标记 published_at。
* 多个中继进程并发时互不重复认领;enqueue 成功但标记前崩溃,重投时由引擎幂等键去重(24h),
* 更长的去重由业务唯一约束兜底。
*/
export class OutboxRelay {
private timer: NodeJS.Timeout | null = null;
private running: Promise<unknown> | null = null;
private readonly log;
readonly id = randomUUID().slice(0, 8);
constructor(private readonly opts: OutboxRelayOptions) {
this.log = createLogger(opts.logSink, { component: "tasks.outbox", relay: this.id });
}
static idempotencyKey(eventId: string) {
return `outbox:${eventId}`;
}
/** 处理一批;返回本批已投递与失败数 */
async pollOnce(): Promise<{ published: number; failed: number }> {
const types = Object.keys(this.opts.routes);
if (types.length === 0) return { published: 0, failed: 0 };
let published = 0;
let failed = 0;
await this.opts.db.transaction().execute(async (trx) => {
const rows = await sql<OutboxEvent>`
SELECT id, type, payload, at FROM outbox_event
WHERE published_at IS NULL AND type = ANY(${types}::text[])
ORDER BY at, id
LIMIT ${this.opts.batchSize ?? 50}
FOR UPDATE SKIP LOCKED`.execute(trx);
for (const ev of rows.rows) {
try {
const route = this.opts.routes[ev.type]!(ev);
if (route) {
const payloadTrace = (ev.payload as { traceId?: unknown } | null)?.traceId;
await this.opts.engine.enqueue(route.task, route.input, {
idempotencyKey: OutboxRelay.idempotencyKey(ev.id),
traceId: route.traceId ?? (typeof payloadTrace === "string" ? payloadTrace : randomUUID()),
...(route.priority === undefined ? {} : { priority: route.priority }),
});
}
await sql`UPDATE outbox_event SET published_at = now() WHERE id = ${ev.id}`.execute(trx);
published++;
} catch (err) {
failed++;
// 留在未投递状态,下一轮重试
this.log.error("outbox dispatch failed", { eventId: ev.id, type: ev.type, err });
}
}
});
return { published, failed };
}
start() {
if (this.timer) return;
const tick = () => {
if (this.running) return;
this.running = this.pollOnce()
.catch((err) => this.log.error("outbox poll failed", { err }))
.finally(() => {
this.running = null;
});
};
this.timer = setInterval(tick, this.opts.intervalMs ?? 1000);
tick();
}
async stop() {
if (this.timer) clearInterval(this.timer);
this.timer = null;
await this.running;
}
}