@iworkflow/dbos 0.6.1 → 0.8.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 +38 -12
  2. package/package.json +2 -2
  3. package/src/index.ts +44 -12
package/dist/index.js CHANGED
@@ -2,7 +2,9 @@
2
2
  import { DBOS, DBOSClient, Error as DBOSErrors } from "@dbos-inc/dbos-sdk";
3
3
  import {
4
4
  stepScope,
5
+ checkLimits,
5
6
  checkPriority,
7
+ plainAttrs,
6
8
  DEFAULT_PRIORITY,
7
9
  exitSignal,
8
10
  Failure,
@@ -69,7 +71,7 @@ function stepKey() {
69
71
  const wf = DBOS.workflowID;
70
72
  const step = DBOS.stepID;
71
73
  if (!wf || step === undefined)
72
- throw new Error("heartbeat вне шага DBOS");
74
+ throw new Error("вызов вне шага DBOS");
73
75
  return [wf, step];
74
76
  }
75
77
  var typeOf = (e) => e instanceof Failure ? e.type : e instanceof Error ? e.name : "Error";
@@ -127,12 +129,16 @@ class Semaphore {
127
129
  class AttemptTimeout extends Error {
128
130
  }
129
131
  var sleepMs = (ms) => new Promise((r) => setTimeout(r, ms));
130
- async function withTimeout(p, ms) {
132
+ async function withTimeout(p, ms, abort) {
131
133
  let timer;
132
134
  try {
133
135
  return await Promise.race([
134
136
  p,
135
- new Promise((_, reject) => timer = setTimeout(() => reject(new AttemptTimeout(`попытка дольше ${ms} мс`)), ms))
137
+ new Promise((_, reject) => timer = setTimeout(() => {
138
+ const e = new AttemptTimeout(`попытка дольше ${ms} мс`);
139
+ abort.abort(e);
140
+ reject(e);
141
+ }, ms))
136
142
  ]);
137
143
  } finally {
138
144
  clearTimeout(timer);
@@ -144,7 +150,10 @@ async function attempts(fn, args, opts, pool, alive, priority, runtime) {
144
150
  let interval = opts.retryIntervalMs ?? 200;
145
151
  for (let n = 1;; n++) {
146
152
  try {
147
- const attempt = () => stepScope(alsScope).run(runtime(n), () => withTimeout(fn(...args), opts.timeoutMs ?? 60000));
153
+ const attempt = () => {
154
+ const abort = new AbortController;
155
+ return stepScope(alsScope).run(runtime(n, abort.signal), () => withTimeout(fn(...args), opts.timeoutMs ?? 60000, abort));
156
+ };
148
157
  return await (pool ? pool.run(attempt, priority) : attempt());
149
158
  } catch (e) {
150
159
  const stop = e instanceof Failure && e.nonRetryable || (opts.nonRetryableTypes ?? []).includes(typeOf(e));
@@ -188,10 +197,11 @@ function dbosRuntime() {
188
197
  let pool;
189
198
  const exit = exitSignal();
190
199
  async function launch(def) {
200
+ checkLimits(def);
191
201
  pool = new Pool({ connectionString: sysDb(), max: 2 });
192
202
  const db = pool;
193
203
  const patches = patchBook(db, def.app, def.patches ?? []);
194
- const stepRuntime = (n) => ({
204
+ const stepRuntime = (n, signal) => ({
195
205
  async heartbeat(details) {
196
206
  await db.query(`INSERT INTO iwf.heartbeat (workflow_id, step_id, details) VALUES ($1, $2, $3)
197
207
  ON CONFLICT (workflow_id, step_id) DO UPDATE SET details = EXCLUDED.details, at = now()`, [...stepKey(), JSON.stringify(details ?? null)]);
@@ -200,9 +210,15 @@ function dbosRuntime() {
200
210
  const r = await db.query("SELECT details FROM iwf.heartbeat WHERE workflow_id = $1 AND step_id = $2", stepKey());
201
211
  return r.rows[0]?.details ?? undefined;
202
212
  },
203
- attempt: () => n
213
+ attempt: () => n,
214
+ info: () => {
215
+ const [wf, step] = stepKey();
216
+ return { workflowId: wf, runId: wf, stepId: String(step) };
217
+ },
218
+ signal: () => signal
204
219
  });
205
220
  const pools = Object.fromEntries(Object.entries(def.pools ?? {}).map(([p, n]) => [p, new Semaphore(n)]));
221
+ const main = def.maxConcurrentSteps ? new Semaphore(def.maxConcurrentSteps) : undefined;
206
222
  const enqueue = (target, args, opts, reject, priority) => DBOS.enqueueWorkflowWithOptions({
207
223
  queueName: target.app,
208
224
  workflowName: target.flow,
@@ -233,7 +249,7 @@ function dbosRuntime() {
233
249
  priority: row?.priority || DEFAULT_PRIORITY,
234
250
  now: () => DBOS.now(),
235
251
  step: (step, stepArgs, opts = {}) => {
236
- const pool = opts.pool ? pools[opts.pool] : undefined;
252
+ const pool = opts.pool ? pools[opts.pool] : main;
237
253
  if (opts.pool && !pool)
238
254
  throw new Error(`пул "${opts.pool}" не объявлен в AppDefinition.pools модуля ${def.app}`);
239
255
  const alive = async () => {
@@ -268,6 +284,10 @@ function dbosRuntime() {
268
284
  throw new FlowError(`процесса ${target} нет или он завершён`, { reason: "not-found" });
269
285
  await DBOS.send(target, value, topic);
270
286
  },
287
+ log: async (level, message, attrs) => {
288
+ const plain = plainAttrs(attrs);
289
+ console[level](message, ...plain ? [plain] : []);
290
+ },
271
291
  setState: (key, value) => DBOS.setEvent(key, value),
272
292
  patched: (id) => patches.patched(workflowId, id),
273
293
  deprecatePatch: async () => {}
@@ -286,15 +306,20 @@ function dbosRuntime() {
286
306
  const poll = Number(process.env.DBOS_QUEUE_POLL_MS) || undefined;
287
307
  await DBOS.registerQueue(def.app, { minPollingIntervalMs: poll, onConflict: "always_update" });
288
308
  }
309
+ const closePool = async () => {
310
+ await pool?.end().catch(() => {});
311
+ pool = undefined;
312
+ };
289
313
  return {
290
314
  done: () => exit.promise(),
291
315
  async start(def) {
316
+ if (pool)
317
+ throw new Error("рантайм уже запущен");
292
318
  exit.arm();
293
319
  try {
294
320
  await launch(def);
295
321
  } catch (e) {
296
- await pool?.end().catch(() => {});
297
- pool = undefined;
322
+ await closePool();
298
323
  exit.reject(e);
299
324
  throw e;
300
325
  }
@@ -302,8 +327,8 @@ function dbosRuntime() {
302
327
  async stop() {
303
328
  try {
304
329
  await DBOS.shutdown();
305
- await pool?.end();
306
330
  } finally {
331
+ await closePool();
307
332
  exit.resolve();
308
333
  }
309
334
  }
@@ -342,8 +367,9 @@ async function dbosClient() {
342
367
  workflowID: opts.id,
343
368
  applicationName: target.app,
344
369
  ...opts.timeoutMs ? { workflowTimeoutMS: opts.timeoutMs } : {},
345
- priority: checkPriority(opts.priority) ?? DEFAULT_PRIORITY
346
- }, ...args);
370
+ priority: checkPriority(opts.priority) ?? DEFAULT_PRIORITY,
371
+ ...opts.onExisting === "reject" || opts.reuseClosedId ? { workflowIDReusePolicy: "reject" } : {}
372
+ }, ...args).catch(alreadyStarted(opts.id));
347
373
  return wrap(h.workflowID);
348
374
  },
349
375
  async signalWithStart(target, args, opts, topic, value) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iworkflow/dbos",
3
- "version": "0.6.1",
3
+ "version": "0.8.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.6.1",
40
+ "@iworkflow/core": "^0.8.0",
41
41
  "pg": "^8.0.0"
42
42
  },
43
43
  "peerDependencies": {
package/src/index.ts CHANGED
@@ -4,7 +4,9 @@ import { DBOS, DBOSClient, Error as DBOSErrors, type WorkflowStatus } from "@dbo
4
4
  import {
5
5
  stepScope,
6
6
  type StepRuntime,
7
+ checkLimits,
7
8
  checkPriority,
9
+ plainAttrs,
8
10
  DEFAULT_PRIORITY,
9
11
  exitSignal,
10
12
  Failure,
@@ -97,7 +99,7 @@ function patchBook(db: Pool, app: string, declared: string[]) {
97
99
  function stepKey(): [string, number] {
98
100
  const wf = DBOS.workflowID;
99
101
  const step = DBOS.stepID;
100
- if (!wf || step === undefined) throw new Error("heartbeat вне шага DBOS");
102
+ if (!wf || step === undefined) throw new Error("вызов вне шага DBOS");
101
103
  return [wf, step];
102
104
  }
103
105
 
@@ -169,12 +171,17 @@ const sleepMs = (ms: number) => new Promise((r) => setTimeout(r, ms));
169
171
 
170
172
 
171
173
  /** Попытка, урезанная по сроку: брошенная продолжает идти в фоне, её итог отбрасывается. */
172
- async function withTimeout<T>(p: Promise<T>, ms: number): Promise<T> {
174
+ async function withTimeout<T>(p: Promise<T>, ms: number, abort: AbortController): Promise<T> {
173
175
  let timer: ReturnType<typeof setTimeout> | undefined;
174
176
  try {
175
177
  return await Promise.race([
176
178
  p,
177
- new Promise<never>((_, reject) => (timer = setTimeout(() => reject(new AttemptTimeout(`попытка дольше ${ms} мс`)), ms))),
179
+ new Promise<never>((_, reject) => (timer = setTimeout(() => {
180
+ const e = new AttemptTimeout(`попытка дольше ${ms} мс`);
181
+ // Брошенная попытка видит отмену через stepSignal() и может остановиться сама.
182
+ abort.abort(e);
183
+ reject(e);
184
+ }, ms))),
178
185
  ]);
179
186
  } finally {
180
187
  clearTimeout(timer);
@@ -189,14 +196,17 @@ async function attempts(
189
196
  pool: Semaphore | undefined,
190
197
  alive: () => Promise<boolean>,
191
198
  priority: number | undefined,
192
- runtime: (n: number) => StepRuntime,
199
+ runtime: (n: number, signal: AbortSignal) => StepRuntime,
193
200
  ) {
194
201
  const max = opts.attempts ?? 3;
195
202
  const started = Date.now();
196
203
  let interval = opts.retryIntervalMs ?? 200;
197
204
  for (let n = 1; ; n++) {
198
205
  try {
199
- const attempt = () => stepScope(alsScope).run(runtime(n), () => withTimeout(fn(...args), opts.timeoutMs ?? 60_000));
206
+ const attempt = () => {
207
+ const abort = new AbortController();
208
+ return stepScope(alsScope).run(runtime(n, abort.signal), () => withTimeout(fn(...args), opts.timeoutMs ?? 60_000, abort));
209
+ };
200
210
  return await (pool ? pool.run(attempt, priority) : attempt());
201
211
  } catch (e) {
202
212
  const stop = (e instanceof Failure && e.nonRetryable) || (opts.nonRetryableTypes ?? []).includes(typeOf(e));
@@ -249,11 +259,12 @@ export function dbosRuntime(): EngineRuntime {
249
259
  // Воркеров у DBOS нет: done() — до stop или сбоя start; падения исполнителя DBOS не видно.
250
260
  const exit = exitSignal();
251
261
  async function launch(def: AppDefinition) {
262
+ checkLimits(def);
252
263
  pool = new Pool({ connectionString: sysDb(), max: 2 });
253
264
  const db = pool;
254
265
  const patches = patchBook(db, def.app, def.patches ?? []);
255
266
  // Рантайм попытки шага: прогресс — в iwf.heartbeat по (процесс, шаг), номер — из цикла повторов.
256
- const stepRuntime = (n: number): StepRuntime => ({
267
+ const stepRuntime = (n: number, signal: AbortSignal): StepRuntime => ({
257
268
  async heartbeat(details) {
258
269
  await db.query(
259
270
  `INSERT INTO iwf.heartbeat (workflow_id, step_id, details) VALUES ($1, $2, $3)
@@ -266,8 +277,16 @@ export function dbosRuntime(): EngineRuntime {
266
277
  return r.rows[0]?.details ?? undefined;
267
278
  },
268
279
  attempt: () => n,
280
+ // id DBOS не переиспользуется: запуск у процесса один.
281
+ info: () => {
282
+ const [wf, step] = stepKey();
283
+ return { workflowId: wf, runId: wf, stepId: String(step) };
284
+ },
285
+ signal: () => signal,
269
286
  });
270
287
  const pools = Object.fromEntries(Object.entries(def.pools ?? {}).map(([p, n]) => [p, new Semaphore(n)]));
288
+ // Шаги вне пулов — под общим пределом модуля, как maxConcurrentActivityTaskExecutions у Temporal.
289
+ const main = def.maxConcurrentSteps ? new Semaphore(def.maxConcurrentSteps) : undefined;
271
290
  const enqueue = (
272
291
  target: { app: string; flow: string },
273
292
  args: unknown[],
@@ -312,7 +331,7 @@ export function dbosRuntime(): EngineRuntime {
312
331
  priority: row?.priority || DEFAULT_PRIORITY,
313
332
  now: () => DBOS.now(),
314
333
  step: (step, stepArgs, opts = {}) => {
315
- const pool = opts.pool ? pools[opts.pool] : undefined;
334
+ const pool = opts.pool ? pools[opts.pool] : main;
316
335
  if (opts.pool && !pool) throw new Error(`пул "${opts.pool}" не объявлен в AppDefinition.pools модуля ${def.app}`);
317
336
  // heartbeatTimeoutMs у DBOS не действует: умерший процесс ловится рестартом, шаг повторяется.
318
337
  // Срок вышел, а DBOS ещё не отменил процесс — ждём отмены: иначе процесс поймает отказ и завершится успехом.
@@ -359,6 +378,12 @@ export function dbosRuntime(): EngineRuntime {
359
378
  throw new FlowError(`процесса ${target} нет или он завершён`, { reason: "not-found" });
360
379
  await DBOS.send(target, value, topic);
361
380
  },
381
+ // Не шагом: строка журнала не сдвигает нумерацию шагов. Цена — повтор строки при восстановлении после рестарта.
382
+ log: async (level, message, attrs) => {
383
+ const plain = plainAttrs(attrs);
384
+ // console, а не DBOS.logger: у того logLevel "warn" — info молча пропал бы.
385
+ console[level](message, ...(plain ? [plain] : []));
386
+ },
362
387
  setState: (key, value) => DBOS.setEvent(key, value),
363
388
  patched: (id) => patches.patched(workflowId, id),
364
389
  // Старых процессов нет — ветка всегда новая; вызов можно удалять.
@@ -382,16 +407,21 @@ export function dbosRuntime(): EngineRuntime {
382
407
  const poll = Number(process.env.DBOS_QUEUE_POLL_MS) || undefined;
383
408
  await DBOS.registerQueue(def.app, { minPollingIntervalMs: poll, onConflict: "always_update" });
384
409
  }
410
+ const closePool = async () => {
411
+ await pool?.end().catch(() => {});
412
+ pool = undefined;
413
+ };
385
414
  return {
386
415
  done: () => exit.promise(),
387
416
  async start(def: AppDefinition) {
417
+ // Второй start не трогает пул и done() идущего: его сбой закрыл бы живое соединение.
418
+ if (pool) throw new Error("рантайм уже запущен");
388
419
  exit.arm();
389
420
  // Не запустился — done() отклоняется: для наблюдателя это сбой, а не штатный выход.
390
421
  try {
391
422
  await launch(def);
392
423
  } catch (e) {
393
- await pool?.end().catch(() => {});
394
- pool = undefined;
424
+ await closePool();
395
425
  exit.reject(e);
396
426
  throw e;
397
427
  }
@@ -399,8 +429,9 @@ export function dbosRuntime(): EngineRuntime {
399
429
  async stop() {
400
430
  try {
401
431
  await DBOS.shutdown();
402
- await pool?.end();
403
432
  } finally {
433
+ // Пул закрывается и при сбое shutdown: иначе соединения копятся от цикла к циклу.
434
+ await closePool();
404
435
  exit.resolve();
405
436
  }
406
437
  },
@@ -444,12 +475,13 @@ export async function dbosClient(): Promise<EngineClient> {
444
475
  ...(opts.timeoutMs ? { workflowTimeoutMS: opts.timeoutMs } : {}),
445
476
  // Без приоритета DBOS ставит 0 — впереди всех; порт по умолчанию 3, как Temporal.
446
477
  priority: checkPriority(opts.priority) ?? DEFAULT_PRIORITY,
478
+ ...(opts.onExisting === "reject" || opts.reuseClosedId ? { workflowIDReusePolicy: "reject" as const } : {}),
447
479
  },
448
480
  ...args,
449
- );
481
+ ).catch(alreadyStarted(opts.id));
450
482
  return wrap(h.workflowID);
451
483
  },
452
- async signalWithStart<T>(target: FlowRef, args: unknown[], opts: StartOptions, topic: string, value: unknown) {
484
+ async signalWithStart<T>(target: FlowRef, args: unknown[], opts: Omit<StartOptions, "onExisting" | "reuseClosedId">, topic: string, value: unknown) {
453
485
  // Не атомарно, но без потерь: сигнал ждёт в очереди темы, пока процесс до него дойдёт.
454
486
  const h = await self.start<T>(target, args, opts);
455
487
  // id завершённого DBOS не переиспользует: сигнал ушёл бы в никуда.