@iworkflow/dbos 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/index.ts +233 -21
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@iworkflow/dbos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "DBOS adapter for @iworkflow/core",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"workflow",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"test": "bun test"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@iworkflow/core": "^0.
|
|
32
|
+
"@iworkflow/core": "^0.3.0",
|
|
33
33
|
"pg": "^8.0.0"
|
|
34
34
|
},
|
|
35
35
|
"peerDependencies": {
|
package/src/index.ts
CHANGED
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
// DBOS: общая системная база Postgres, у модуля своё приложение (name) и своя очередь.
|
|
2
2
|
// Дочерний процесс в чужом модуле — enqueueWorkflowWithOptions по имени, без ссылки на функцию.
|
|
3
|
-
import { DBOS, DBOSClient } from "@dbos-inc/dbos-sdk";
|
|
3
|
+
import { DBOS, DBOSClient, Error as DBOSErrors, type WorkflowStatus } from "@dbos-inc/dbos-sdk";
|
|
4
4
|
import {
|
|
5
5
|
bindStepRuntime,
|
|
6
|
+
Failure,
|
|
7
|
+
FlowError,
|
|
8
|
+
isFlowError,
|
|
9
|
+
type Activity,
|
|
6
10
|
type AppDefinition,
|
|
11
|
+
type ChildOptions,
|
|
7
12
|
type EngineClient,
|
|
8
13
|
type EngineRuntime,
|
|
9
14
|
type FlowContext,
|
|
10
15
|
type FlowHandle,
|
|
16
|
+
type FlowStatus,
|
|
17
|
+
type StepOptions,
|
|
11
18
|
} from "@iworkflow/core";
|
|
12
19
|
import { Pool } from "pg";
|
|
13
20
|
|
|
@@ -87,6 +94,132 @@ function stepKey(): [string, number] {
|
|
|
87
94
|
return [wf, step];
|
|
88
95
|
}
|
|
89
96
|
|
|
97
|
+
// --- Отказы ---------------------------------------------------------------------------------------
|
|
98
|
+
// Ошибку шага и процесса DBOS сериализует: при переигрывании класс теряется, остаются name и поля.
|
|
99
|
+
|
|
100
|
+
const typeOf = (e: unknown) => (e instanceof Failure ? e.type : e instanceof Error ? e.name : "Error");
|
|
101
|
+
|
|
102
|
+
/** FlowError после сериализации — снова FlowError: модуль проверяет instanceof. */
|
|
103
|
+
function revive(e: unknown): unknown {
|
|
104
|
+
if (e instanceof FlowError || !isFlowError(e)) return e;
|
|
105
|
+
return new FlowError(e.message, { reason: e.reason, type: e.type, details: e.details, exhausted: e.exhausted });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const CANCELLED = new DBOSErrors.DBOSAwaitedWorkflowCancelledError("").dbosErrorCode;
|
|
109
|
+
const isCancelled = (e: unknown) => e instanceof Error && DBOSErrors.getDBOSErrorCode(e) === CANCELLED;
|
|
110
|
+
|
|
111
|
+
/** Прерван процесс сроком или снаружи: по статусу, неизменному после конца, — ответ одинаков при переигрывании. */
|
|
112
|
+
const cancelReason = (st: WorkflowStatus | undefined): "timeout" | "terminated" =>
|
|
113
|
+
st?.deadlineEpochMS !== undefined && (st.updatedAt ?? 0) >= st.deadlineEpochMS ? "timeout" : "terminated";
|
|
114
|
+
|
|
115
|
+
/** Отказ процесса глазами ожидающего: родителя или клиента. Как у Temporal — "failed" с типом отказа. */
|
|
116
|
+
async function flowFailure(e: unknown, status: () => Promise<WorkflowStatus | undefined>): Promise<unknown> {
|
|
117
|
+
if (isCancelled(e)) {
|
|
118
|
+
const reason = cancelReason(await status());
|
|
119
|
+
return new FlowError(reason === "timeout" ? "срок процесса вышел" : "процесс прерван", { reason });
|
|
120
|
+
}
|
|
121
|
+
if (isFlowError(e))
|
|
122
|
+
return new FlowError(e.message, { reason: "failed", type: e.type ?? e.reason, details: e.details });
|
|
123
|
+
if (e instanceof Error)
|
|
124
|
+
return new FlowError(e.message, { reason: "failed", type: (e as Failure).type ?? e.name, details: (e as Failure).details });
|
|
125
|
+
return e;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const alreadyStarted = (id: string) => (e: unknown): never => {
|
|
129
|
+
if (DBOSErrors.isWorkflowIDInUseError(e)) throw new FlowError(`процесс ${id} уже есть`, { reason: "already-started" });
|
|
130
|
+
throw e;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
// --- Шаг ------------------------------------------------------------------------------------------
|
|
134
|
+
// Повторы — свои, внутри одного шага DBOS: в историю пишется уже FlowError с причиной, как у Temporal.
|
|
135
|
+
// Цена: отмена процесса не прерывает повторы шага, а ждёт его конца.
|
|
136
|
+
|
|
137
|
+
class Semaphore {
|
|
138
|
+
private queue: (() => void)[] = [];
|
|
139
|
+
constructor(private free: number) {}
|
|
140
|
+
async run<T>(fn: () => Promise<T>): Promise<T> {
|
|
141
|
+
if (this.free > 0) this.free--;
|
|
142
|
+
else await new Promise<void>((r) => this.queue.push(r));
|
|
143
|
+
try {
|
|
144
|
+
return await fn();
|
|
145
|
+
} finally {
|
|
146
|
+
const next = this.queue.shift();
|
|
147
|
+
if (next) next();
|
|
148
|
+
else this.free++;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
class AttemptTimeout extends Error {}
|
|
154
|
+
|
|
155
|
+
const sleepMs = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
156
|
+
|
|
157
|
+
/** Попытка, урезанная по сроку: брошенная продолжает идти в фоне, её итог отбрасывается. */
|
|
158
|
+
async function withTimeout<T>(p: Promise<T>, ms: number): Promise<T> {
|
|
159
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
160
|
+
try {
|
|
161
|
+
return await Promise.race([
|
|
162
|
+
p,
|
|
163
|
+
new Promise<never>((_, reject) => (timer = setTimeout(() => reject(new AttemptTimeout(`попытка дольше ${ms} мс`)), ms))),
|
|
164
|
+
]);
|
|
165
|
+
} finally {
|
|
166
|
+
clearTimeout(timer);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Повторы внутри одного runStep: процесс прерван (срок, отмена) — новых попыток нет. */
|
|
171
|
+
async function attempts(
|
|
172
|
+
fn: Activity,
|
|
173
|
+
args: unknown[],
|
|
174
|
+
opts: StepOptions,
|
|
175
|
+
pool: Semaphore | undefined,
|
|
176
|
+
alive: () => Promise<boolean>,
|
|
177
|
+
) {
|
|
178
|
+
const max = opts.attempts ?? 3;
|
|
179
|
+
const started = Date.now();
|
|
180
|
+
let interval = opts.retryIntervalMs ?? 200;
|
|
181
|
+
for (let n = 1; ; n++) {
|
|
182
|
+
try {
|
|
183
|
+
const attempt = () => withTimeout(fn(...args), opts.timeoutMs ?? 60_000);
|
|
184
|
+
return await (pool ? pool.run(attempt) : attempt());
|
|
185
|
+
} catch (e) {
|
|
186
|
+
const stop = (e instanceof Failure && e.nonRetryable) || (opts.nonRetryableTypes ?? []).includes(typeOf(e));
|
|
187
|
+
if (stop || n >= max) {
|
|
188
|
+
const exhausted = !stop;
|
|
189
|
+
if (e instanceof AttemptTimeout) throw new FlowError(e.message, { reason: "timeout", exhausted });
|
|
190
|
+
throw new FlowError(e instanceof Error ? e.message : String(e), {
|
|
191
|
+
reason: "failed",
|
|
192
|
+
type: typeOf(e),
|
|
193
|
+
details: e instanceof Failure ? e.details : undefined,
|
|
194
|
+
exhausted,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
if (opts.totalTimeoutMs !== undefined && Date.now() - started + interval >= opts.totalTimeoutMs)
|
|
198
|
+
throw new FlowError(e instanceof Error ? e.message : String(e), {
|
|
199
|
+
reason: "timeout",
|
|
200
|
+
type: typeOf(e),
|
|
201
|
+
details: e instanceof Failure ? e.details : undefined,
|
|
202
|
+
});
|
|
203
|
+
await sleepMs(interval);
|
|
204
|
+
// После паузы: за неё процесс мог выйти по сроку или быть отменён.
|
|
205
|
+
// Сбой чтения статуса — не повод бросать шаг: повторяем дальше.
|
|
206
|
+
if (!(await alive().catch(() => true))) throw new FlowError("процесс прерван — повторы шага остановлены", { reason: "terminated" });
|
|
207
|
+
interval = Math.min(interval * (opts.backoff ?? 1), opts.maxRetryIntervalMs ?? Infinity);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const FINISHED = new Set(["SUCCESS", "ERROR", "CANCELLED", "MAX_RECOVERY_ATTEMPTS_EXCEEDED"]);
|
|
213
|
+
|
|
214
|
+
const STATUS: Record<string, FlowStatus> = {
|
|
215
|
+
SUCCESS: "completed",
|
|
216
|
+
ERROR: "failed",
|
|
217
|
+
MAX_RECOVERY_ATTEMPTS_EXCEEDED: "failed",
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const describeStatus = (st: WorkflowStatus): FlowStatus =>
|
|
221
|
+
st.status === "CANCELLED" ? (cancelReason(st) === "timeout" ? "timed-out" : "terminated") : (STATUS[st.status] ?? "running");
|
|
222
|
+
|
|
90
223
|
export function dbosRuntime(): EngineRuntime {
|
|
91
224
|
let pool: Pool | undefined;
|
|
92
225
|
return {
|
|
@@ -107,34 +240,90 @@ export function dbosRuntime(): EngineRuntime {
|
|
|
107
240
|
return r.rows[0]?.details ?? undefined;
|
|
108
241
|
},
|
|
109
242
|
});
|
|
243
|
+
const pools = Object.fromEntries(Object.entries(def.pools ?? {}).map(([p, n]) => [p, new Semaphore(n)]));
|
|
244
|
+
const enqueue = (target: { app: string; flow: string }, args: unknown[], opts: ChildOptions, reject: boolean) =>
|
|
245
|
+
DBOS.enqueueWorkflowWithOptions(
|
|
246
|
+
{
|
|
247
|
+
queueName: target.app,
|
|
248
|
+
workflowName: target.flow,
|
|
249
|
+
workflowID: opts.id,
|
|
250
|
+
applicationName: target.app,
|
|
251
|
+
...(opts.timeoutMs ? { workflowTimeoutMS: opts.timeoutMs } : {}),
|
|
252
|
+
...(reject ? { workflowIDReusePolicy: "reject" as const } : {}),
|
|
253
|
+
},
|
|
254
|
+
...args,
|
|
255
|
+
).catch(alreadyStarted(opts.id));
|
|
256
|
+
const waitChild = async <T>(h: { getResult(o?: object): Promise<unknown> }, id: string): Promise<T> => {
|
|
257
|
+
try {
|
|
258
|
+
return (await h.getResult(resultPoll())) as T;
|
|
259
|
+
} catch (e) {
|
|
260
|
+
throw await flowFailure(e, () => DBOS.getWorkflowStatus(id).then((st) => st ?? undefined));
|
|
261
|
+
}
|
|
262
|
+
};
|
|
110
263
|
for (const [name, flow] of Object.entries(def.flows)) {
|
|
111
264
|
const run = async (...args: unknown[]) => {
|
|
112
|
-
|
|
265
|
+
const workflowId = DBOS.workflowID!;
|
|
266
|
+
await patches.born(workflowId);
|
|
267
|
+
// Строка статуса не меняется после создания: чтение вне шага детерминировано.
|
|
268
|
+
const row = (
|
|
269
|
+
await db.query("SELECT created_at, parent_workflow_id FROM dbos.workflow_status WHERE workflow_uuid = $1", [
|
|
270
|
+
workflowId,
|
|
271
|
+
])
|
|
272
|
+
).rows[0];
|
|
113
273
|
const ctx: FlowContext = {
|
|
114
|
-
workflowId
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
274
|
+
workflowId,
|
|
275
|
+
parentId: row?.parent_workflow_id ?? undefined,
|
|
276
|
+
startedAt: new Date(Number(row?.created_at)),
|
|
277
|
+
step: (step, stepArgs, opts = {}) => {
|
|
278
|
+
const pool = opts.pool ? pools[opts.pool] : undefined;
|
|
279
|
+
if (opts.pool && !pool) throw new Error(`пул "${opts.pool}" не объявлен в AppDefinition.pools модуля ${def.app}`);
|
|
280
|
+
// heartbeatTimeoutMs у DBOS не действует: умерший процесс ловится рестартом, шаг повторяется.
|
|
281
|
+
// Срок вышел, а DBOS ещё не отменил процесс — ждём отмены: иначе процесс поймает отказ и завершится успехом.
|
|
282
|
+
const alive = async () => {
|
|
283
|
+
for (;;) {
|
|
284
|
+
const st = (
|
|
285
|
+
await db.query(
|
|
286
|
+
"SELECT status, workflow_deadline_epoch_ms AS deadline FROM dbos.workflow_status WHERE workflow_uuid = $1",
|
|
287
|
+
[workflowId],
|
|
288
|
+
)
|
|
289
|
+
).rows[0];
|
|
290
|
+
if (!st || !["PENDING", "ENQUEUED"].includes(st.status)) return false;
|
|
291
|
+
if (st.deadline === null || Number(st.deadline) > Date.now()) return true;
|
|
292
|
+
// Отмену ставит DBOS; не поставил за минуту — не ждать вечно.
|
|
293
|
+
if (Date.now() - Number(st.deadline) > 60_000) return false;
|
|
294
|
+
await sleepMs(100);
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
return DBOS.runStep(() => attempts(def.activities[step]!, stepArgs, opts, pool, alive), {
|
|
119
298
|
name: step,
|
|
120
|
-
retriesAllowed:
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
backoffRate: 1,
|
|
299
|
+
retriesAllowed: false,
|
|
300
|
+
}).catch((e) => {
|
|
301
|
+
throw revive(e);
|
|
124
302
|
}) as Promise<never>;
|
|
125
303
|
},
|
|
126
|
-
child: async (target, childArgs, opts) =>
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
);
|
|
131
|
-
return (
|
|
304
|
+
child: async (target, childArgs, opts) =>
|
|
305
|
+
waitChild(await enqueue(target, childArgs, opts, opts.onExisting === "reject"), opts.id),
|
|
306
|
+
async startChild(target, childArgs, opts) {
|
|
307
|
+
// Процесс DBOS не привязан к родителю: живёт сам, как ABANDON у Temporal.
|
|
308
|
+
const h = await enqueue(target, childArgs, opts, true);
|
|
309
|
+
return { id: opts.id, result: () => waitChild(h, opts.id) };
|
|
132
310
|
},
|
|
133
311
|
sleep: (ms) => DBOS.sleep(ms),
|
|
134
312
|
recv: (topic, timeoutMs) => DBOS.recv(topic, { timeoutSeconds: timeoutMs / 1000, ...resultPoll() }),
|
|
135
|
-
signal
|
|
313
|
+
async signal(target, topic, value) {
|
|
314
|
+
// Отправка завершённому процессу у DBOS проходит молча; Temporal отказывает — проверяем сами.
|
|
315
|
+
const status = await DBOS.runStep(
|
|
316
|
+
async () =>
|
|
317
|
+
((await db.query("SELECT status FROM dbos.workflow_status WHERE workflow_uuid = $1", [target])).rows[0]
|
|
318
|
+
?.status as string | undefined) ?? null,
|
|
319
|
+
{ name: "iwf.signalTarget" },
|
|
320
|
+
);
|
|
321
|
+
if (status === null || FINISHED.has(status))
|
|
322
|
+
throw new FlowError(`процесса ${target} нет или он завершён`, { reason: "not-found" });
|
|
323
|
+
await DBOS.send(target, value, topic);
|
|
324
|
+
},
|
|
136
325
|
setState: (key, value) => DBOS.setEvent(key, value),
|
|
137
|
-
patched: (id) => patches.patched(
|
|
326
|
+
patched: (id) => patches.patched(workflowId, id),
|
|
138
327
|
// Старых процессов нет — ветка всегда новая; вызов можно удалять.
|
|
139
328
|
deprecatePatch: async () => {},
|
|
140
329
|
};
|
|
@@ -165,17 +354,40 @@ export function dbosRuntime(): EngineRuntime {
|
|
|
165
354
|
|
|
166
355
|
export async function dbosClient(): Promise<EngineClient> {
|
|
167
356
|
const client = await DBOSClient.create({ systemDatabaseUrl: sysDb() });
|
|
357
|
+
const status = (id: string) => client.getWorkflow(id);
|
|
358
|
+
const exists = async (id: string) => {
|
|
359
|
+
const st = await status(id);
|
|
360
|
+
if (!st) throw new FlowError(`процесса ${id} нет`, { reason: "not-found" });
|
|
361
|
+
return st;
|
|
362
|
+
};
|
|
168
363
|
const wrap = <T>(id: string): FlowHandle<T> => ({
|
|
169
364
|
id,
|
|
170
|
-
result: () =>
|
|
365
|
+
result: () =>
|
|
366
|
+
(client.retrieveWorkflow<T>(id).getResult(resultPoll()) as Promise<T>).catch(async (e) => {
|
|
367
|
+
throw await flowFailure(e, () => status(id));
|
|
368
|
+
}),
|
|
171
369
|
signal: (topic, value) => client.send(id, value, topic),
|
|
172
370
|
// Нет ожидания: query читает текущее значение, как у Temporal.
|
|
173
371
|
query: async <V>(key: string) => ((await client.getEvent<V>(id, key, { timeoutSeconds: 0 })) ?? undefined),
|
|
372
|
+
async describe() {
|
|
373
|
+
const st = await exists(id);
|
|
374
|
+
return { id, status: describeStatus(st), startedAt: new Date(st.createdAt) };
|
|
375
|
+
},
|
|
376
|
+
async terminate() {
|
|
377
|
+
await exists(id);
|
|
378
|
+
await client.cancelWorkflow(id);
|
|
379
|
+
},
|
|
174
380
|
});
|
|
175
381
|
return {
|
|
176
382
|
async start(target, args, opts) {
|
|
177
383
|
const h = await client.enqueue(
|
|
178
|
-
{
|
|
384
|
+
{
|
|
385
|
+
queueName: target.app,
|
|
386
|
+
workflowName: target.flow,
|
|
387
|
+
workflowID: opts.id,
|
|
388
|
+
applicationName: target.app,
|
|
389
|
+
...(opts.timeoutMs ? { workflowTimeoutMS: opts.timeoutMs } : {}),
|
|
390
|
+
},
|
|
179
391
|
...args,
|
|
180
392
|
);
|
|
181
393
|
return wrap(h.workflowID);
|