@iworkflow/dbos 0.1.0 → 0.2.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 +207 -21
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iworkflow/dbos",
3
- "version": "0.1.0",
3
+ "version": "0.2.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.2.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,122 @@ 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
+ async function attempts(fn: Activity, args: unknown[], opts: StepOptions, pool: Semaphore | undefined) {
171
+ const max = opts.attempts ?? 3;
172
+ const started = Date.now();
173
+ let interval = opts.retryIntervalMs ?? 200;
174
+ for (let n = 1; ; n++) {
175
+ try {
176
+ const attempt = () => withTimeout(fn(...args), opts.timeoutMs ?? 60_000);
177
+ return await (pool ? pool.run(attempt) : attempt());
178
+ } catch (e) {
179
+ const stop = (e instanceof Failure && e.nonRetryable) || (opts.nonRetryableTypes ?? []).includes(typeOf(e));
180
+ if (stop || n >= max) {
181
+ const exhausted = !stop;
182
+ if (e instanceof AttemptTimeout) throw new FlowError(e.message, { reason: "timeout", exhausted });
183
+ throw new FlowError(e instanceof Error ? e.message : String(e), {
184
+ reason: "failed",
185
+ type: typeOf(e),
186
+ details: e instanceof Failure ? e.details : undefined,
187
+ exhausted,
188
+ });
189
+ }
190
+ if (opts.totalTimeoutMs !== undefined && Date.now() - started + interval >= opts.totalTimeoutMs)
191
+ throw new FlowError(e instanceof Error ? e.message : String(e), {
192
+ reason: "timeout",
193
+ type: typeOf(e),
194
+ details: e instanceof Failure ? e.details : undefined,
195
+ });
196
+ await sleepMs(interval);
197
+ interval = Math.min(interval * (opts.backoff ?? 1), opts.maxRetryIntervalMs ?? Infinity);
198
+ }
199
+ }
200
+ }
201
+
202
+ const FINISHED = new Set(["SUCCESS", "ERROR", "CANCELLED", "MAX_RECOVERY_ATTEMPTS_EXCEEDED"]);
203
+
204
+ const STATUS: Record<string, FlowStatus> = {
205
+ SUCCESS: "completed",
206
+ ERROR: "failed",
207
+ MAX_RECOVERY_ATTEMPTS_EXCEEDED: "failed",
208
+ };
209
+
210
+ const describeStatus = (st: WorkflowStatus): FlowStatus =>
211
+ st.status === "CANCELLED" ? (cancelReason(st) === "timeout" ? "timed-out" : "terminated") : (STATUS[st.status] ?? "running");
212
+
90
213
  export function dbosRuntime(): EngineRuntime {
91
214
  let pool: Pool | undefined;
92
215
  return {
@@ -107,34 +230,74 @@ export function dbosRuntime(): EngineRuntime {
107
230
  return r.rows[0]?.details ?? undefined;
108
231
  },
109
232
  });
233
+ const pools = Object.fromEntries(Object.entries(def.pools ?? {}).map(([p, n]) => [p, new Semaphore(n)]));
234
+ const enqueue = (target: { app: string; flow: string }, args: unknown[], opts: ChildOptions, reject: boolean) =>
235
+ DBOS.enqueueWorkflowWithOptions(
236
+ {
237
+ queueName: target.app,
238
+ workflowName: target.flow,
239
+ workflowID: opts.id,
240
+ applicationName: target.app,
241
+ ...(opts.timeoutMs ? { workflowTimeoutMS: opts.timeoutMs } : {}),
242
+ ...(reject ? { workflowIDReusePolicy: "reject" as const } : {}),
243
+ },
244
+ ...args,
245
+ ).catch(alreadyStarted(opts.id));
246
+ const waitChild = async <T>(h: { getResult(o?: object): Promise<unknown> }, id: string): Promise<T> => {
247
+ try {
248
+ return (await h.getResult(resultPoll())) as T;
249
+ } catch (e) {
250
+ throw await flowFailure(e, () => DBOS.getWorkflowStatus(id).then((st) => st ?? undefined));
251
+ }
252
+ };
110
253
  for (const [name, flow] of Object.entries(def.flows)) {
111
254
  const run = async (...args: unknown[]) => {
112
- await patches.born(DBOS.workflowID!);
255
+ const workflowId = DBOS.workflowID!;
256
+ await patches.born(workflowId);
257
+ // Строка статуса не меняется после создания: чтение вне шага детерминировано.
258
+ const row = (
259
+ await db.query("SELECT created_at, parent_workflow_id FROM dbos.workflow_status WHERE workflow_uuid = $1", [
260
+ workflowId,
261
+ ])
262
+ ).rows[0];
113
263
  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), {
264
+ workflowId,
265
+ parentId: row?.parent_workflow_id ?? undefined,
266
+ startedAt: new Date(Number(row?.created_at)),
267
+ step: (step, stepArgs, opts = {}) => {
268
+ const pool = opts.pool ? pools[opts.pool] : undefined;
269
+ if (opts.pool && !pool) throw new Error(`пул "${opts.pool}" не объявлен в AppDefinition.pools модуля ${def.app}`);
270
+ // heartbeatTimeoutMs у DBOS не действует: умерший процесс ловится рестартом, шаг повторяется.
271
+ return DBOS.runStep(() => attempts(def.activities[step]!, stepArgs, opts, pool), {
119
272
  name: step,
120
- retriesAllowed: attempts > 1,
121
- maxAttempts: attempts,
122
- intervalSeconds: 0.2,
123
- backoffRate: 1,
273
+ retriesAllowed: false,
274
+ }).catch((e) => {
275
+ throw revive(e);
124
276
  }) as Promise<never>;
125
277
  },
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;
278
+ child: async (target, childArgs, opts) =>
279
+ waitChild(await enqueue(target, childArgs, opts, opts.onExisting === "reject"), opts.id),
280
+ async startChild(target, childArgs, opts) {
281
+ // Процесс DBOS не привязан к родителю: живёт сам, как ABANDON у Temporal.
282
+ const h = await enqueue(target, childArgs, opts, true);
283
+ return { id: opts.id, result: () => waitChild(h, opts.id) };
132
284
  },
133
285
  sleep: (ms) => DBOS.sleep(ms),
134
286
  recv: (topic, timeoutMs) => DBOS.recv(topic, { timeoutSeconds: timeoutMs / 1000, ...resultPoll() }),
135
- signal: (workflowId, topic, value) => DBOS.send(workflowId, value, topic),
287
+ async signal(target, topic, value) {
288
+ // Отправка завершённому процессу у DBOS проходит молча; Temporal отказывает — проверяем сами.
289
+ const status = await DBOS.runStep(
290
+ async () =>
291
+ ((await db.query("SELECT status FROM dbos.workflow_status WHERE workflow_uuid = $1", [target])).rows[0]
292
+ ?.status as string | undefined) ?? null,
293
+ { name: "iwf.signalTarget" },
294
+ );
295
+ if (status === null || FINISHED.has(status))
296
+ throw new FlowError(`процесса ${target} нет или он завершён`, { reason: "not-found" });
297
+ await DBOS.send(target, value, topic);
298
+ },
136
299
  setState: (key, value) => DBOS.setEvent(key, value),
137
- patched: (id) => patches.patched(DBOS.workflowID!, id),
300
+ patched: (id) => patches.patched(workflowId, id),
138
301
  // Старых процессов нет — ветка всегда новая; вызов можно удалять.
139
302
  deprecatePatch: async () => {},
140
303
  };
@@ -165,17 +328,40 @@ export function dbosRuntime(): EngineRuntime {
165
328
 
166
329
  export async function dbosClient(): Promise<EngineClient> {
167
330
  const client = await DBOSClient.create({ systemDatabaseUrl: sysDb() });
331
+ const status = (id: string) => client.getWorkflow(id);
332
+ const exists = async (id: string) => {
333
+ const st = await status(id);
334
+ if (!st) throw new FlowError(`процесса ${id} нет`, { reason: "not-found" });
335
+ return st;
336
+ };
168
337
  const wrap = <T>(id: string): FlowHandle<T> => ({
169
338
  id,
170
- result: () => client.retrieveWorkflow<T>(id).getResult(resultPoll()) as Promise<T>,
339
+ result: () =>
340
+ (client.retrieveWorkflow<T>(id).getResult(resultPoll()) as Promise<T>).catch(async (e) => {
341
+ throw await flowFailure(e, () => status(id));
342
+ }),
171
343
  signal: (topic, value) => client.send(id, value, topic),
172
344
  // Нет ожидания: query читает текущее значение, как у Temporal.
173
345
  query: async <V>(key: string) => ((await client.getEvent<V>(id, key, { timeoutSeconds: 0 })) ?? undefined),
346
+ async describe() {
347
+ const st = await exists(id);
348
+ return { id, status: describeStatus(st), startedAt: new Date(st.createdAt) };
349
+ },
350
+ async terminate() {
351
+ await exists(id);
352
+ await client.cancelWorkflow(id);
353
+ },
174
354
  });
175
355
  return {
176
356
  async start(target, args, opts) {
177
357
  const h = await client.enqueue(
178
- { queueName: target.app, workflowName: target.flow, workflowID: opts.id, applicationName: target.app },
358
+ {
359
+ queueName: target.app,
360
+ workflowName: target.flow,
361
+ workflowID: opts.id,
362
+ applicationName: target.app,
363
+ ...(opts.timeoutMs ? { workflowTimeoutMS: opts.timeoutMs } : {}),
364
+ },
179
365
  ...args,
180
366
  );
181
367
  return wrap(h.workflowID);