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