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:
1 parent
ec023a8405
commit
1262340856
22 files changed
+1818
-8
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user