@iworkflow/dbos 0.4.0 → 0.6.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/dist/index.js CHANGED
@@ -1,13 +1,24 @@
1
1
  // src/index.ts
2
2
  import { DBOS, DBOSClient, Error as DBOSErrors } from "@dbos-inc/dbos-sdk";
3
3
  import {
4
- bindStepRuntime,
4
+ stepScope,
5
+ checkPriority,
6
+ DEFAULT_PRIORITY,
5
7
  exitSignal,
6
8
  Failure,
7
9
  FlowError,
8
10
  isFlowError
9
11
  } from "@iworkflow/core";
10
12
  import { Pool } from "pg";
13
+
14
+ // src/scope.ts
15
+ import { AsyncLocalStorage } from "node:async_hooks";
16
+ var alsScope = () => {
17
+ const als = new AsyncLocalStorage;
18
+ return { run: (rt, fn) => als.run(rt, fn), current: () => als.getStore() };
19
+ };
20
+
21
+ // src/index.ts
11
22
  var resultPoll = () => ({ pollingIntervalMs: Number(process.env.DBOS_RESULT_POLL_MS) || undefined });
12
23
  var sysDb = () => process.env.DBOS_SYSTEM_DATABASE_URL ?? "postgres://iworkflow:iworkflow@localhost:55432/iworkflow";
13
24
  var HEARTBEAT_DDL = [
@@ -93,17 +104,20 @@ class Semaphore {
93
104
  constructor(free) {
94
105
  this.free = free;
95
106
  }
96
- async run(fn) {
107
+ async run(fn, priority = DEFAULT_PRIORITY) {
97
108
  if (this.free > 0)
98
109
  this.free--;
99
110
  else
100
- await new Promise((r) => this.queue.push(r));
111
+ await new Promise((go) => {
112
+ const i = this.queue.findIndex((w) => w.priority > priority);
113
+ this.queue.splice(i < 0 ? this.queue.length : i, 0, { priority, go });
114
+ });
101
115
  try {
102
116
  return await fn();
103
117
  } finally {
104
118
  const next = this.queue.shift();
105
119
  if (next)
106
- next();
120
+ next.go();
107
121
  else
108
122
  this.free++;
109
123
  }
@@ -113,6 +127,7 @@ class Semaphore {
113
127
  class AttemptTimeout extends Error {
114
128
  }
115
129
  var sleepMs = (ms) => new Promise((r) => setTimeout(r, ms));
130
+ var scope = stepScope(alsScope);
116
131
  async function withTimeout(p, ms) {
117
132
  let timer;
118
133
  try {
@@ -124,14 +139,14 @@ async function withTimeout(p, ms) {
124
139
  clearTimeout(timer);
125
140
  }
126
141
  }
127
- async function attempts(fn, args, opts, pool, alive) {
142
+ async function attempts(fn, args, opts, pool, alive, priority, runtime) {
128
143
  const max = opts.attempts ?? 3;
129
144
  const started = Date.now();
130
145
  let interval = opts.retryIntervalMs ?? 200;
131
146
  for (let n = 1;; n++) {
132
147
  try {
133
- const attempt = () => withTimeout(fn(...args), opts.timeoutMs ?? 60000);
134
- return await (pool ? pool.run(attempt) : attempt());
148
+ const attempt = () => scope.run(runtime(n), () => withTimeout(fn(...args), opts.timeoutMs ?? 60000));
149
+ return await (pool ? pool.run(attempt, priority) : attempt());
135
150
  } catch (e) {
136
151
  const stop = e instanceof Failure && e.nonRetryable || (opts.nonRetryableTypes ?? []).includes(typeOf(e));
137
152
  if (stop || n >= max) {
@@ -145,15 +160,20 @@ async function attempts(fn, args, opts, pool, alive) {
145
160
  exhausted
146
161
  });
147
162
  }
148
- if (opts.totalTimeoutMs !== undefined && Date.now() - started + interval >= opts.totalTimeoutMs)
163
+ const wait = e instanceof Failure && e.retryAfterMs !== undefined ? e.retryAfterMs : interval;
164
+ if (opts.totalTimeoutMs !== undefined && Date.now() - started + wait >= opts.totalTimeoutMs)
149
165
  throw new FlowError(e instanceof Error ? e.message : String(e), {
150
166
  reason: "timeout",
151
167
  type: typeOf(e),
152
168
  details: e instanceof Failure ? e.details : undefined
153
169
  });
154
- await sleepMs(interval);
155
- if (!await alive().catch(() => true))
156
- throw new FlowError("процесс прерван — повторы шага остановлены", { reason: "terminated" });
170
+ let left = wait;
171
+ do {
172
+ await sleepMs(Math.min(Math.max(left, 0), 5000));
173
+ if (!await alive().catch(() => true))
174
+ throw new FlowError("процесс прерван — повторы шага остановлены", { reason: "terminated" });
175
+ left -= 5000;
176
+ } while (left > 0);
157
177
  interval = Math.min(interval * (opts.backoff ?? 1), opts.maxRetryIntervalMs ?? Infinity);
158
178
  }
159
179
  }
@@ -172,7 +192,7 @@ function dbosRuntime() {
172
192
  pool = new Pool({ connectionString: sysDb(), max: 2 });
173
193
  const db = pool;
174
194
  const patches = patchBook(db, def.app, def.patches ?? []);
175
- bindStepRuntime({
195
+ const stepRuntime = (n) => ({
176
196
  async heartbeat(details) {
177
197
  await db.query(`INSERT INTO iwf.heartbeat (workflow_id, step_id, details) VALUES ($1, $2, $3)
178
198
  ON CONFLICT (workflow_id, step_id) DO UPDATE SET details = EXCLUDED.details, at = now()`, [...stepKey(), JSON.stringify(details ?? null)]);
@@ -180,16 +200,18 @@ function dbosRuntime() {
180
200
  async lastHeartbeat() {
181
201
  const r = await db.query("SELECT details FROM iwf.heartbeat WHERE workflow_id = $1 AND step_id = $2", stepKey());
182
202
  return r.rows[0]?.details ?? undefined;
183
- }
203
+ },
204
+ attempt: () => n
184
205
  });
185
206
  const pools = Object.fromEntries(Object.entries(def.pools ?? {}).map(([p, n]) => [p, new Semaphore(n)]));
186
- const enqueue = (target, args, opts, reject) => DBOS.enqueueWorkflowWithOptions({
207
+ const enqueue = (target, args, opts, reject, priority) => DBOS.enqueueWorkflowWithOptions({
187
208
  queueName: target.app,
188
209
  workflowName: target.flow,
189
210
  workflowID: opts.id,
190
211
  applicationName: target.app,
191
212
  ...opts.timeoutMs ? { workflowTimeoutMS: opts.timeoutMs } : {},
192
- ...reject ? { workflowIDReusePolicy: "reject" } : {}
213
+ ...reject ? { workflowIDReusePolicy: "reject" } : {},
214
+ priority: priority ?? DEFAULT_PRIORITY
193
215
  }, ...args).catch(alreadyStarted(opts.id));
194
216
  const waitChild = async (h, id) => {
195
217
  try {
@@ -202,13 +224,15 @@ function dbosRuntime() {
202
224
  const run = async (...args) => {
203
225
  const workflowId = DBOS.workflowID;
204
226
  await patches.born(workflowId);
205
- const row = (await db.query("SELECT created_at, parent_workflow_id FROM dbos.workflow_status WHERE workflow_uuid = $1", [
227
+ const row = (await db.query("SELECT created_at, parent_workflow_id, priority FROM dbos.workflow_status WHERE workflow_uuid = $1", [
206
228
  workflowId
207
229
  ])).rows[0];
208
230
  const ctx = {
209
231
  workflowId,
210
232
  parentId: row?.parent_workflow_id ?? undefined,
211
233
  startedAt: new Date(Number(row?.created_at)),
234
+ priority: row?.priority || DEFAULT_PRIORITY,
235
+ now: () => DBOS.now(),
212
236
  step: (step, stepArgs, opts = {}) => {
213
237
  const pool = opts.pool ? pools[opts.pool] : undefined;
214
238
  if (opts.pool && !pool)
@@ -225,16 +249,16 @@ function dbosRuntime() {
225
249
  await sleepMs(100);
226
250
  }
227
251
  };
228
- return DBOS.runStep(() => attempts(def.activities[step], stepArgs, opts, pool, alive), {
252
+ return DBOS.runStep(() => attempts(def.activities[step], stepArgs, opts, pool, alive, checkPriority(opts.priority) ?? ctx.priority, stepRuntime), {
229
253
  name: step,
230
254
  retriesAllowed: false
231
255
  }).catch((e) => {
232
256
  throw revive(e);
233
257
  });
234
258
  },
235
- child: async (target, childArgs, opts) => waitChild(await enqueue(target, childArgs, opts, opts.onExisting === "reject"), opts.id),
259
+ child: async (target, childArgs, opts) => waitChild(await enqueue(target, childArgs, opts, opts.onExisting === "reject", ctx.priority), opts.id),
236
260
  async startChild(target, childArgs, opts) {
237
- const h = await enqueue(target, childArgs, opts, true);
261
+ const h = await enqueue(target, childArgs, opts, true, ctx.priority);
238
262
  return { id: opts.id, result: () => waitChild(h, opts.id) };
239
263
  },
240
264
  sleep: (ms) => DBOS.sleep(ms),
@@ -311,20 +335,29 @@ async function dbosClient() {
311
335
  await client.cancelWorkflow(id);
312
336
  }
313
337
  });
314
- return {
338
+ const self = {
315
339
  async start(target, args, opts) {
316
340
  const h = await client.enqueue({
317
341
  queueName: target.app,
318
342
  workflowName: target.flow,
319
343
  workflowID: opts.id,
320
344
  applicationName: target.app,
321
- ...opts.timeoutMs ? { workflowTimeoutMS: opts.timeoutMs } : {}
345
+ ...opts.timeoutMs ? { workflowTimeoutMS: opts.timeoutMs } : {},
346
+ priority: checkPriority(opts.priority) ?? DEFAULT_PRIORITY
322
347
  }, ...args);
323
348
  return wrap(h.workflowID);
324
349
  },
350
+ async signalWithStart(target, args, opts, topic, value) {
351
+ const h = await self.start(target, args, opts);
352
+ if (FINISHED.has((await status(h.id))?.status ?? ""))
353
+ throw new FlowError(`процесс ${h.id} уже завершён — id не переиспользуется`, { reason: "already-started" });
354
+ await client.send(h.id, value, topic);
355
+ return h;
356
+ },
325
357
  handle: (id) => wrap(id),
326
358
  close: () => client.destroy()
327
359
  };
360
+ return self;
328
361
  }
329
362
  export {
330
363
  dbosClient,
@@ -0,0 +1,2 @@
1
+ import type { StepScope } from "@iworkflow/core";
2
+ export declare const alsScope: () => StepScope;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iworkflow/dbos",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "DBOS adapter for @iworkflow/core",
5
5
  "keywords": [
6
6
  "workflow",
@@ -37,7 +37,7 @@
37
37
  "prepack": "bun run build"
38
38
  },
39
39
  "dependencies": {
40
- "@iworkflow/core": "^0.4.0",
40
+ "@iworkflow/core": "^0.6.0",
41
41
  "pg": "^8.0.0"
42
42
  },
43
43
  "peerDependencies": {
package/src/index.ts CHANGED
@@ -2,7 +2,10 @@
2
2
  // Дочерний процесс в чужом модуле — enqueueWorkflowWithOptions по имени, без ссылки на функцию.
3
3
  import { DBOS, DBOSClient, Error as DBOSErrors, type WorkflowStatus } from "@dbos-inc/dbos-sdk";
4
4
  import {
5
- bindStepRuntime,
5
+ stepScope,
6
+ type StepRuntime,
7
+ checkPriority,
8
+ DEFAULT_PRIORITY,
6
9
  exitSignal,
7
10
  Failure,
8
11
  FlowError,
@@ -14,10 +17,13 @@ import {
14
17
  type EngineRuntime,
15
18
  type FlowContext,
16
19
  type FlowHandle,
20
+ type FlowRef,
17
21
  type FlowStatus,
22
+ type StartOptions,
18
23
  type StepOptions,
19
24
  } from "@iworkflow/core";
20
25
  import { Pool } from "pg";
26
+ import { alsScope } from "./scope";
21
27
 
22
28
  // Ожидание результата дочернего процесса — опрос базы, по умолчанию раз в секунду: это и есть
23
29
  // задержка каждого перехода между модулями. DBOS_RESULT_POLL_MS уменьшает её ценой запросов к базе.
@@ -135,17 +141,22 @@ const alreadyStarted = (id: string) => (e: unknown): never => {
135
141
  // Повторы — свои, внутри одного шага DBOS: в историю пишется уже FlowError с причиной, как у Temporal.
136
142
  // Цена: отмена процесса не прерывает повторы шага, а ждёт его конца.
137
143
 
144
+ /** Предел одновременных шагов пула; ждущие выходят по приоритету (1 — первым), внутри — по очереди. */
138
145
  class Semaphore {
139
- private queue: (() => void)[] = [];
146
+ private queue: { priority: number; go: () => void }[] = [];
140
147
  constructor(private free: number) {}
141
- async run<T>(fn: () => Promise<T>): Promise<T> {
148
+ async run<T>(fn: () => Promise<T>, priority = DEFAULT_PRIORITY): Promise<T> {
142
149
  if (this.free > 0) this.free--;
143
- else await new Promise<void>((r) => this.queue.push(r));
150
+ else
151
+ await new Promise<void>((go) => {
152
+ const i = this.queue.findIndex((w) => w.priority > priority);
153
+ this.queue.splice(i < 0 ? this.queue.length : i, 0, { priority, go });
154
+ });
144
155
  try {
145
156
  return await fn();
146
157
  } finally {
147
158
  const next = this.queue.shift();
148
- if (next) next();
159
+ if (next) next.go();
149
160
  else this.free++;
150
161
  }
151
162
  }
@@ -155,6 +166,8 @@ class AttemptTimeout extends Error {}
155
166
 
156
167
  const sleepMs = (ms: number) => new Promise((r) => setTimeout(r, ms));
157
168
 
169
+ const scope = stepScope(alsScope);
170
+
158
171
  /** Попытка, урезанная по сроку: брошенная продолжает идти в фоне, её итог отбрасывается. */
159
172
  async function withTimeout<T>(p: Promise<T>, ms: number): Promise<T> {
160
173
  let timer: ReturnType<typeof setTimeout> | undefined;
@@ -175,14 +188,16 @@ async function attempts(
175
188
  opts: StepOptions,
176
189
  pool: Semaphore | undefined,
177
190
  alive: () => Promise<boolean>,
191
+ priority: number | undefined,
192
+ runtime: (n: number) => StepRuntime,
178
193
  ) {
179
194
  const max = opts.attempts ?? 3;
180
195
  const started = Date.now();
181
196
  let interval = opts.retryIntervalMs ?? 200;
182
197
  for (let n = 1; ; n++) {
183
198
  try {
184
- const attempt = () => withTimeout(fn(...args), opts.timeoutMs ?? 60_000);
185
- return await (pool ? pool.run(attempt) : attempt());
199
+ const attempt = () => scope.run(runtime(n), () => withTimeout(fn(...args), opts.timeoutMs ?? 60_000));
200
+ return await (pool ? pool.run(attempt, priority) : attempt());
186
201
  } catch (e) {
187
202
  const stop = (e instanceof Failure && e.nonRetryable) || (opts.nonRetryableTypes ?? []).includes(typeOf(e));
188
203
  if (stop || n >= max) {
@@ -195,16 +210,24 @@ async function attempts(
195
210
  exhausted,
196
211
  });
197
212
  }
198
- if (opts.totalTimeoutMs !== undefined && Date.now() - started + interval >= opts.totalTimeoutMs)
213
+ // Отказ сам назвал срок (FLOOD_WAIT) — ждём его, а не паузу из opts.
214
+ const wait = e instanceof Failure && e.retryAfterMs !== undefined ? e.retryAfterMs : interval;
215
+ if (opts.totalTimeoutMs !== undefined && Date.now() - started + wait >= opts.totalTimeoutMs)
199
216
  throw new FlowError(e instanceof Error ? e.message : String(e), {
200
217
  reason: "timeout",
201
218
  type: typeOf(e),
202
219
  details: e instanceof Failure ? e.details : undefined,
203
220
  });
204
- await sleepMs(interval);
205
- // После паузы: за неё процесс мог выйти по сроку или быть отменён.
221
+ // Пауза кусками: срок или отмена процесса видны не позже чем через 5 с, а не после всей паузы.
206
222
  // Сбой чтения статуса — не повод бросать шаг: повторяем дальше.
207
- if (!(await alive().catch(() => true))) throw new FlowError("процесс прерван — повторы шага остановлены", { reason: "terminated" });
223
+ // do…while: и при нулевой паузе статус процесса проверяется перед каждой попыткой.
224
+ let left = wait;
225
+ do {
226
+ await sleepMs(Math.min(Math.max(left, 0), 5_000));
227
+ if (!(await alive().catch(() => true)))
228
+ throw new FlowError("процесс прерван — повторы шага остановлены", { reason: "terminated" });
229
+ left -= 5_000;
230
+ } while (left > 0);
208
231
  interval = Math.min(interval * (opts.backoff ?? 1), opts.maxRetryIntervalMs ?? Infinity);
209
232
  }
210
233
  }
@@ -229,7 +252,8 @@ export function dbosRuntime(): EngineRuntime {
229
252
  pool = new Pool({ connectionString: sysDb(), max: 2 });
230
253
  const db = pool;
231
254
  const patches = patchBook(db, def.app, def.patches ?? []);
232
- bindStepRuntime({
255
+ // Рантайм попытки шага: прогресс — в iwf.heartbeat по (процесс, шаг), номер — из цикла повторов.
256
+ const stepRuntime = (n: number): StepRuntime => ({
233
257
  async heartbeat(details) {
234
258
  await db.query(
235
259
  `INSERT INTO iwf.heartbeat (workflow_id, step_id, details) VALUES ($1, $2, $3)
@@ -241,9 +265,16 @@ export function dbosRuntime(): EngineRuntime {
241
265
  const r = await db.query("SELECT details FROM iwf.heartbeat WHERE workflow_id = $1 AND step_id = $2", stepKey());
242
266
  return r.rows[0]?.details ?? undefined;
243
267
  },
268
+ attempt: () => n,
244
269
  });
245
270
  const pools = Object.fromEntries(Object.entries(def.pools ?? {}).map(([p, n]) => [p, new Semaphore(n)]));
246
- const enqueue = (target: { app: string; flow: string }, args: unknown[], opts: ChildOptions, reject: boolean) =>
271
+ const enqueue = (
272
+ target: { app: string; flow: string },
273
+ args: unknown[],
274
+ opts: ChildOptions,
275
+ reject: boolean,
276
+ priority: number | undefined,
277
+ ) =>
247
278
  DBOS.enqueueWorkflowWithOptions(
248
279
  {
249
280
  queueName: target.app,
@@ -252,6 +283,8 @@ export function dbosRuntime(): EngineRuntime {
252
283
  applicationName: target.app,
253
284
  ...(opts.timeoutMs ? { workflowTimeoutMS: opts.timeoutMs } : {}),
254
285
  ...(reject ? { workflowIDReusePolicy: "reject" as const } : {}),
286
+ // Ребёнок наследует приоритет родителя, как у Temporal.
287
+ priority: priority ?? DEFAULT_PRIORITY,
255
288
  },
256
289
  ...args,
257
290
  ).catch(alreadyStarted(opts.id));
@@ -268,7 +301,7 @@ export function dbosRuntime(): EngineRuntime {
268
301
  await patches.born(workflowId);
269
302
  // Строка статуса не меняется после создания: чтение вне шага детерминировано.
270
303
  const row = (
271
- await db.query("SELECT created_at, parent_workflow_id FROM dbos.workflow_status WHERE workflow_uuid = $1", [
304
+ await db.query("SELECT created_at, parent_workflow_id, priority FROM dbos.workflow_status WHERE workflow_uuid = $1", [
272
305
  workflowId,
273
306
  ])
274
307
  ).rows[0];
@@ -276,6 +309,8 @@ export function dbosRuntime(): EngineRuntime {
276
309
  workflowId,
277
310
  parentId: row?.parent_workflow_id ?? undefined,
278
311
  startedAt: new Date(Number(row?.created_at)),
312
+ priority: row?.priority || DEFAULT_PRIORITY,
313
+ now: () => DBOS.now(),
279
314
  step: (step, stepArgs, opts = {}) => {
280
315
  const pool = opts.pool ? pools[opts.pool] : undefined;
281
316
  if (opts.pool && !pool) throw new Error(`пул "${opts.pool}" не объявлен в AppDefinition.pools модуля ${def.app}`);
@@ -296,7 +331,7 @@ export function dbosRuntime(): EngineRuntime {
296
331
  await sleepMs(100);
297
332
  }
298
333
  };
299
- return DBOS.runStep(() => attempts(def.activities[step]!, stepArgs, opts, pool, alive), {
334
+ return DBOS.runStep(() => attempts(def.activities[step]!, stepArgs, opts, pool, alive, checkPriority(opts.priority) ?? ctx.priority, stepRuntime), {
300
335
  name: step,
301
336
  retriesAllowed: false,
302
337
  }).catch((e) => {
@@ -304,10 +339,10 @@ export function dbosRuntime(): EngineRuntime {
304
339
  }) as Promise<never>;
305
340
  },
306
341
  child: async (target, childArgs, opts) =>
307
- waitChild(await enqueue(target, childArgs, opts, opts.onExisting === "reject"), opts.id),
342
+ waitChild(await enqueue(target, childArgs, opts, opts.onExisting === "reject", ctx.priority), opts.id),
308
343
  async startChild(target, childArgs, opts) {
309
344
  // Процесс DBOS не привязан к родителю: живёт сам, как ABANDON у Temporal.
310
- const h = await enqueue(target, childArgs, opts, true);
345
+ const h = await enqueue(target, childArgs, opts, true, ctx.priority);
311
346
  return { id: opts.id, result: () => waitChild(h, opts.id) };
312
347
  },
313
348
  sleep: (ms) => DBOS.sleep(ms),
@@ -398,7 +433,7 @@ export async function dbosClient(): Promise<EngineClient> {
398
433
  await client.cancelWorkflow(id);
399
434
  },
400
435
  });
401
- return {
436
+ const self: EngineClient = {
402
437
  async start(target, args, opts) {
403
438
  const h = await client.enqueue(
404
439
  {
@@ -407,12 +442,24 @@ export async function dbosClient(): Promise<EngineClient> {
407
442
  workflowID: opts.id,
408
443
  applicationName: target.app,
409
444
  ...(opts.timeoutMs ? { workflowTimeoutMS: opts.timeoutMs } : {}),
445
+ // Без приоритета DBOS ставит 0 — впереди всех; порт по умолчанию 3, как Temporal.
446
+ priority: checkPriority(opts.priority) ?? DEFAULT_PRIORITY,
410
447
  },
411
448
  ...args,
412
449
  );
413
450
  return wrap(h.workflowID);
414
451
  },
452
+ async signalWithStart<T>(target: FlowRef, args: unknown[], opts: StartOptions, topic: string, value: unknown) {
453
+ // Не атомарно, но без потерь: сигнал ждёт в очереди темы, пока процесс до него дойдёт.
454
+ const h = await self.start<T>(target, args, opts);
455
+ // id завершённого DBOS не переиспользует: сигнал ушёл бы в никуда.
456
+ if (FINISHED.has((await status(h.id))?.status ?? ""))
457
+ throw new FlowError(`процесс ${h.id} уже завершён — id не переиспользуется`, { reason: "already-started" });
458
+ await client.send(h.id, value, topic);
459
+ return h;
460
+ },
415
461
  handle: (id) => wrap(id),
416
462
  close: () => client.destroy(),
417
463
  };
464
+ return self;
418
465
  }
package/src/scope.ts ADDED
@@ -0,0 +1,8 @@
1
+ // Область шага на AsyncLocalStorage: stepScope ядра берёт её у первого адаптера процесса.
2
+ import { AsyncLocalStorage } from "node:async_hooks";
3
+ import type { StepRuntime, StepScope } from "@iworkflow/core";
4
+
5
+ export const alsScope = (): StepScope => {
6
+ const als = new AsyncLocalStorage<StepRuntime>();
7
+ return { run: (rt, fn) => als.run(rt, fn), current: () => als.getStore() };
8
+ };