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