@iworkflow/dbos 0.3.0 → 0.5.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.
@@ -0,0 +1,3 @@
1
+ import { type EngineClient, type EngineRuntime } from "@iworkflow/core";
2
+ export declare function dbosRuntime(): EngineRuntime;
3
+ export declare function dbosClient(): Promise<EngineClient>;
package/dist/index.js ADDED
@@ -0,0 +1,362 @@
1
+ // src/index.ts
2
+ import { DBOS, DBOSClient, Error as DBOSErrors } from "@dbos-inc/dbos-sdk";
3
+ import {
4
+ bindStepRuntime,
5
+ checkPriority,
6
+ DEFAULT_PRIORITY,
7
+ exitSignal,
8
+ Failure,
9
+ FlowError,
10
+ isFlowError
11
+ } from "@iworkflow/core";
12
+ import { AsyncLocalStorage } from "node:async_hooks";
13
+ import { Pool } from "pg";
14
+ var resultPoll = () => ({ pollingIntervalMs: Number(process.env.DBOS_RESULT_POLL_MS) || undefined });
15
+ var sysDb = () => process.env.DBOS_SYSTEM_DATABASE_URL ?? "postgres://iworkflow:iworkflow@localhost:55432/iworkflow";
16
+ var HEARTBEAT_DDL = [
17
+ "CREATE SCHEMA IF NOT EXISTS iwf",
18
+ `CREATE TABLE IF NOT EXISTS iwf.birth (
19
+ workflow_id text PRIMARY KEY, app text NOT NULL, patches text[] NOT NULL, at timestamptz NOT NULL DEFAULT now())`,
20
+ `CREATE TABLE IF NOT EXISTS iwf.heartbeat (
21
+ workflow_id text NOT NULL, step_id integer NOT NULL, details jsonb, at timestamptz NOT NULL DEFAULT now(),
22
+ PRIMARY KEY (workflow_id, step_id))`
23
+ ];
24
+ var LAUNCH_LOCK = "hashtext('iworkflow/dbos launch')";
25
+ async function underLaunchLock(pool, fn) {
26
+ const c = await pool.connect();
27
+ try {
28
+ while (!(await c.query(`SELECT pg_try_advisory_lock(${LAUNCH_LOCK}) AS ok`)).rows[0].ok)
29
+ await sleepMs(200);
30
+ for (const sql of HEARTBEAT_DDL)
31
+ await c.query(sql);
32
+ return await fn();
33
+ } finally {
34
+ await c.query(`SELECT pg_advisory_unlock(${LAUNCH_LOCK})`).catch(() => {});
35
+ c.release();
36
+ }
37
+ }
38
+ function patchBook(db, app, declared) {
39
+ const births = new Map;
40
+ return {
41
+ async patched(workflowId, id) {
42
+ if (!declared.includes(id))
43
+ throw new Error(`патч "${id}" не объявлен в AppDefinition.patches модуля ${app}`);
44
+ let set = births.get(workflowId);
45
+ if (!set) {
46
+ await db.query("INSERT INTO iwf.birth (workflow_id, app, patches) VALUES ($1, $2, $3) ON CONFLICT (workflow_id) DO NOTHING", [workflowId, app, declared]);
47
+ const r = await db.query("SELECT patches FROM iwf.birth WHERE workflow_id = $1", [workflowId]);
48
+ set = new Set(r.rows[0].patches);
49
+ births.set(workflowId, set);
50
+ }
51
+ return set.has(id);
52
+ },
53
+ async born(workflowId) {
54
+ if (births.has(workflowId))
55
+ return;
56
+ await db.query("INSERT INTO iwf.birth (workflow_id, app, patches) VALUES ($1, $2, $3) ON CONFLICT (workflow_id) DO NOTHING", [workflowId, app, declared]);
57
+ }
58
+ };
59
+ }
60
+ function stepKey() {
61
+ const wf = DBOS.workflowID;
62
+ const step = DBOS.stepID;
63
+ if (!wf || step === undefined)
64
+ throw new Error("heartbeat вне шага DBOS");
65
+ return [wf, step];
66
+ }
67
+ var typeOf = (e) => e instanceof Failure ? e.type : e instanceof Error ? e.name : "Error";
68
+ function revive(e) {
69
+ if (e instanceof FlowError || !isFlowError(e))
70
+ return e;
71
+ return new FlowError(e.message, { reason: e.reason, type: e.type, details: e.details, exhausted: e.exhausted });
72
+ }
73
+ var CANCELLED = new DBOSErrors.DBOSAwaitedWorkflowCancelledError("").dbosErrorCode;
74
+ var isCancelled = (e) => e instanceof Error && DBOSErrors.getDBOSErrorCode(e) === CANCELLED;
75
+ var cancelReason = (st) => st?.deadlineEpochMS !== undefined && (st.updatedAt ?? 0) >= st.deadlineEpochMS ? "timeout" : "terminated";
76
+ async function flowFailure(e, status) {
77
+ if (isCancelled(e)) {
78
+ const reason = cancelReason(await status());
79
+ return new FlowError(reason === "timeout" ? "срок процесса вышел" : "процесс прерван", { reason });
80
+ }
81
+ if (isFlowError(e))
82
+ return new FlowError(e.message, { reason: "failed", type: e.type ?? e.reason, details: e.details });
83
+ if (e instanceof Error)
84
+ return new FlowError(e.message, { reason: "failed", type: e.type ?? e.name, details: e.details });
85
+ return e;
86
+ }
87
+ var alreadyStarted = (id) => (e) => {
88
+ if (DBOSErrors.isWorkflowIDInUseError(e))
89
+ throw new FlowError(`процесс ${id} уже есть`, { reason: "already-started" });
90
+ throw e;
91
+ };
92
+
93
+ class Semaphore {
94
+ free;
95
+ queue = [];
96
+ constructor(free) {
97
+ this.free = free;
98
+ }
99
+ async run(fn, priority = DEFAULT_PRIORITY) {
100
+ if (this.free > 0)
101
+ this.free--;
102
+ else
103
+ await new Promise((go) => {
104
+ const i = this.queue.findIndex((w) => w.priority > priority);
105
+ this.queue.splice(i < 0 ? this.queue.length : i, 0, { priority, go });
106
+ });
107
+ try {
108
+ return await fn();
109
+ } finally {
110
+ const next = this.queue.shift();
111
+ if (next)
112
+ next.go();
113
+ else
114
+ this.free++;
115
+ }
116
+ }
117
+ }
118
+
119
+ class AttemptTimeout extends Error {
120
+ }
121
+ var sleepMs = (ms) => new Promise((r) => setTimeout(r, ms));
122
+ var attemptNo = new AsyncLocalStorage;
123
+ async function withTimeout(p, ms) {
124
+ let timer;
125
+ try {
126
+ return await Promise.race([
127
+ p,
128
+ new Promise((_, reject) => timer = setTimeout(() => reject(new AttemptTimeout(`попытка дольше ${ms} мс`)), ms))
129
+ ]);
130
+ } finally {
131
+ clearTimeout(timer);
132
+ }
133
+ }
134
+ async function attempts(fn, args, opts, pool, alive, priority) {
135
+ const max = opts.attempts ?? 3;
136
+ const started = Date.now();
137
+ let interval = opts.retryIntervalMs ?? 200;
138
+ for (let n = 1;; n++) {
139
+ try {
140
+ const attempt = () => attemptNo.run(n, () => withTimeout(fn(...args), opts.timeoutMs ?? 60000));
141
+ return await (pool ? pool.run(attempt, priority) : attempt());
142
+ } catch (e) {
143
+ const stop = e instanceof Failure && e.nonRetryable || (opts.nonRetryableTypes ?? []).includes(typeOf(e));
144
+ if (stop || n >= max) {
145
+ const exhausted = !stop;
146
+ if (e instanceof AttemptTimeout)
147
+ throw new FlowError(e.message, { reason: "timeout", exhausted });
148
+ throw new FlowError(e instanceof Error ? e.message : String(e), {
149
+ reason: "failed",
150
+ type: typeOf(e),
151
+ details: e instanceof Failure ? e.details : undefined,
152
+ exhausted
153
+ });
154
+ }
155
+ const wait = e instanceof Failure && e.retryAfterMs !== undefined ? e.retryAfterMs : interval;
156
+ if (opts.totalTimeoutMs !== undefined && Date.now() - started + wait >= opts.totalTimeoutMs)
157
+ throw new FlowError(e instanceof Error ? e.message : String(e), {
158
+ reason: "timeout",
159
+ type: typeOf(e),
160
+ details: e instanceof Failure ? e.details : undefined
161
+ });
162
+ let left = wait;
163
+ do {
164
+ await sleepMs(Math.min(Math.max(left, 0), 5000));
165
+ if (!await alive().catch(() => true))
166
+ throw new FlowError("процесс прерван — повторы шага остановлены", { reason: "terminated" });
167
+ left -= 5000;
168
+ } while (left > 0);
169
+ interval = Math.min(interval * (opts.backoff ?? 1), opts.maxRetryIntervalMs ?? Infinity);
170
+ }
171
+ }
172
+ }
173
+ var FINISHED = new Set(["SUCCESS", "ERROR", "CANCELLED", "MAX_RECOVERY_ATTEMPTS_EXCEEDED"]);
174
+ var STATUS = {
175
+ SUCCESS: "completed",
176
+ ERROR: "failed",
177
+ MAX_RECOVERY_ATTEMPTS_EXCEEDED: "failed"
178
+ };
179
+ var describeStatus = (st) => st.status === "CANCELLED" ? cancelReason(st) === "timeout" ? "timed-out" : "terminated" : STATUS[st.status] ?? "running";
180
+ function dbosRuntime() {
181
+ let pool;
182
+ const exit = exitSignal();
183
+ async function launch(def) {
184
+ pool = new Pool({ connectionString: sysDb(), max: 2 });
185
+ const db = pool;
186
+ const patches = patchBook(db, def.app, def.patches ?? []);
187
+ bindStepRuntime({
188
+ async heartbeat(details) {
189
+ await db.query(`INSERT INTO iwf.heartbeat (workflow_id, step_id, details) VALUES ($1, $2, $3)
190
+ ON CONFLICT (workflow_id, step_id) DO UPDATE SET details = EXCLUDED.details, at = now()`, [...stepKey(), JSON.stringify(details ?? null)]);
191
+ },
192
+ async lastHeartbeat() {
193
+ const r = await db.query("SELECT details FROM iwf.heartbeat WHERE workflow_id = $1 AND step_id = $2", stepKey());
194
+ return r.rows[0]?.details ?? undefined;
195
+ },
196
+ attempt: () => {
197
+ const n = attemptNo.getStore();
198
+ if (n === undefined)
199
+ throw new Error("stepAttempt вне шага");
200
+ return n;
201
+ }
202
+ });
203
+ const pools = Object.fromEntries(Object.entries(def.pools ?? {}).map(([p, n]) => [p, new Semaphore(n)]));
204
+ const enqueue = (target, args, opts, reject, priority) => DBOS.enqueueWorkflowWithOptions({
205
+ queueName: target.app,
206
+ workflowName: target.flow,
207
+ workflowID: opts.id,
208
+ applicationName: target.app,
209
+ ...opts.timeoutMs ? { workflowTimeoutMS: opts.timeoutMs } : {},
210
+ ...reject ? { workflowIDReusePolicy: "reject" } : {},
211
+ priority: priority ?? DEFAULT_PRIORITY
212
+ }, ...args).catch(alreadyStarted(opts.id));
213
+ const waitChild = async (h, id) => {
214
+ try {
215
+ return await h.getResult(resultPoll());
216
+ } catch (e) {
217
+ throw await flowFailure(e, () => DBOS.getWorkflowStatus(id).then((st) => st ?? undefined));
218
+ }
219
+ };
220
+ for (const [name, flow] of Object.entries(def.flows)) {
221
+ const run = async (...args) => {
222
+ const workflowId = DBOS.workflowID;
223
+ await patches.born(workflowId);
224
+ const row = (await db.query("SELECT created_at, parent_workflow_id, priority FROM dbos.workflow_status WHERE workflow_uuid = $1", [
225
+ workflowId
226
+ ])).rows[0];
227
+ const ctx = {
228
+ workflowId,
229
+ parentId: row?.parent_workflow_id ?? undefined,
230
+ startedAt: new Date(Number(row?.created_at)),
231
+ priority: row?.priority || DEFAULT_PRIORITY,
232
+ now: () => DBOS.now(),
233
+ step: (step, stepArgs, opts = {}) => {
234
+ const pool = opts.pool ? pools[opts.pool] : undefined;
235
+ if (opts.pool && !pool)
236
+ throw new Error(`пул "${opts.pool}" не объявлен в AppDefinition.pools модуля ${def.app}`);
237
+ const alive = async () => {
238
+ for (;; ) {
239
+ const st = (await db.query("SELECT status, workflow_deadline_epoch_ms AS deadline FROM dbos.workflow_status WHERE workflow_uuid = $1", [workflowId])).rows[0];
240
+ if (!st || !["PENDING", "ENQUEUED"].includes(st.status))
241
+ return false;
242
+ if (st.deadline === null || Number(st.deadline) > Date.now())
243
+ return true;
244
+ if (Date.now() - Number(st.deadline) > 60000)
245
+ return false;
246
+ await sleepMs(100);
247
+ }
248
+ };
249
+ return DBOS.runStep(() => attempts(def.activities[step], stepArgs, opts, pool, alive, checkPriority(opts.priority) ?? ctx.priority), {
250
+ name: step,
251
+ retriesAllowed: false
252
+ }).catch((e) => {
253
+ throw revive(e);
254
+ });
255
+ },
256
+ child: async (target, childArgs, opts) => waitChild(await enqueue(target, childArgs, opts, opts.onExisting === "reject", ctx.priority), opts.id),
257
+ async startChild(target, childArgs, opts) {
258
+ const h = await enqueue(target, childArgs, opts, true, ctx.priority);
259
+ return { id: opts.id, result: () => waitChild(h, opts.id) };
260
+ },
261
+ sleep: (ms) => DBOS.sleep(ms),
262
+ recv: (topic, timeoutMs) => DBOS.recv(topic, { timeoutSeconds: timeoutMs / 1000, ...resultPoll() }),
263
+ async signal(target, topic, value) {
264
+ const status = await DBOS.runStep(async () => (await db.query("SELECT status FROM dbos.workflow_status WHERE workflow_uuid = $1", [target])).rows[0]?.status ?? null, { name: "iwf.signalTarget" });
265
+ if (status === null || FINISHED.has(status))
266
+ throw new FlowError(`процесса ${target} нет или он завершён`, { reason: "not-found" });
267
+ await DBOS.send(target, value, topic);
268
+ },
269
+ setState: (key, value) => DBOS.setEvent(key, value),
270
+ patched: (id) => patches.patched(workflowId, id),
271
+ deprecatePatch: async () => {}
272
+ };
273
+ return flow(ctx, ...args);
274
+ };
275
+ DBOS.registerWorkflow(run, { name });
276
+ }
277
+ DBOS.setConfig({
278
+ name: def.app,
279
+ systemDatabaseUrl: sysDb(),
280
+ logLevel: "warn",
281
+ applicationVersion: process.env.DBOS_APPLICATION_VERSION ?? `${def.app}-patching`
282
+ });
283
+ await underLaunchLock(db, () => DBOS.launch());
284
+ const poll = Number(process.env.DBOS_QUEUE_POLL_MS) || undefined;
285
+ await DBOS.registerQueue(def.app, { minPollingIntervalMs: poll, onConflict: "always_update" });
286
+ }
287
+ return {
288
+ done: () => exit.promise(),
289
+ async start(def) {
290
+ exit.arm();
291
+ try {
292
+ await launch(def);
293
+ } catch (e) {
294
+ await pool?.end().catch(() => {});
295
+ pool = undefined;
296
+ exit.reject(e);
297
+ throw e;
298
+ }
299
+ },
300
+ async stop() {
301
+ try {
302
+ await DBOS.shutdown();
303
+ await pool?.end();
304
+ } finally {
305
+ exit.resolve();
306
+ }
307
+ }
308
+ };
309
+ }
310
+ async function dbosClient() {
311
+ const client = await DBOSClient.create({ systemDatabaseUrl: sysDb() });
312
+ const status = (id) => client.getWorkflow(id);
313
+ const exists = async (id) => {
314
+ const st = await status(id);
315
+ if (!st)
316
+ throw new FlowError(`процесса ${id} нет`, { reason: "not-found" });
317
+ return st;
318
+ };
319
+ const wrap = (id) => ({
320
+ id,
321
+ result: () => client.retrieveWorkflow(id).getResult(resultPoll()).catch(async (e) => {
322
+ throw await flowFailure(e, () => status(id));
323
+ }),
324
+ signal: (topic, value) => client.send(id, value, topic),
325
+ query: async (key) => await client.getEvent(id, key, { timeoutSeconds: 0 }) ?? undefined,
326
+ async describe() {
327
+ const st = await exists(id);
328
+ return { id, status: describeStatus(st), startedAt: new Date(st.createdAt) };
329
+ },
330
+ async terminate() {
331
+ await exists(id);
332
+ await client.cancelWorkflow(id);
333
+ }
334
+ });
335
+ const self = {
336
+ async start(target, args, opts) {
337
+ const h = await client.enqueue({
338
+ queueName: target.app,
339
+ workflowName: target.flow,
340
+ workflowID: opts.id,
341
+ applicationName: target.app,
342
+ ...opts.timeoutMs ? { workflowTimeoutMS: opts.timeoutMs } : {},
343
+ priority: checkPriority(opts.priority) ?? DEFAULT_PRIORITY
344
+ }, ...args);
345
+ return wrap(h.workflowID);
346
+ },
347
+ async signalWithStart(target, args, opts, topic, value) {
348
+ const h = await self.start(target, args, opts);
349
+ if (FINISHED.has((await status(h.id))?.status ?? ""))
350
+ throw new FlowError(`процесс ${h.id} уже завершён — id не переиспользуется`, { reason: "already-started" });
351
+ await client.send(h.id, value, topic);
352
+ return h;
353
+ },
354
+ handle: (id) => wrap(id),
355
+ close: () => client.destroy()
356
+ };
357
+ return self;
358
+ }
359
+ export {
360
+ dbosClient,
361
+ dbosRuntime
362
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iworkflow/dbos",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "DBOS adapter for @iworkflow/core",
5
5
  "keywords": [
6
6
  "workflow",
@@ -14,22 +14,30 @@
14
14
  "directory": "packages/dbos"
15
15
  },
16
16
  "files": [
17
- "src"
17
+ "src",
18
+ "!src/**/*.test.ts",
19
+ "dist"
18
20
  ],
19
21
  "type": "module",
20
22
  "sideEffects": false,
21
23
  "exports": {
22
- ".": "./src/index.ts"
24
+ ".": {
25
+ "bun": "./src/index.ts",
26
+ "types": "./dist/index.d.ts",
27
+ "default": "./dist/index.js"
28
+ }
23
29
  },
24
30
  "publishConfig": {
25
31
  "access": "public"
26
32
  },
27
33
  "scripts": {
34
+ "build": "rm -rf dist && bun build src/index.ts --outdir dist --target node --format esm --packages external && tsc -p tsconfig.build.json",
28
35
  "type-check": "tsc --noEmit -p tsconfig.json",
29
- "test": "bun test"
36
+ "test": "bun test",
37
+ "prepack": "bun run build"
30
38
  },
31
39
  "dependencies": {
32
- "@iworkflow/core": "^0.3.0",
40
+ "@iworkflow/core": "^0.5.0",
33
41
  "pg": "^8.0.0"
34
42
  },
35
43
  "peerDependencies": {
@@ -38,5 +46,8 @@
38
46
  "devDependencies": {
39
47
  "@dbos-inc/dbos-sdk": "^5.3.0",
40
48
  "@types/pg": "^8.0.0"
49
+ },
50
+ "engines": {
51
+ "node": ">=22"
41
52
  }
42
53
  }
package/src/index.ts CHANGED
@@ -3,6 +3,9 @@
3
3
  import { DBOS, DBOSClient, Error as DBOSErrors, type WorkflowStatus } from "@dbos-inc/dbos-sdk";
4
4
  import {
5
5
  bindStepRuntime,
6
+ checkPriority,
7
+ DEFAULT_PRIORITY,
8
+ exitSignal,
6
9
  Failure,
7
10
  FlowError,
8
11
  isFlowError,
@@ -13,9 +16,12 @@ import {
13
16
  type EngineRuntime,
14
17
  type FlowContext,
15
18
  type FlowHandle,
19
+ type FlowRef,
16
20
  type FlowStatus,
21
+ type StartOptions,
17
22
  type StepOptions,
18
23
  } from "@iworkflow/core";
24
+ import { AsyncLocalStorage } from "node:async_hooks";
19
25
  import { Pool } from "pg";
20
26
 
21
27
  // Ожидание результата дочернего процесса — опрос базы, по умолчанию раз в секунду: это и есть
@@ -45,7 +51,7 @@ const LAUNCH_LOCK = "hashtext('iworkflow/dbos launch')";
45
51
  async function underLaunchLock<T>(pool: Pool, fn: () => Promise<T>): Promise<T> {
46
52
  const c = await pool.connect();
47
53
  try {
48
- while (!(await c.query(`SELECT pg_try_advisory_lock(${LAUNCH_LOCK}) AS ok`)).rows[0].ok) await Bun.sleep(200);
54
+ while (!(await c.query(`SELECT pg_try_advisory_lock(${LAUNCH_LOCK}) AS ok`)).rows[0].ok) await sleepMs(200);
49
55
  for (const sql of HEARTBEAT_DDL) await c.query(sql);
50
56
  return await fn();
51
57
  } finally {
@@ -134,17 +140,22 @@ const alreadyStarted = (id: string) => (e: unknown): never => {
134
140
  // Повторы — свои, внутри одного шага DBOS: в историю пишется уже FlowError с причиной, как у Temporal.
135
141
  // Цена: отмена процесса не прерывает повторы шага, а ждёт его конца.
136
142
 
143
+ /** Предел одновременных шагов пула; ждущие выходят по приоритету (1 — первым), внутри — по очереди. */
137
144
  class Semaphore {
138
- private queue: (() => void)[] = [];
145
+ private queue: { priority: number; go: () => void }[] = [];
139
146
  constructor(private free: number) {}
140
- async run<T>(fn: () => Promise<T>): Promise<T> {
147
+ async run<T>(fn: () => Promise<T>, priority = DEFAULT_PRIORITY): Promise<T> {
141
148
  if (this.free > 0) this.free--;
142
- else await new Promise<void>((r) => this.queue.push(r));
149
+ else
150
+ await new Promise<void>((go) => {
151
+ const i = this.queue.findIndex((w) => w.priority > priority);
152
+ this.queue.splice(i < 0 ? this.queue.length : i, 0, { priority, go });
153
+ });
143
154
  try {
144
155
  return await fn();
145
156
  } finally {
146
157
  const next = this.queue.shift();
147
- if (next) next();
158
+ if (next) next.go();
148
159
  else this.free++;
149
160
  }
150
161
  }
@@ -154,6 +165,9 @@ class AttemptTimeout extends Error {}
154
165
 
155
166
  const sleepMs = (ms: number) => new Promise((r) => setTimeout(r, ms));
156
167
 
168
+ /** Номер идущей попытки шага — для stepAttempt(). */
169
+ const attemptNo = new AsyncLocalStorage<number>();
170
+
157
171
  /** Попытка, урезанная по сроку: брошенная продолжает идти в фоне, её итог отбрасывается. */
158
172
  async function withTimeout<T>(p: Promise<T>, ms: number): Promise<T> {
159
173
  let timer: ReturnType<typeof setTimeout> | undefined;
@@ -174,14 +188,15 @@ async function attempts(
174
188
  opts: StepOptions,
175
189
  pool: Semaphore | undefined,
176
190
  alive: () => Promise<boolean>,
191
+ priority: number | undefined,
177
192
  ) {
178
193
  const max = opts.attempts ?? 3;
179
194
  const started = Date.now();
180
195
  let interval = opts.retryIntervalMs ?? 200;
181
196
  for (let n = 1; ; n++) {
182
197
  try {
183
- const attempt = () => withTimeout(fn(...args), opts.timeoutMs ?? 60_000);
184
- return await (pool ? pool.run(attempt) : attempt());
198
+ const attempt = () => attemptNo.run(n, () => withTimeout(fn(...args), opts.timeoutMs ?? 60_000));
199
+ return await (pool ? pool.run(attempt, priority) : attempt());
185
200
  } catch (e) {
186
201
  const stop = (e instanceof Failure && e.nonRetryable) || (opts.nonRetryableTypes ?? []).includes(typeOf(e));
187
202
  if (stop || n >= max) {
@@ -194,16 +209,24 @@ async function attempts(
194
209
  exhausted,
195
210
  });
196
211
  }
197
- if (opts.totalTimeoutMs !== undefined && Date.now() - started + interval >= opts.totalTimeoutMs)
212
+ // Отказ сам назвал срок (FLOOD_WAIT) — ждём его, а не паузу из opts.
213
+ const wait = e instanceof Failure && e.retryAfterMs !== undefined ? e.retryAfterMs : interval;
214
+ if (opts.totalTimeoutMs !== undefined && Date.now() - started + wait >= opts.totalTimeoutMs)
198
215
  throw new FlowError(e instanceof Error ? e.message : String(e), {
199
216
  reason: "timeout",
200
217
  type: typeOf(e),
201
218
  details: e instanceof Failure ? e.details : undefined,
202
219
  });
203
- await sleepMs(interval);
204
- // После паузы: за неё процесс мог выйти по сроку или быть отменён.
220
+ // Пауза кусками: срок или отмена процесса видны не позже чем через 5 с, а не после всей паузы.
205
221
  // Сбой чтения статуса — не повод бросать шаг: повторяем дальше.
206
- if (!(await alive().catch(() => true))) throw new FlowError("процесс прерван — повторы шага остановлены", { reason: "terminated" });
222
+ // do…while: и при нулевой паузе статус процесса проверяется перед каждой попыткой.
223
+ let left = wait;
224
+ do {
225
+ await sleepMs(Math.min(Math.max(left, 0), 5_000));
226
+ if (!(await alive().catch(() => true)))
227
+ throw new FlowError("процесс прерван — повторы шага остановлены", { reason: "terminated" });
228
+ left -= 5_000;
229
+ } while (left > 0);
207
230
  interval = Math.min(interval * (opts.backoff ?? 1), opts.maxRetryIntervalMs ?? Infinity);
208
231
  }
209
232
  }
@@ -222,132 +245,166 @@ const describeStatus = (st: WorkflowStatus): FlowStatus =>
222
245
 
223
246
  export function dbosRuntime(): EngineRuntime {
224
247
  let pool: Pool | undefined;
225
- return {
226
- async start(def: AppDefinition) {
227
- pool = new Pool({ connectionString: sysDb(), max: 2 });
228
- const db = pool;
229
- const patches = patchBook(db, def.app, def.patches ?? []);
230
- bindStepRuntime({
231
- async heartbeat(details) {
232
- await db.query(
233
- `INSERT INTO iwf.heartbeat (workflow_id, step_id, details) VALUES ($1, $2, $3)
234
- ON CONFLICT (workflow_id, step_id) DO UPDATE SET details = EXCLUDED.details, at = now()`,
235
- [...stepKey(), JSON.stringify(details ?? null)],
236
- );
237
- },
238
- async lastHeartbeat() {
239
- const r = await db.query("SELECT details FROM iwf.heartbeat WHERE workflow_id = $1 AND step_id = $2", stepKey());
240
- return r.rows[0]?.details ?? undefined;
248
+ // Воркеров у DBOS нет: done() — до stop или сбоя start; падения исполнителя DBOS не видно.
249
+ const exit = exitSignal();
250
+ async function launch(def: AppDefinition) {
251
+ pool = new Pool({ connectionString: sysDb(), max: 2 });
252
+ const db = pool;
253
+ const patches = patchBook(db, def.app, def.patches ?? []);
254
+ bindStepRuntime({
255
+ async heartbeat(details) {
256
+ await db.query(
257
+ `INSERT INTO iwf.heartbeat (workflow_id, step_id, details) VALUES ($1, $2, $3)
258
+ ON CONFLICT (workflow_id, step_id) DO UPDATE SET details = EXCLUDED.details, at = now()`,
259
+ [...stepKey(), JSON.stringify(details ?? null)],
260
+ );
261
+ },
262
+ async lastHeartbeat() {
263
+ const r = await db.query("SELECT details FROM iwf.heartbeat WHERE workflow_id = $1 AND step_id = $2", stepKey());
264
+ return r.rows[0]?.details ?? undefined;
265
+ },
266
+ attempt: () => {
267
+ const n = attemptNo.getStore();
268
+ if (n === undefined) throw new Error("stepAttempt вне шага");
269
+ return n;
270
+ },
271
+ });
272
+ const pools = Object.fromEntries(Object.entries(def.pools ?? {}).map(([p, n]) => [p, new Semaphore(n)]));
273
+ const enqueue = (
274
+ target: { app: string; flow: string },
275
+ args: unknown[],
276
+ opts: ChildOptions,
277
+ reject: boolean,
278
+ priority: number | undefined,
279
+ ) =>
280
+ DBOS.enqueueWorkflowWithOptions(
281
+ {
282
+ queueName: target.app,
283
+ workflowName: target.flow,
284
+ workflowID: opts.id,
285
+ applicationName: target.app,
286
+ ...(opts.timeoutMs ? { workflowTimeoutMS: opts.timeoutMs } : {}),
287
+ ...(reject ? { workflowIDReusePolicy: "reject" as const } : {}),
288
+ // Ребёнок наследует приоритет родителя, как у Temporal.
289
+ priority: priority ?? DEFAULT_PRIORITY,
241
290
  },
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
- };
263
- for (const [name, flow] of Object.entries(def.flows)) {
264
- const run = async (...args: unknown[]) => {
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];
273
- const ctx: FlowContext = {
291
+ ...args,
292
+ ).catch(alreadyStarted(opts.id));
293
+ const waitChild = async <T>(h: { getResult(o?: object): Promise<unknown> }, id: string): Promise<T> => {
294
+ try {
295
+ return (await h.getResult(resultPoll())) as T;
296
+ } catch (e) {
297
+ throw await flowFailure(e, () => DBOS.getWorkflowStatus(id).then((st) => st ?? undefined));
298
+ }
299
+ };
300
+ for (const [name, flow] of Object.entries(def.flows)) {
301
+ const run = async (...args: unknown[]) => {
302
+ const workflowId = DBOS.workflowID!;
303
+ await patches.born(workflowId);
304
+ // Строка статуса не меняется после создания: чтение вне шага детерминировано.
305
+ const row = (
306
+ await db.query("SELECT created_at, parent_workflow_id, priority FROM dbos.workflow_status WHERE workflow_uuid = $1", [
274
307
  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), {
298
- name: step,
299
- retriesAllowed: false,
300
- }).catch((e) => {
301
- throw revive(e);
302
- }) as Promise<never>;
303
- },
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) };
310
- },
311
- sleep: (ms) => DBOS.sleep(ms),
312
- recv: (topic, timeoutMs) => DBOS.recv(topic, { timeoutSeconds: timeoutMs / 1000, ...resultPoll() }),
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
- },
325
- setState: (key, value) => DBOS.setEvent(key, value),
326
- patched: (id) => patches.patched(workflowId, id),
327
- // Старых процессов нет — ветка всегда новая; вызов можно удалять.
328
- deprecatePatch: async () => {},
329
- };
330
- return flow(ctx, ...args);
308
+ ])
309
+ ).rows[0];
310
+ const ctx: FlowContext = {
311
+ workflowId,
312
+ parentId: row?.parent_workflow_id ?? undefined,
313
+ startedAt: new Date(Number(row?.created_at)),
314
+ priority: row?.priority || DEFAULT_PRIORITY,
315
+ now: () => DBOS.now(),
316
+ step: (step, stepArgs, opts = {}) => {
317
+ const pool = opts.pool ? pools[opts.pool] : undefined;
318
+ if (opts.pool && !pool) throw new Error(`пул "${opts.pool}" не объявлен в AppDefinition.pools модуля ${def.app}`);
319
+ // heartbeatTimeoutMs у DBOS не действует: умерший процесс ловится рестартом, шаг повторяется.
320
+ // Срок вышел, а DBOS ещё не отменил процесс — ждём отмены: иначе процесс поймает отказ и завершится успехом.
321
+ const alive = async () => {
322
+ for (;;) {
323
+ const st = (
324
+ await db.query(
325
+ "SELECT status, workflow_deadline_epoch_ms AS deadline FROM dbos.workflow_status WHERE workflow_uuid = $1",
326
+ [workflowId],
327
+ )
328
+ ).rows[0];
329
+ if (!st || !["PENDING", "ENQUEUED"].includes(st.status)) return false;
330
+ if (st.deadline === null || Number(st.deadline) > Date.now()) return true;
331
+ // Отмену ставит DBOS; не поставил за минуту — не ждать вечно.
332
+ if (Date.now() - Number(st.deadline) > 60_000) return false;
333
+ await sleepMs(100);
334
+ }
335
+ };
336
+ return DBOS.runStep(() => attempts(def.activities[step]!, stepArgs, opts, pool, alive, checkPriority(opts.priority) ?? ctx.priority), {
337
+ name: step,
338
+ retriesAllowed: false,
339
+ }).catch((e) => {
340
+ throw revive(e);
341
+ }) as Promise<never>;
342
+ },
343
+ child: async (target, childArgs, opts) =>
344
+ waitChild(await enqueue(target, childArgs, opts, opts.onExisting === "reject", ctx.priority), opts.id),
345
+ async startChild(target, childArgs, opts) {
346
+ // Процесс DBOS не привязан к родителю: живёт сам, как ABANDON у Temporal.
347
+ const h = await enqueue(target, childArgs, opts, true, ctx.priority);
348
+ return { id: opts.id, result: () => waitChild(h, opts.id) };
349
+ },
350
+ sleep: (ms) => DBOS.sleep(ms),
351
+ recv: (topic, timeoutMs) => DBOS.recv(topic, { timeoutSeconds: timeoutMs / 1000, ...resultPoll() }),
352
+ async signal(target, topic, value) {
353
+ // Отправка завершённому процессу у DBOS проходит молча; Temporal отказывает — проверяем сами.
354
+ const status = await DBOS.runStep(
355
+ async () =>
356
+ ((await db.query("SELECT status FROM dbos.workflow_status WHERE workflow_uuid = $1", [target])).rows[0]
357
+ ?.status as string | undefined) ?? null,
358
+ { name: "iwf.signalTarget" },
359
+ );
360
+ if (status === null || FINISHED.has(status))
361
+ throw new FlowError(`процесса ${target} нет или он завершён`, { reason: "not-found" });
362
+ await DBOS.send(target, value, topic);
363
+ },
364
+ setState: (key, value) => DBOS.setEvent(key, value),
365
+ patched: (id) => patches.patched(workflowId, id),
366
+ // Старых процессов нет — ветка всегда новая; вызов можно удалять.
367
+ deprecatePatch: async () => {},
331
368
  };
332
- DBOS.registerWorkflow(run, { name });
369
+ return flow(ctx, ...args);
370
+ };
371
+ DBOS.registerWorkflow(run, { name });
372
+ }
373
+ // Версия постоянна, ветки кода — patched. Без этого версия — хэш кода, и после выкатки новый
374
+ // процесс не подхватывает незавершённые процессы старого. Своя на модуль: в общей системной
375
+ // базе DBOS требует уникальных имён версий.
376
+ DBOS.setConfig({
377
+ name: def.app,
378
+ systemDatabaseUrl: sysDb(),
379
+ logLevel: "warn",
380
+ applicationVersion: process.env.DBOS_APPLICATION_VERSION ?? `${def.app}-patching`,
381
+ });
382
+ await underLaunchLock(db, () => DBOS.launch());
383
+ // Опрос очереди — главный вклад в задержку перехода между модулями (по умолчанию ~1 с).
384
+ const poll = Number(process.env.DBOS_QUEUE_POLL_MS) || undefined;
385
+ await DBOS.registerQueue(def.app, { minPollingIntervalMs: poll, onConflict: "always_update" });
386
+ }
387
+ return {
388
+ done: () => exit.promise(),
389
+ async start(def: AppDefinition) {
390
+ exit.arm();
391
+ // Не запустился — done() отклоняется: для наблюдателя это сбой, а не штатный выход.
392
+ try {
393
+ await launch(def);
394
+ } catch (e) {
395
+ await pool?.end().catch(() => {});
396
+ pool = undefined;
397
+ exit.reject(e);
398
+ throw e;
333
399
  }
334
- // Версия постоянна, ветки кода — patched. Без этого версия — хэш кода, и после выкатки новый
335
- // процесс не подхватывает незавершённые процессы старого. Своя на модуль: в общей системной
336
- // базе DBOS требует уникальных имён версий.
337
- DBOS.setConfig({
338
- name: def.app,
339
- systemDatabaseUrl: sysDb(),
340
- logLevel: "warn",
341
- applicationVersion: process.env.DBOS_APPLICATION_VERSION ?? `${def.app}-patching`,
342
- });
343
- await underLaunchLock(db, () => DBOS.launch());
344
- // Опрос очереди — главный вклад в задержку перехода между модулями (по умолчанию ~1 с).
345
- const poll = Number(process.env.DBOS_QUEUE_POLL_MS) || undefined;
346
- await DBOS.registerQueue(def.app, { minPollingIntervalMs: poll, onConflict: "always_update" });
347
400
  },
348
401
  async stop() {
349
- await DBOS.shutdown();
350
- await pool?.end();
402
+ try {
403
+ await DBOS.shutdown();
404
+ await pool?.end();
405
+ } finally {
406
+ exit.resolve();
407
+ }
351
408
  },
352
409
  };
353
410
  }
@@ -378,7 +435,7 @@ export async function dbosClient(): Promise<EngineClient> {
378
435
  await client.cancelWorkflow(id);
379
436
  },
380
437
  });
381
- return {
438
+ const self: EngineClient = {
382
439
  async start(target, args, opts) {
383
440
  const h = await client.enqueue(
384
441
  {
@@ -387,12 +444,24 @@ export async function dbosClient(): Promise<EngineClient> {
387
444
  workflowID: opts.id,
388
445
  applicationName: target.app,
389
446
  ...(opts.timeoutMs ? { workflowTimeoutMS: opts.timeoutMs } : {}),
447
+ // Без приоритета DBOS ставит 0 — впереди всех; порт по умолчанию 3, как Temporal.
448
+ priority: checkPriority(opts.priority) ?? DEFAULT_PRIORITY,
390
449
  },
391
450
  ...args,
392
451
  );
393
452
  return wrap(h.workflowID);
394
453
  },
454
+ async signalWithStart<T>(target: FlowRef, args: unknown[], opts: StartOptions, topic: string, value: unknown) {
455
+ // Не атомарно, но без потерь: сигнал ждёт в очереди темы, пока процесс до него дойдёт.
456
+ const h = await self.start<T>(target, args, opts);
457
+ // id завершённого DBOS не переиспользует: сигнал ушёл бы в никуда.
458
+ if (FINISHED.has((await status(h.id))?.status ?? ""))
459
+ throw new FlowError(`процесс ${h.id} уже завершён — id не переиспользуется`, { reason: "already-started" });
460
+ await client.send(h.id, value, topic);
461
+ return h;
462
+ },
395
463
  handle: (id) => wrap(id),
396
464
  close: () => client.destroy(),
397
465
  };
466
+ return self;
398
467
  }