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,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,
|
||||
);
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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 }),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
Reference in new issue
Block a user