@iworkflow/dbos 0.1.0 → 0.3.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 (2) hide show
  1. package/package.json +2 -2
  2. package/src/index.ts +233 -21
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iworkflow/dbos",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "DBOS adapter for @iworkflow/core",
5
5
  "keywords": [
6
6
  "workflow",
@@ -29,7 +29,7 @@
29
29
  "test": "bun test"
30
30
  },
31
31
  "dependencies": {
32
- "@iworkflow/core": "^0.1.0",
32
+ "@iworkflow/core": "^0.3.0",
33
33
  "pg": "^8.0.0"
34
34
  },
35
35
  "peerDependencies": {
package/src/index.ts CHANGED
@@ -1,13 +1,20 @@
1
1
  // DBOS: общая системная база Postgres, у модуля своё приложение (name) и своя очередь.
2
2
  // Дочерний процесс в чужом модуле — enqueueWorkflowWithOptions по имени, без ссылки на функцию.
3
- import { DBOS, DBOSClient } from "@dbos-inc/dbos-sdk";
3
+ import { DBOS, DBOSClient, Error as DBOSErrors, type WorkflowStatus } from "@dbos-inc/dbos-sdk";
4
4
  import {
5
5
  bindStepRuntime,
6
+ Failure,
7
+ FlowError,
8
+ isFlowError,
9
+ type Activity,
6
10
  type AppDefinition,
11
+ type ChildOptions,
7
12
  type EngineClient,
8
13
  type EngineRuntime,
9
14
  type FlowContext,
10
15
  type FlowHandle,
16
+ type FlowStatus,
17
+ type StepOptions,
11
18
  } from "@iworkflow/core";
12
19
  import { Pool } from "pg";
13
20
 
@@ -87,6 +94,132 @@ function stepKey(): [string, number] {
87
94
  return [wf, step];
88
95
  }
89
96
 
97
+ // --- Отказы ---------------------------------------------------------------------------------------
98
+ // Ошибку шага и процесса DBOS сериализует: при переигрывании класс теряется, остаются name и поля.
99
+
100
+ const typeOf = (e: unknown) => (e instanceof Failure ? e.type : e instanceof Error ? e.name : "Error");
101
+
102
+ /** FlowError после сериализации — снова FlowError: модуль проверяет instanceof. */
103
+ function revive(e: unknown): unknown {
104
+ if (e instanceof FlowError || !isFlowError(e)) return e;
105
+ return new FlowError(e.message, { reason: e.reason, type: e.type, details: e.details, exhausted: e.exhausted });
106
+ }
107
+
108
+ const CANCELLED = new DBOSErrors.DBOSAwaitedWorkflowCancelledError("").dbosErrorCode;
109
+ const isCancelled = (e: unknown) => e instanceof Error && DBOSErrors.getDBOSErrorCode(e) === CANCELLED;
110
+
111
+ /** Прерван процесс сроком или снаружи: по статусу, неизменному после конца, — ответ одинаков при переигрывании. */
112
+ const cancelReason = (st: WorkflowStatus | undefined): "timeout" | "terminated" =>
113
+ st?.deadlineEpochMS !== undefined && (st.updatedAt ?? 0) >= st.deadlineEpochMS ? "timeout" : "terminated";
114
+
115
+ /** Отказ процесса глазами ожидающего: родителя или клиента. Как у Temporal — "failed" с типом отказа. */
116
+ async function flowFailure(e: unknown, status: () => Promise<WorkflowStatus | undefined>): Promise<unknown> {
117
+ if (isCancelled(e)) {
118
+ const reason = cancelReason(await status());
119
+ return new FlowError(reason === "timeout" ? "срок процесса вышел" : "процесс прерван", { reason });
120
+ }
121
+ if (isFlowError(e))
122
+ return new FlowError(e.message, { reason: "failed", type: e.type ?? e.reason, details: e.details });
123
+ if (e instanceof Error)
124
+ return new FlowError(e.message, { reason: "failed", type: (e as Failure).type ?? e.name, details: (e as Failure).details });
125
+ return e;
126
+ }
127
+
128
+ const alreadyStarted = (id: string) => (e: unknown): never => {
129
+ if (DBOSErrors.isWorkflowIDInUseError(e)) throw new FlowError(`процесс ${id} уже есть`, { reason: "already-started" });
130
+ throw e;
131
+ };
132
+
133
+ // --- Шаг ------------------------------------------------------------------------------------------
134
+ // Повторы — свои, внутри одного шага DBOS: в историю пишется уже FlowError с причиной, как у Temporal.
135
+ // Цена: отмена процесса не прерывает повторы шага, а ждёт его конца.
136
+
137
+ class Semaphore {
138
+ private queue: (() => void)[] = [];
139
+ constructor(private free: number) {}
140
+ async run<T>(fn: () => Promise<T>): Promise<T> {
141
+ if (this.free > 0) this.free--;
142
+ else await new Promise<void>((r) => this.queue.push(r));
143
+ try {
144
+ return await fn();
145
+ } finally {
146
+ const next = this.queue.shift();
147
+ if (next) next();
148
+ else this.free++;
149
+ }
150
+ }
151
+ }
152
+
153
+ class AttemptTimeout extends Error {}
154
+
155
+ const sleepMs = (ms: number) => new Promise((r) => setTimeout(r, ms));
156
+
157
+ /** Попытка, урезанная по сроку: брошенная продолжает идти в фоне, её итог отбрасывается. */
158
+ async function withTimeout<T>(p: Promise<T>, ms: number): Promise<T> {
159
+ let timer: ReturnType<typeof setTimeout> | undefined;
160
+ try {
161
+ return await Promise.race([
162
+ p,
163
+ new Promise<never>((_, reject) => (timer = setTimeout(() => reject(new AttemptTimeout(`попытка дольше ${ms} мс`)), ms))),
164
+ ]);
165
+ } finally {
166
+ clearTimeout(timer);
167
+ }
168
+ }
169
+
170
+ /** Повторы внутри одного runStep: процесс прерван (срок, отмена) — новых попыток нет. */
171
+ async function attempts(
172
+ fn: Activity,
173
+ args: unknown[],
174
+ opts: StepOptions,
175
+ pool: Semaphore | undefined,
176
+ alive: () => Promise<boolean>,
177
+ ) {
178
+ const max = opts.attempts ?? 3;
179
+ const started = Date.now();
180
+ let interval = opts.retryIntervalMs ?? 200;
181
+ for (let n = 1; ; n++) {
182
+ try {
183
+ const attempt = () => withTimeout(fn(...args), opts.timeoutMs ?? 60_000);
184
+ return await (pool ? pool.run(attempt) : attempt());
185
+ } catch (e) {
186
+ const stop = (e instanceof Failure && e.nonRetryable) || (opts.nonRetryableTypes ?? []).includes(typeOf(e));
187
+ if (stop || n >= max) {
188
+ const exhausted = !stop;
189
+ if (e instanceof AttemptTimeout) throw new FlowError(e.message, { reason: "timeout", exhausted });
190
+ throw new FlowError(e instanceof Error ? e.message : String(e), {
191
+ reason: "failed",
192
+ type: typeOf(e),
193
+ details: e instanceof Failure ? e.details : undefined,
194
+ exhausted,
195
+ });
196
+ }
197
+ if (opts.totalTimeoutMs !== undefined && Date.now() - started + interval >= opts.totalTimeoutMs)
198
+ throw new FlowError(e instanceof Error ? e.message : String(e), {
199
+ reason: "timeout",
200
+ type: typeOf(e),
201
+ details: e instanceof Failure ? e.details : undefined,
202
+ });
203
+ await sleepMs(interval);
204
+ // После паузы: за неё процесс мог выйти по сроку или быть отменён.
205
+ // Сбой чтения статуса — не повод бросать шаг: повторяем дальше.
206
+ if (!(await alive().catch(() => true))) throw new FlowError("процесс прерван — повторы шага остановлены", { reason: "terminated" });
207
+ interval = Math.min(interval * (opts.backoff ?? 1), opts.maxRetryIntervalMs ?? Infinity);
208
+ }
209
+ }
210
+ }
211
+
212
+ const FINISHED = new Set(["SUCCESS", "ERROR", "CANCELLED", "MAX_RECOVERY_ATTEMPTS_EXCEEDED"]);
213
+
214
+ const STATUS: Record<string, FlowStatus> = {
215
+ SUCCESS: "completed",
216
+ ERROR: "failed",
217
+ MAX_RECOVERY_ATTEMPTS_EXCEEDED: "failed",
218
+ };
219
+
220
+ const describeStatus = (st: WorkflowStatus): FlowStatus =>
221
+ st.status === "CANCELLED" ? (cancelReason(st) === "timeout" ? "timed-out" : "terminated") : (STATUS[st.status] ?? "running");
222
+
90
223
  export function dbosRuntime(): EngineRuntime {
91
224
  let pool: Pool | undefined;
92
225
  return {
@@ -107,34 +240,90 @@ export function dbosRuntime(): EngineRuntime {
107
240
  return r.rows[0]?.details ?? undefined;
108
241
  },
109
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
+ };
110
263
  for (const [name, flow] of Object.entries(def.flows)) {
111
264
  const run = async (...args: unknown[]) => {
112
- await patches.born(DBOS.workflowID!);
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];
113
273
  const ctx: FlowContext = {
114
- workflowId: DBOS.workflowID!,
115
- step: (step, stepArgs, opts) => {
116
- // timeoutMs и heartbeatTimeoutMs у DBOS не действуют: шаг повторяется после рестарта процесса.
117
- const attempts = opts?.attempts ?? 3;
118
- return DBOS.runStep(() => def.activities[step]!(...stepArgs), {
274
+ 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), {
119
298
  name: step,
120
- retriesAllowed: attempts > 1,
121
- maxAttempts: attempts,
122
- intervalSeconds: 0.2,
123
- backoffRate: 1,
299
+ retriesAllowed: false,
300
+ }).catch((e) => {
301
+ throw revive(e);
124
302
  }) as Promise<never>;
125
303
  },
126
- child: async (target, childArgs, opts) => {
127
- const h = await DBOS.enqueueWorkflowWithOptions(
128
- { queueName: target.app, workflowName: target.flow, workflowID: opts.id, applicationName: target.app },
129
- ...childArgs,
130
- );
131
- return (await h.getResult(resultPoll())) as never;
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) };
132
310
  },
133
311
  sleep: (ms) => DBOS.sleep(ms),
134
312
  recv: (topic, timeoutMs) => DBOS.recv(topic, { timeoutSeconds: timeoutMs / 1000, ...resultPoll() }),
135
- signal: (workflowId, topic, value) => DBOS.send(workflowId, value, topic),
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
+ },
136
325
  setState: (key, value) => DBOS.setEvent(key, value),
137
- patched: (id) => patches.patched(DBOS.workflowID!, id),
326
+ patched: (id) => patches.patched(workflowId, id),
138
327
  // Старых процессов нет — ветка всегда новая; вызов можно удалять.
139
328
  deprecatePatch: async () => {},
140
329
  };
@@ -165,17 +354,40 @@ export function dbosRuntime(): EngineRuntime {
165
354
 
166
355
  export async function dbosClient(): Promise<EngineClient> {
167
356
  const client = await DBOSClient.create({ systemDatabaseUrl: sysDb() });
357
+ const status = (id: string) => client.getWorkflow(id);
358
+ const exists = async (id: string) => {
359
+ const st = await status(id);
360
+ if (!st) throw new FlowError(`процесса ${id} нет`, { reason: "not-found" });
361
+ return st;
362
+ };
168
363
  const wrap = <T>(id: string): FlowHandle<T> => ({
169
364
  id,
170
- result: () => client.retrieveWorkflow<T>(id).getResult(resultPoll()) as Promise<T>,
365
+ result: () =>
366
+ (client.retrieveWorkflow<T>(id).getResult(resultPoll()) as Promise<T>).catch(async (e) => {
367
+ throw await flowFailure(e, () => status(id));
368
+ }),
171
369
  signal: (topic, value) => client.send(id, value, topic),
172
370
  // Нет ожидания: query читает текущее значение, как у Temporal.
173
371
  query: async <V>(key: string) => ((await client.getEvent<V>(id, key, { timeoutSeconds: 0 })) ?? undefined),
372
+ async describe() {
373
+ const st = await exists(id);
374
+ return { id, status: describeStatus(st), startedAt: new Date(st.createdAt) };
375
+ },
376
+ async terminate() {
377
+ await exists(id);
378
+ await client.cancelWorkflow(id);
379
+ },
174
380
  });
175
381
  return {
176
382
  async start(target, args, opts) {
177
383
  const h = await client.enqueue(
178
- { queueName: target.app, workflowName: target.flow, workflowID: opts.id, applicationName: target.app },
384
+ {
385
+ queueName: target.app,
386
+ workflowName: target.flow,
387
+ workflowID: opts.id,
388
+ applicationName: target.app,
389
+ ...(opts.timeoutMs ? { workflowTimeoutMS: opts.timeoutMs } : {}),
390
+ },
179
391
  ...args,
180
392
  );
181
393
  return wrap(h.workflowID);