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,116 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { type Kysely, sql, type Transaction } from "kysely";
|
||||
import { FencingError } from "./errors";
|
||||
import type { JsonValue } from "./types";
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 本包只用原生 SQL 访问自有表,不依赖业务库类型
|
||||
type AnyDb = Kysely<any>;
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 同上
|
||||
type AnyTrx = Transaction<any>;
|
||||
|
||||
export interface AttemptInfo {
|
||||
opKey: string;
|
||||
attemptToken: string;
|
||||
/** 第几次尝试(1 起) */
|
||||
attempt: number;
|
||||
/** 之前有 attempt 但没有结果:先对账外部状态(qB 按 hash、文件按路径+大小/hash),再决定收尾还是重做 */
|
||||
resumed: boolean;
|
||||
}
|
||||
|
||||
export interface WithAttemptTokenOptions<T> {
|
||||
runId?: string;
|
||||
/** 缺省新生成;每次调用都是一个新 attempt,会覆盖旧 token */
|
||||
attemptToken?: string;
|
||||
/** 在同一事务里、fencing 校验通过后提交业务状态;抛错则整体回滚 */
|
||||
commit?: (trx: AnyTrx, result: T, info: AttemptInfo) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 操作账本 fencing(ADR-002 提醒 3):
|
||||
* 1. 已有 succeeded 结果 → 直接返回,不重做;
|
||||
* 2. 否则写入新 attempt_token(旧 attempt 自此失效)并执行 fn;
|
||||
* 3. 提交时 `SELECT … FOR UPDATE` 校验 token 仍是自己,不是则抛 FencingError(租约已丢,拒绝提交)。
|
||||
*/
|
||||
export async function withAttemptToken<T extends JsonValue>(
|
||||
db: AnyDb,
|
||||
opKey: string,
|
||||
fn: (info: AttemptInfo) => Promise<T>,
|
||||
opts: WithAttemptTokenOptions<T> = {},
|
||||
): Promise<T> {
|
||||
const token = opts.attemptToken ?? randomUUID();
|
||||
const claimed = await sql<{ attempts: number }>`
|
||||
INSERT INTO tasks_op_ledger (op_key, run_id, attempt_token, attempts, status)
|
||||
VALUES (${opKey}, ${opts.runId ?? null}, ${token}, 1, 'started')
|
||||
ON CONFLICT (op_key) DO UPDATE
|
||||
SET attempt_token = EXCLUDED.attempt_token, attempts = tasks_op_ledger.attempts + 1,
|
||||
started_at = now(), run_id = COALESCE(EXCLUDED.run_id, tasks_op_ledger.run_id)
|
||||
WHERE tasks_op_ledger.status <> 'succeeded'
|
||||
RETURNING attempts`.execute(db);
|
||||
|
||||
const row = claimed.rows[0];
|
||||
if (!row) {
|
||||
const done = await sql<{ result: T }>`SELECT result FROM tasks_op_ledger WHERE op_key = ${opKey}`.execute(db);
|
||||
return done.rows[0]!.result;
|
||||
}
|
||||
|
||||
const info: AttemptInfo = { opKey, attemptToken: token, attempt: row.attempts, resumed: row.attempts > 1 };
|
||||
let result: T;
|
||||
try {
|
||||
result = await fn(info);
|
||||
} catch (err) {
|
||||
await sql`UPDATE tasks_op_ledger SET last_error = ${String((err as Error)?.message ?? err)}
|
||||
WHERE op_key = ${opKey} AND attempt_token = ${token}`.execute(db);
|
||||
throw err;
|
||||
}
|
||||
|
||||
await db.transaction().execute(async (trx) => {
|
||||
await assertAttemptToken(trx, opKey, token);
|
||||
if (opts.commit) await opts.commit(trx, result, info);
|
||||
await sql`UPDATE tasks_op_ledger
|
||||
SET status = 'succeeded', result = ${JSON.stringify(result)}::jsonb, completed_at = now(), last_error = NULL
|
||||
WHERE op_key = ${opKey}`.execute(trx);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 在业务事务里校验 token 并锁住账本行;用于 withAttemptToken 之外的业务提交 */
|
||||
export async function assertAttemptToken(trx: AnyTrx, opKey: string, attemptToken: string): Promise<void> {
|
||||
const cur = await sql<{ attempt_token: string; status: string }>`
|
||||
SELECT attempt_token, status FROM tasks_op_ledger WHERE op_key = ${opKey} FOR UPDATE`.execute(trx);
|
||||
const r = cur.rows[0];
|
||||
if (!r || r.attempt_token !== attemptToken || r.status === "succeeded") throw new FencingError(opKey);
|
||||
}
|
||||
|
||||
export interface OpLedgerRow {
|
||||
opKey: string;
|
||||
runId: string | null;
|
||||
attemptToken: string;
|
||||
attempts: number;
|
||||
status: "started" | "succeeded";
|
||||
result: unknown;
|
||||
lastError: string | null;
|
||||
}
|
||||
|
||||
export async function getOperation(db: AnyDb, opKey: string): Promise<OpLedgerRow | null> {
|
||||
const r = await sql<{
|
||||
op_key: string;
|
||||
run_id: string | null;
|
||||
attempt_token: string;
|
||||
attempts: number;
|
||||
status: "started" | "succeeded";
|
||||
result: unknown;
|
||||
last_error: string | null;
|
||||
}>`SELECT * FROM tasks_op_ledger WHERE op_key = ${opKey}`.execute(db);
|
||||
const row = r.rows[0];
|
||||
return row
|
||||
? {
|
||||
opKey: row.op_key,
|
||||
runId: row.run_id,
|
||||
attemptToken: row.attempt_token,
|
||||
attempts: row.attempts,
|
||||
status: row.status,
|
||||
result: row.result,
|
||||
lastError: row.last_error,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
Reference in new issue
Block a user