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

+135
View File
@@ -0,0 +1,135 @@
import { z } from "zod";
import { defineTask, TaskRegistry } from "./registry";
// 任务书 §3.1-8 的九类任务。W1 只落空壳:输入 schema + 回显 handler,W2 填充处理器。
// 填充规则见 README「新增/填充任务」:副作用走 withAttemptToken,外部条件用 step.waitForFact,
// 业务状态写业务表,不要放进 handler 返回值。
const id = z.string().min(1);
/** 空壳 handler:记录一条日志并回显输入 */
function stub<I>(name: string) {
return async (ctx: { input: I; logger: { info(msg: string, f?: Record<string, unknown>): void } }) => {
// TODO(W2): 实现真实处理器
ctx.logger.info(`${name}: stub handler (TODO W2)`);
return { todo: true as const, task: name, input: ctx.input };
};
}
const NETWORK_RETRY = {
maximumAttempts: 5,
initialInterval: "1s",
backoffCoefficient: 2,
maximumInterval: "60s",
} as const;
const FILE_RETRY = {
maximumAttempts: 3,
initialInterval: "2s",
backoffCoefficient: 2,
maximumInterval: "60s",
} as const;
const DB_RETRY = { maximumAttempts: 3, initialInterval: "1s", backoffCoefficient: 2, maximumInterval: "30s" } as const;
export const ingestFeed = defineTask({
name: "ingestFeed",
version: 1,
inputSchema: z.object({ feedSourceId: id }),
retryPolicy: NETWORK_RETRY,
handler: stub("ingestFeed"),
});
export const resolveObservation = defineTask({
name: "resolveObservation",
version: 1,
inputSchema: z.object({ observationId: id }),
retryPolicy: NETWORK_RETRY,
handler: stub("resolveObservation"),
});
export const evaluateAcquisition = defineTask({
name: "evaluateAcquisition",
version: 1,
inputSchema: z.object({
subscriptionId: id.optional(),
acquisitionSlotId: id.optional(),
sourceReleaseId: id.optional(),
}),
retryPolicy: DB_RETRY,
handler: stub("evaluateAcquisition"),
});
export const submitDownload = defineTask({
name: "submitDownload",
version: 1,
inputSchema: z.object({ acquisitionPlanId: id }),
retryPolicy: NETWORK_RETRY,
handler: stub("submitDownload"),
});
export const verifyAndImport = defineTask({
name: "verifyAndImport",
version: 1,
inputSchema: z.object({ downloadTaskId: id }),
retryPolicy: FILE_RETRY,
handler: stub("verifyAndImport"),
});
export const refreshMetadata = defineTask({
name: "refreshMetadata",
version: 1,
inputSchema: z.object({ mediaEntityId: id }),
retryPolicy: NETWORK_RETRY,
handler: stub("refreshMetadata"),
});
export const reconcileLibrary = defineTask({
name: "reconcileLibrary",
version: 1,
inputSchema: z.object({ libraryRootId: id.optional(), reconcileRunId: id.optional() }),
retryPolicy: FILE_RETRY,
handler: stub("reconcileLibrary"),
});
export const cleanupDownloadCache = defineTask({
name: "cleanupDownloadCache",
version: 1,
inputSchema: z.object({ downloaderInstanceId: id.optional() }),
retryPolicy: FILE_RETRY,
handler: stub("cleanupDownloadCache"),
});
export const resolveDecision = defineTask({
name: "resolveDecision",
version: 1,
inputSchema: z.object({ decisionId: id }),
retryPolicy: DB_RETRY,
handler: stub("resolveDecision"),
});
export const TASK_NAMES = [
"ingestFeed",
"resolveObservation",
"evaluateAcquisition",
"submitDownload",
"verifyAndImport",
"refreshMetadata",
"reconcileLibrary",
"cleanupDownloadCache",
"resolveDecision",
] as const;
export type TaskName = (typeof TASK_NAMES)[number];
/** v0.1 的默认注册表;W2 把真实 handler 换进来(同名同版本替换,或升版本并保留旧版本) */
export function createDefaultRegistry(): TaskRegistry {
return new TaskRegistry().register(
ingestFeed,
resolveObservation,
evaluateAcquisition,
submitDownload,
verifyAndImport,
refreshMetadata,
reconcileLibrary,
cleanupDownloadCache,
resolveDecision,
);
}
+21
View File
@@ -0,0 +1,21 @@
export class TaskCanceledError extends Error {
override name = "TaskCanceledError";
constructor(runId: string, reason?: string | null) {
super(`task run ${runId} canceled${reason ? `: ${reason}` : ""}`);
}
}
/** 租约已丢:该 opKey 已有更新的 attempt,当前 attempt 的提交被拒绝 */
export class FencingError extends Error {
override name = "FencingError";
constructor(opKey: string) {
super(`operation ${opKey}: attempt token superseded, commit rejected`);
}
}
export class UnknownTaskError extends Error {
override name = "UnknownTaskError";
constructor(name: string, version?: number) {
super(`task ${name}${version === undefined ? "" : `@${version}`} is not registered`);
}
}
+46 -1
View File
@@ -1,2 +1,47 @@
// @umt/tasks 公开入口;W1 起在此导出实现
// @umt/tasks 公开入口:TaskEngine 端口 + OpenWorkflow 实现、任务注册表、outbox 中继、操作账本 fencing
export const PACKAGE_NAME = "@umt/tasks";
export {
cleanupDownloadCache,
createDefaultRegistry,
evaluateAcquisition,
ingestFeed,
reconcileLibrary,
refreshMetadata,
resolveDecision,
resolveObservation,
submitDownload,
TASK_NAMES,
type TaskName,
verifyAndImport,
} from "./definitions";
export { FencingError, TaskCanceledError, UnknownTaskError } from "./errors";
export {
consoleSink,
createLogger,
type LogLevel,
type LogRecord,
type LogSink,
redact,
silentSink,
type TaskLogger,
} from "./logger";
export { migrateTasksTables, TASKS_MIGRATIONS_DIR } from "./migrate";
export {
type AttemptInfo,
assertAttemptToken,
getOperation,
type OpLedgerRow,
type WithAttemptTokenOptions,
withAttemptToken,
} from "./op-ledger";
export { ENGINE_SCHEMA, OpenWorkflowEngine, type OpenWorkflowEngineOptions, signalName } from "./openworkflow-engine";
export {
type OutboxEvent,
OutboxRelay,
type OutboxRelayOptions,
type OutboxRoute,
type OutboxRouteFn,
} from "./outbox-relay";
export { defineTask, type TaskDefinition, TaskRegistry } from "./registry";
export type * from "./types";
+64
View File
@@ -0,0 +1,64 @@
export type LogLevel = "debug" | "info" | "warn" | "error";
export interface LogRecord {
level: LogLevel;
msg: string;
at: string;
fields: Record<string, unknown>;
}
export type LogSink = (record: LogRecord) => void;
export interface TaskLogger {
debug(msg: string, fields?: Record<string, unknown>): void;
info(msg: string, fields?: Record<string, unknown>): void;
warn(msg: string, fields?: Record<string, unknown>): void;
error(msg: string, fields?: Record<string, unknown>): void;
child(fields: Record<string, unknown>): TaskLogger;
}
const SENSITIVE_KEY = /pass(word|key)?|secret|token|cookie|authorization|api[-_]?key|credential|session/i;
// URL 查询参数里的凭证(passkey、token、apikey、sign 等)
const SENSITIVE_QUERY = /([?&](?:passkey|token|apikey|api_key|key|sign|secret|auth)=)[^&#\s]+/gi;
const REDACTED = "***";
/** 深拷贝并脱敏:敏感键名整体替换,字符串里的凭证型 URL 参数替换 */
export function redact(value: unknown, depth = 0): unknown {
if (depth > 8) return "[depth]";
if (typeof value === "string") return value.replace(SENSITIVE_QUERY, `$1${REDACTED}`);
if (Array.isArray(value)) return value.map((v) => redact(v, depth + 1));
if (value instanceof Error) return { name: value.name, message: redact(value.message, depth + 1) };
if (value && typeof value === "object") {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value)) out[k] = SENSITIVE_KEY.test(k) ? REDACTED : redact(v, depth + 1);
return out;
}
return value;
}
export const consoleSink: LogSink = (r) => {
const line = JSON.stringify(r);
if (r.level === "error" || r.level === "warn") console.error(line);
else console.log(line);
};
export const silentSink: LogSink = () => {};
export function createLogger(sink: LogSink = consoleSink, base: Record<string, unknown> = {}): TaskLogger {
const emit =
(level: LogLevel) =>
(msg: string, fields: Record<string, unknown> = {}) =>
sink({
level,
msg: redact(msg) as string,
at: new Date().toISOString(),
fields: redact({ ...base, ...fields }) as Record<string, unknown>,
});
return {
debug: emit("debug"),
info: emit("info"),
warn: emit("warn"),
error: emit("error"),
child: (fields) => createLogger(sink, { ...base, ...fields }),
};
}
+25
View File
@@ -0,0 +1,25 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { type Kysely, sql } from "kysely";
import { FileMigrationProvider, type MigrationResultSet, Migrator } from "kysely/migration";
/** 本包自带迁移(tasks_run_meta / tasks_fact / tasks_op_ledger),W2 合并进 packages/db */
export const TASKS_MIGRATIONS_DIR = fileURLToPath(new URL("../migrations", import.meta.url));
/** 迁移到最新;表建在连接的默认 schema(search_path)里,迁移记录表独立命名避免与 @umt/db 冲突 */
// biome-ignore lint/suspicious/noExplicitAny: 迁移器不关心具体表类型
export async function migrateTasksTables(db: Kysely<any>): Promise<MigrationResultSet> {
// 显式指定迁移记录表的 schema:Kysely 默认按表名跨 schema 判断存在性,多个 schema 并存时会误判
const { rows } = await sql<{ schema: string }>`SELECT current_schema() AS schema`.execute(db);
const migrator = new Migrator({
db,
provider: new FileMigrationProvider({ fs, path, migrationFolder: TASKS_MIGRATIONS_DIR }),
migrationTableName: "tasks_migration",
migrationLockTableName: "tasks_migration_lock",
migrationTableSchema: rows[0]!.schema,
});
const result = await migrator.migrateToLatest();
if (result.error) throw result.error;
return result;
}
+116
View File
@@ -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;
}
+321
View File
@@ -0,0 +1,321 @@
import { randomUUID } from "node:crypto";
import { type Kysely, sql } from "kysely";
import { defineWorkflow, OpenWorkflow, Worker, type Workflow } from "openworkflow";
import { BackendPostgres } from "openworkflow/postgres";
import { TaskCanceledError } from "./errors";
import { createLogger, type LogSink } from "./logger";
import { engineVersion, parseEngineVersion, type TaskDefinition, type TaskRegistry } from "./registry";
import type {
EnqueueOptions,
JsonValue,
ListFilter,
StartWorkerOptions,
TaskContext,
TaskEngine,
TaskRunStatus,
TaskRunView,
TaskStepApi,
WaitForFactOptions,
} from "./types";
// biome-ignore lint/suspicious/noExplicitAny: 本包只用原生 SQL 访问自有表
type AnyDb = Kysely<any>;
type OwWorkflowRun = NonNullable<Awaited<ReturnType<BackendPostgres["getWorkflowRun"]>>>;
type OwStep = Parameters<Parameters<typeof defineWorkflow>[1]>[0]["step"];
export const ENGINE_SCHEMA = "openworkflow";
const DEFAULT_STEP_RETRY = { maximumAttempts: 3, initialInterval: "1s", backoffCoefficient: 2, maximumInterval: "60s" };
/** 写入引擎的 run 输入:业务输入 + 追踪元数据 */
interface Envelope {
input: JsonValue;
traceId: string;
priority: number;
}
export interface OpenWorkflowEngineOptions {
/** 引擎自己的 postgres.js 连接池使用(与 Kysely 同库) */
databaseUrl: string;
/** 业务库(Kysely/pg);tasks_* 表所在 */
db: AnyDb;
registry: TaskRegistry;
/** 固定 "openworkflow";只允许测试覆盖 */
schema?: string;
/** 默认 true(幂等)。W2 并入统一迁移后启动时传 false */
runMigrations?: boolean;
logSink?: LogSink;
/** handler 执行期间轮询取消标志的间隔,默认 1000ms */
cancelPollMs?: number;
}
export const signalName = (runId: string, name: string) => `umt:${runId}:${name}`;
export class OpenWorkflowEngine implements TaskEngine {
private readonly ow: OpenWorkflow;
private readonly workers: Worker[] = [];
private readonly log;
private constructor(
private readonly backend: BackendPostgres,
private readonly opts: OpenWorkflowEngineOptions,
) {
this.ow = new OpenWorkflow({ backend });
this.log = createLogger(opts.logSink, { component: "tasks.engine" });
}
static async connect(opts: OpenWorkflowEngineOptions): Promise<OpenWorkflowEngine> {
const backend = await BackendPostgres.connect(opts.databaseUrl, {
schema: opts.schema ?? ENGINE_SCHEMA,
runMigrations: opts.runMigrations ?? true,
});
return new OpenWorkflowEngine(backend, opts);
}
async enqueue(taskName: string, input: unknown, opts: EnqueueOptions) {
const def = this.opts.registry.latest(taskName);
const parsed = def.inputSchema.parse(input) as JsonValue;
const envelope: Envelope = { input: parsed, traceId: opts.traceId, priority: opts.priority ?? 0 };
const handle = await this.ow.runWorkflow(
{ name: def.name, version: engineVersion(def.version) },
envelope as unknown as JsonValue,
{ idempotencyKey: opts.idempotencyKey, ...(opts.availableAt ? { availableAt: opts.availableAt } : {}) },
);
const run = handle.workflowRun;
const inserted = await sql`
INSERT INTO tasks_run_meta (run_id, task_name, task_version, idempotency_key, trace_id, priority)
VALUES (${run.id}, ${def.name}, ${def.version}, ${opts.idempotencyKey}, ${opts.traceId}, ${envelope.priority})
ON CONFLICT (run_id) DO NOTHING`.execute(this.opts.db);
// 同一幂等键返回已有 run 时 meta 行已存在
return { runId: run.id, deduplicated: Number(inserted.numAffectedRows ?? 0n) === 0 };
}
async signal(runId: string, name: string, payload: JsonValue = null) {
await sql`INSERT INTO tasks_fact (run_id, name, payload) VALUES (${runId}, ${name}, ${JSON.stringify(payload)}::jsonb)`.execute(
this.opts.db,
);
const r = await this.ow.sendSignal({ signal: signalName(runId, name), data: payload });
return { delivered: r.workflowRunIds.length };
}
async cancel(runId: string, reason?: string) {
// 先写业务侧标志:运行中的 handler 通过它触发 abortSignal,步骤间检查也读它
await sql`
INSERT INTO tasks_run_meta (run_id, task_name, task_version, idempotency_key, cancel_requested_at, cancel_reason)
VALUES (${runId}, '', 0, '', now(), ${reason ?? null})
ON CONFLICT (run_id) DO UPDATE SET cancel_requested_at = COALESCE(tasks_run_meta.cancel_requested_at, now()),
cancel_reason = COALESCE(tasks_run_meta.cancel_reason, EXCLUDED.cancel_reason)`.execute(this.opts.db);
try {
await this.ow.cancelWorkflowRun(runId);
return true;
} catch (err) {
const run = await this.backend.getWorkflowRun({ workflowRunId: runId });
if (run?.status === "canceled") return true;
if (run) return false;
throw err;
}
}
async get(runId: string): Promise<TaskRunView | null> {
const run = await this.backend.getWorkflowRun({ workflowRunId: runId });
if (!run) return null;
const metas = await this.loadMeta([run.id]);
return toView(run, metas.get(run.id));
}
async list(filter: ListFilter = {}): Promise<TaskRunView[]> {
const res = await this.backend.listWorkflowRuns({
limit: filter.limit ?? 50,
...(filter.taskName ? { workflowName: filter.taskName } : {}),
...(filter.status ? { status: filter.status } : {}),
});
const metas = await this.loadMeta(res.data.map((r) => r.id));
return res.data.map((r) => toView(r, metas.get(r.id)));
}
async startWorker(opts: StartWorkerOptions = {}) {
const defs = this.opts.registry.select(opts.versions);
const workflows = defs.map((d) => this.toWorkflow(d)) as Workflow<unknown, unknown, unknown>[];
const worker = new Worker({ backend: this.backend, workflows, concurrency: opts.concurrency ?? 4 });
await worker.start();
this.workers.push(worker);
this.log.info("worker started", { tasks: defs.map((d) => `${d.name}@${d.version}`) });
}
/** 等进行中的 handler 执行完再断开连接池 */
async stop() {
await Promise.all(this.workers.splice(0).map((w) => w.stop()));
await this.backend.stop();
}
private async loadMeta(runIds: string[]) {
const out = new Map<string, MetaRow>();
if (runIds.length === 0) return out;
const r = await sql<MetaRow>`SELECT * FROM tasks_run_meta WHERE run_id = ANY(${runIds}::text[])`.execute(
this.opts.db,
);
for (const row of r.rows) out.set(row.run_id, row);
return out;
}
private async cancelState(runId: string): Promise<{ at: Date | null; reason: string | null }> {
const r = await sql<{ cancel_requested_at: Date | null; cancel_reason: string | null }>`
SELECT cancel_requested_at, cancel_reason FROM tasks_run_meta WHERE run_id = ${runId}`.execute(this.opts.db);
return { at: r.rows[0]?.cancel_requested_at ?? null, reason: r.rows[0]?.cancel_reason ?? null };
}
private toWorkflow(def: TaskDefinition) {
const { db } = this.opts;
const pollMs = this.opts.cancelPollMs ?? 1000;
const stepRetry = { ...DEFAULT_STEP_RETRY, ...def.retryPolicy };
return defineWorkflow(
{ name: def.name, version: engineVersion(def.version), retryPolicy: { maximumAttempts: 1 } },
async ({ input, step, run }) => {
const env = input as unknown as Envelope;
const parsed = def.inputSchema.parse(env.input);
const attemptToken = randomUUID();
const logger = createLogger(this.opts.logSink, {
task: def.name,
version: def.version,
runId: run.id,
traceId: env.traceId,
});
const abort = new AbortController();
let cancelReason: string | null = null;
const poll = async () => {
const s = await this.cancelState(run.id);
if (s.at && !abort.signal.aborted) {
cancelReason = s.reason;
abort.abort(new TaskCanceledError(run.id, s.reason));
}
return abort.signal.aborted;
};
const timer = setInterval(() => {
poll().catch((err) => logger.warn("cancel poll failed", { err }));
}, pollMs);
const throwIfCanceled = async () => {
if (await poll()) throw new TaskCanceledError(run.id, cancelReason);
};
const stepApi = makeStepApi(step as OwStep, run.id, db, stepRetry, throwIfCanceled);
const ctx: TaskContext<unknown> = {
input: parsed,
runId: run.id,
taskName: def.name,
version: def.version,
traceId: env.traceId,
priority: env.priority,
attemptToken,
abortSignal: abort.signal,
logger,
step: stepApi,
throwIfCanceled,
};
try {
await throwIfCanceled();
const out = await def.handler(ctx);
return (out ?? null) as JsonValue;
} finally {
clearInterval(timer);
}
},
);
}
}
function makeStepApi(
step: OwStep,
runId: string,
db: AnyDb,
stepRetry: typeof DEFAULT_STEP_RETRY,
throwIfCanceled: () => Promise<void>,
): TaskStepApi {
const api: TaskStepApi = {
async run(name, fn, opts) {
await throwIfCanceled();
// biome-ignore lint/suspicious/noExplicitAny: 引擎的 DurationString 是模板字面量类型
const retryPolicy = { ...stepRetry, ...opts?.retryPolicy } as any;
return (await step.run({ name, retryPolicy }, async () => (await fn()) as never)) as never;
},
async sleep(name, duration) {
await throwIfCanceled();
// biome-ignore lint/suspicious/noExplicitAny: 同上
await step.sleep(name, duration as any);
},
async waitForFact<T>(name: string, opts: WaitForFactOptions<T> = {}) {
const check =
opts.check ??
(async () => {
const r = await sql<{ payload: T }>`
SELECT payload FROM tasks_fact WHERE run_id = ${runId} AND name = ${name} ORDER BY id DESC LIMIT 1`.execute(
db,
);
return r.rows[0] ? r.rows[0].payload : undefined;
});
const maxRounds = opts.maxRounds ?? 100;
for (let i = 0; ; i++) {
const got = await api.run(`fact:${name}:check:${i}`, async () => {
const v = await check();
return v === undefined || v === null ? { found: false } : { found: true, value: v as unknown };
});
if (got.found) return (got as { value: T }).value;
if (i >= maxRounds) return null;
await throwIfCanceled();
await step.waitForSignal({
name: `fact:${name}:wait:${i}`,
signal: signalName(runId, name),
timeout: opts.timeout ?? "5m",
});
}
},
};
return api;
}
interface MetaRow {
run_id: string;
task_name: string;
task_version: number;
idempotency_key: string;
trace_id: string | null;
priority: number;
cancel_requested_at: Date | null;
cancel_reason: string | null;
}
function toStatus(run: OwWorkflowRun): TaskRunStatus {
switch (run.status) {
case "succeeded":
case "completed":
return "completed";
case "sleeping":
return "waiting";
case "running":
// 等信号 / sleep 期间 status 仍是 running,但没有 worker 持有
return run.workerId ? "running" : "waiting";
default:
return run.status;
}
}
function toView(run: OwWorkflowRun, meta: MetaRow | undefined): TaskRunView {
const env = (run.input ?? {}) as Partial<Envelope>;
return {
runId: run.id,
taskName: run.workflowName,
version: parseEngineVersion(run.version),
status: toStatus(run),
attempts: run.attempts,
idempotencyKey: run.idempotencyKey,
traceId: meta?.trace_id ?? env.traceId ?? null,
priority: meta?.priority ?? env.priority ?? 0,
input: env.input ?? null,
output: run.output,
error: run.error ? { name: run.error.name, message: run.error.message } : null,
cancelRequestedAt: meta?.cancel_requested_at ?? null,
cancelReason: meta?.cancel_reason ?? null,
availableAt: run.availableAt,
createdAt: run.createdAt,
startedAt: run.startedAt,
finishedAt: run.finishedAt,
};
}
+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;
}
}
+77
View File
@@ -0,0 +1,77 @@
import type { z } from "zod";
import { UnknownTaskError } from "./errors";
import type { RetryPolicy, TaskContext } from "./types";
export interface TaskDefinition<S extends z.ZodType = z.ZodType, O = unknown> {
name: string;
/** 正整数;不兼容改动 +1,并在 worker 上同时注册新旧版本直到旧 run 清空 */
version: number;
inputSchema: S;
/** 步骤默认重试策略(步骤可单独覆盖)。外部网络类建议 5 次 1s→60s,文件类 3 次 */
retryPolicy?: Partial<RetryPolicy>;
handler: (ctx: TaskContext<z.output<S>>) => Promise<O>;
}
export function defineTask<S extends z.ZodType, O>(def: TaskDefinition<S, O>): TaskDefinition<S, O> {
if (!/^[A-Za-z][A-Za-z0-9_.-]*$/.test(def.name)) throw new Error(`invalid task name: ${def.name}`);
if (!Number.isInteger(def.version) || def.version < 1) throw new Error(`invalid task version: ${def.version}`);
return def;
}
export const engineVersion = (version: number) => `v${version}`;
export const parseEngineVersion = (v: string | null): number | null => {
const m = v ? /^v(\d+)$/.exec(v) : null;
return m ? Number(m[1]) : null;
};
export class TaskRegistry {
private readonly defs = new Map<string, Map<number, TaskDefinition>>();
// biome-ignore lint/suspicious/noExplicitAny: 注册表按名存放异构的任务定义
register(...defs: TaskDefinition<any, any>[]): this {
for (const def of defs) {
let byVersion = this.defs.get(def.name);
if (!byVersion) {
byVersion = new Map();
this.defs.set(def.name, byVersion);
}
if (byVersion.has(def.version)) throw new Error(`task ${def.name}@${def.version} already registered`);
byVersion.set(def.version, def as TaskDefinition);
}
return this;
}
get(name: string, version: number): TaskDefinition {
const def = this.defs.get(name)?.get(version);
if (!def) throw new UnknownTaskError(name, version);
return def;
}
/** 新 run 一律用最高版本 */
latest(name: string): TaskDefinition {
const byVersion = this.defs.get(name);
if (!byVersion || byVersion.size === 0) throw new UnknownTaskError(name);
return byVersion.get(Math.max(...byVersion.keys()))!;
}
names(): string[] {
return [...this.defs.keys()].sort();
}
all(): TaskDefinition[] {
return [...this.defs.values()].flatMap((m) => [...m.values()]);
}
/** 按 StartWorkerOptions.versions 选择:"name@2" / "@1" / "name"(该任务全部版本) */
select(selectors?: string[]): TaskDefinition[] {
if (!selectors || selectors.length === 0) return this.all();
const picked = this.all().filter((d) =>
selectors.some((s) => {
const [name, ver] = s.split("@");
return (!name || name === d.name) && (ver === undefined || ver === "" || Number(ver) === d.version);
}),
);
if (picked.length === 0) throw new Error(`no task definitions match ${selectors.join(",")}`);
return picked;
}
}
+113
View File
@@ -0,0 +1,113 @@
import type { TaskLogger } from "./logger";
export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
/** 与 OpenWorkflow 的 DurationString 一致:"500ms" / "5s" / "10m" / "2 hours" */
export type Duration = `${number}` | `${number}${string}` | `${number} ${string}`;
export interface RetryPolicy {
maximumAttempts: number;
initialInterval: Duration;
backoffCoefficient: number;
maximumInterval: Duration;
}
export type TaskRunStatus = "pending" | "running" | "waiting" | "completed" | "failed" | "canceled";
/** 只读投影,供 task.inspect / 管理页用;业务时间线仍读业务表 */
export interface TaskRunView {
runId: string;
taskName: string;
version: number | null;
status: TaskRunStatus;
attempts: number;
idempotencyKey: string | null;
traceId: string | null;
priority: number;
input: unknown;
output: unknown;
error: { name?: string; message: string } | null;
cancelRequestedAt: Date | null;
cancelReason: string | null;
availableAt: Date | null;
createdAt: Date;
startedAt: Date | null;
finishedAt: Date | null;
}
export interface EnqueueOptions {
/** 由服务端按语义生成(如 outbox 行 id);引擎只保证 24h 窗口内去重,长期去重靠业务唯一约束 */
idempotencyKey: string;
traceId: string;
/** 记录在元数据里;OpenWorkflow 0.10.1 按 available_at 先后认领,暂不支持真正的优先级调度 */
priority?: number;
availableAt?: Date;
}
export interface ListFilter {
taskName?: string;
status?: Exclude<TaskRunStatus, "waiting">;
limit?: number;
}
export interface StartWorkerOptions {
/**
* 本 worker 执行哪些版本:"ingestFeed@2" 精确指定,"@1" 表示所有任务的 v1,
* 省略则执行注册表里的全部版本。升级期间新旧版本要同时列出。
*/
versions?: string[];
concurrency?: number;
}
export interface TaskEngine {
enqueue(taskName: string, input: unknown, opts: EnqueueOptions): Promise<{ runId: string; deduplicated: boolean }>;
/**
* 先把事实写入 tasks_fact,再发引擎信号唤醒等待方。信号丢了不影响正确性:
* waitForFact 每轮都先查库。业务事实(审批、下载完成)应先写业务表,再调用这里。
*/
signal(runId: string, name: string, payload?: JsonValue): Promise<{ delivered: number }>;
/** 写取消标志后取消引擎 run。不撤销已完成的外部动作;返回 false 表示 run 已结束无法取消 */
cancel(runId: string, reason?: string): Promise<boolean>;
get(runId: string): Promise<TaskRunView | null>;
list(filter?: ListFilter): Promise<TaskRunView[]>;
startWorker(opts?: StartWorkerOptions): Promise<void>;
stop(): Promise<void>;
}
export interface StepRunOptions {
retryPolicy?: Partial<RetryPolicy>;
}
export interface WaitForFactOptions<T> {
/** 业务库检查;省略时读 tasks_fact(engine.signal 写入的事实)。返回 null/undefined 表示尚未满足 */
check?: () => Promise<T | null | undefined>;
/** 每轮等待信号的超时,超时后重新查库;默认 "5m" */
timeout?: Duration;
/** 最多等待几轮,用尽返回 null;默认 100(单个 run 上限 1000 个 step attempt) */
maxRounds?: number;
}
export interface TaskStepApi {
/** 结果持久化;重放时直接返回。步骤体必须幂等(可能在新 worker 上重跑) */
run<T>(name: string, fn: () => Promise<T> | T, opts?: StepRunOptions): Promise<T>;
sleep(name: string, duration: Duration): Promise<void>;
/** 先查库、再带超时等信号的循环。事实先于等待到达也不会丢 */
waitForFact<T = JsonValue>(name: string, opts?: WaitForFactOptions<T>): Promise<T | null>;
}
export interface TaskContext<I> {
input: I;
runId: string;
taskName: string;
version: number;
traceId: string;
priority: number;
/** 每次 handler 执行(含重放)新生成;配合 withAttemptToken 做 fencing */
attemptToken: string;
/** cancel 写 PG 标志后触发;长步骤自行轮询并中止 */
abortSignal: AbortSignal;
logger: TaskLogger;
step: TaskStepApi;
/** 已请求取消时抛出 TaskCanceledError */
throwIfCanceled(): Promise<void>;
}