@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.
- package/dist/index.js +38 -12
- package/package.json +2 -2
- 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("
|
|
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(() =>
|
|
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 = () =>
|
|
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] :
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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("
|
|
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(() =>
|
|
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 = () =>
|
|
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] :
|
|
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
|
|
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 не переиспользует: сигнал ушёл бы в никуда.
|