@iworkflow/dbos 0.0.0-stage → 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.
- package/README.md +10 -2
- package/package.json +40 -4
- package/src/index.ts +372 -0
package/README.md
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @iworkflow/dbos
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
DBOS для `@iworkflow/core`. Peer: `@dbos-inc/dbos-sdk` ^5.3; зависимость `pg`.
|
|
4
|
+
|
|
5
|
+
- `dbosRuntime().start(def)` — приложение `def.app`, очередь `def.app`, версия `<app>-patching`.
|
|
6
|
+
- `dbosClient()` — старт, сигнал, query по id.
|
|
7
|
+
- Модули, зовущие друг друга, делят одну системную базу (`DBOS_SYSTEM_DATABASE_URL`).
|
|
8
|
+
- Своё в схеме `iwf`: `heartbeat` (прогресс шага), `birth` (патчи кода, начавшего процесс).
|
|
9
|
+
- Запуск модулей — под advisory-замком: миграции DBOS на пустой базе иначе ловят deadlock.
|
|
10
|
+
|
|
11
|
+
Переменные: `DBOS_SYSTEM_DATABASE_URL`, `DBOS_QUEUE_POLL_MS`, `DBOS_RESULT_POLL_MS`, `DBOS_APPLICATION_VERSION`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,42 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@iworkflow/dbos",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "DBOS adapter for @iworkflow/core",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"workflow",
|
|
7
|
+
"dbos",
|
|
8
|
+
"postgres"
|
|
9
|
+
],
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/iconicompany/iworkflow.git",
|
|
14
|
+
"directory": "packages/dbos"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"src"
|
|
18
|
+
],
|
|
19
|
+
"type": "module",
|
|
20
|
+
"sideEffects": false,
|
|
21
|
+
"exports": {
|
|
22
|
+
".": "./src/index.ts"
|
|
23
|
+
},
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"type-check": "tsc --noEmit -p tsconfig.json",
|
|
29
|
+
"test": "bun test"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@iworkflow/core": "^0.2.0",
|
|
33
|
+
"pg": "^8.0.0"
|
|
34
|
+
},
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"@dbos-inc/dbos-sdk": "^5.3.0"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@dbos-inc/dbos-sdk": "^5.3.0",
|
|
40
|
+
"@types/pg": "^8.0.0"
|
|
41
|
+
}
|
|
42
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
// DBOS: общая системная база Postgres, у модуля своё приложение (name) и своя очередь.
|
|
2
|
+
// Дочерний процесс в чужом модуле — enqueueWorkflowWithOptions по имени, без ссылки на функцию.
|
|
3
|
+
import { DBOS, DBOSClient, Error as DBOSErrors, type WorkflowStatus } from "@dbos-inc/dbos-sdk";
|
|
4
|
+
import {
|
|
5
|
+
bindStepRuntime,
|
|
6
|
+
Failure,
|
|
7
|
+
FlowError,
|
|
8
|
+
isFlowError,
|
|
9
|
+
type Activity,
|
|
10
|
+
type AppDefinition,
|
|
11
|
+
type ChildOptions,
|
|
12
|
+
type EngineClient,
|
|
13
|
+
type EngineRuntime,
|
|
14
|
+
type FlowContext,
|
|
15
|
+
type FlowHandle,
|
|
16
|
+
type FlowStatus,
|
|
17
|
+
type StepOptions,
|
|
18
|
+
} from "@iworkflow/core";
|
|
19
|
+
import { Pool } from "pg";
|
|
20
|
+
|
|
21
|
+
// Ожидание результата дочернего процесса — опрос базы, по умолчанию раз в секунду: это и есть
|
|
22
|
+
// задержка каждого перехода между модулями. DBOS_RESULT_POLL_MS уменьшает её ценой запросов к базе.
|
|
23
|
+
const resultPoll = () => ({ pollingIntervalMs: Number(process.env.DBOS_RESULT_POLL_MS) || undefined });
|
|
24
|
+
|
|
25
|
+
const sysDb = () =>
|
|
26
|
+
process.env.DBOS_SYSTEM_DATABASE_URL ?? "postgres://iworkflow:iworkflow@localhost:55432/iworkflow";
|
|
27
|
+
|
|
28
|
+
// У DBOS нет heartbeat: прогресс шага — строка в системной базе по (workflow, номер шага).
|
|
29
|
+
// Номер шага детерминирован, поэтому повтор после рестарта читает прогресс своей прошлой попытки.
|
|
30
|
+
const HEARTBEAT_DDL = [
|
|
31
|
+
"CREATE SCHEMA IF NOT EXISTS iwf",
|
|
32
|
+
`CREATE TABLE IF NOT EXISTS iwf.birth (
|
|
33
|
+
workflow_id text PRIMARY KEY, app text NOT NULL, patches text[] NOT NULL, at timestamptz NOT NULL DEFAULT now())`,
|
|
34
|
+
`CREATE TABLE IF NOT EXISTS iwf.heartbeat (
|
|
35
|
+
workflow_id text NOT NULL, step_id integer NOT NULL, details jsonb, at timestamptz NOT NULL DEFAULT now(),
|
|
36
|
+
PRIMARY KEY (workflow_id, step_id))`,
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
// Модули стартуют разом на одной системной базе: миграции DBOS на пустой базе ловят deadlock и
|
|
40
|
+
// "tuple concurrently updated", IF NOT EXISTS — гонку. Запуск целиком — под общим advisory-замком.
|
|
41
|
+
// Ждать замок — опросом pg_try_advisory_lock: висящий pg_advisory_lock держит транзакцию, и
|
|
42
|
+
// CREATE INDEX CONCURRENTLY из миграций DBOS ждёт его вечно.
|
|
43
|
+
const LAUNCH_LOCK = "hashtext('iworkflow/dbos launch')";
|
|
44
|
+
|
|
45
|
+
async function underLaunchLock<T>(pool: Pool, fn: () => Promise<T>): Promise<T> {
|
|
46
|
+
const c = await pool.connect();
|
|
47
|
+
try {
|
|
48
|
+
while (!(await c.query(`SELECT pg_try_advisory_lock(${LAUNCH_LOCK}) AS ok`)).rows[0].ok) await Bun.sleep(200);
|
|
49
|
+
for (const sql of HEARTBEAT_DDL) await c.query(sql);
|
|
50
|
+
return await fn();
|
|
51
|
+
} finally {
|
|
52
|
+
await c.query(`SELECT pg_advisory_unlock(${LAUNCH_LOCK})`).catch(() => {});
|
|
53
|
+
c.release();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// patched без DBOS.patch: тот смотрит слот текущей операции, а у процесса, ждущего в recv, слот ещё
|
|
58
|
+
// пуст — старый процесс уходит в новую ветку и ломает детерминизм на следующем шаге.
|
|
59
|
+
// Здесь при первом исполнении процесса записывается список патчей кода, который его начал
|
|
60
|
+
// (iwf.birth), и patched отвечает по нему: ответ одинаков при каждом переигрывании, номер операции
|
|
61
|
+
// не тратится, откат и повторная выкатка версии не путают ветки.
|
|
62
|
+
function patchBook(db: Pool, app: string, declared: string[]) {
|
|
63
|
+
const births = new Map<string, Set<string>>();
|
|
64
|
+
return {
|
|
65
|
+
async patched(workflowId: string, id: string) {
|
|
66
|
+
if (!declared.includes(id)) throw new Error(`патч "${id}" не объявлен в AppDefinition.patches модуля ${app}`);
|
|
67
|
+
let set = births.get(workflowId);
|
|
68
|
+
if (!set) {
|
|
69
|
+
await db.query(
|
|
70
|
+
"INSERT INTO iwf.birth (workflow_id, app, patches) VALUES ($1, $2, $3) ON CONFLICT (workflow_id) DO NOTHING",
|
|
71
|
+
[workflowId, app, declared],
|
|
72
|
+
);
|
|
73
|
+
const r = await db.query("SELECT patches FROM iwf.birth WHERE workflow_id = $1", [workflowId]);
|
|
74
|
+
set = new Set(r.rows[0].patches as string[]);
|
|
75
|
+
births.set(workflowId, set);
|
|
76
|
+
}
|
|
77
|
+
return set.has(id);
|
|
78
|
+
},
|
|
79
|
+
/** Первое исполнение — запомнить патчи кода. Вызывается до первой операции процесса. */
|
|
80
|
+
async born(workflowId: string) {
|
|
81
|
+
if (births.has(workflowId)) return;
|
|
82
|
+
await db.query(
|
|
83
|
+
"INSERT INTO iwf.birth (workflow_id, app, patches) VALUES ($1, $2, $3) ON CONFLICT (workflow_id) DO NOTHING",
|
|
84
|
+
[workflowId, app, declared],
|
|
85
|
+
);
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function stepKey(): [string, number] {
|
|
91
|
+
const wf = DBOS.workflowID;
|
|
92
|
+
const step = DBOS.stepID;
|
|
93
|
+
if (!wf || step === undefined) throw new Error("heartbeat вне шага DBOS");
|
|
94
|
+
return [wf, step];
|
|
95
|
+
}
|
|
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
|
+
|
|
213
|
+
export function dbosRuntime(): EngineRuntime {
|
|
214
|
+
let pool: Pool | undefined;
|
|
215
|
+
return {
|
|
216
|
+
async start(def: AppDefinition) {
|
|
217
|
+
pool = new Pool({ connectionString: sysDb(), max: 2 });
|
|
218
|
+
const db = pool;
|
|
219
|
+
const patches = patchBook(db, def.app, def.patches ?? []);
|
|
220
|
+
bindStepRuntime({
|
|
221
|
+
async heartbeat(details) {
|
|
222
|
+
await db.query(
|
|
223
|
+
`INSERT INTO iwf.heartbeat (workflow_id, step_id, details) VALUES ($1, $2, $3)
|
|
224
|
+
ON CONFLICT (workflow_id, step_id) DO UPDATE SET details = EXCLUDED.details, at = now()`,
|
|
225
|
+
[...stepKey(), JSON.stringify(details ?? null)],
|
|
226
|
+
);
|
|
227
|
+
},
|
|
228
|
+
async lastHeartbeat() {
|
|
229
|
+
const r = await db.query("SELECT details FROM iwf.heartbeat WHERE workflow_id = $1 AND step_id = $2", stepKey());
|
|
230
|
+
return r.rows[0]?.details ?? undefined;
|
|
231
|
+
},
|
|
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
|
+
};
|
|
253
|
+
for (const [name, flow] of Object.entries(def.flows)) {
|
|
254
|
+
const run = async (...args: unknown[]) => {
|
|
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];
|
|
263
|
+
const ctx: FlowContext = {
|
|
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), {
|
|
272
|
+
name: step,
|
|
273
|
+
retriesAllowed: false,
|
|
274
|
+
}).catch((e) => {
|
|
275
|
+
throw revive(e);
|
|
276
|
+
}) as Promise<never>;
|
|
277
|
+
},
|
|
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) };
|
|
284
|
+
},
|
|
285
|
+
sleep: (ms) => DBOS.sleep(ms),
|
|
286
|
+
recv: (topic, timeoutMs) => DBOS.recv(topic, { timeoutSeconds: timeoutMs / 1000, ...resultPoll() }),
|
|
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
|
+
},
|
|
299
|
+
setState: (key, value) => DBOS.setEvent(key, value),
|
|
300
|
+
patched: (id) => patches.patched(workflowId, id),
|
|
301
|
+
// Старых процессов нет — ветка всегда новая; вызов можно удалять.
|
|
302
|
+
deprecatePatch: async () => {},
|
|
303
|
+
};
|
|
304
|
+
return flow(ctx, ...args);
|
|
305
|
+
};
|
|
306
|
+
DBOS.registerWorkflow(run, { name });
|
|
307
|
+
}
|
|
308
|
+
// Версия постоянна, ветки кода — patched. Без этого версия — хэш кода, и после выкатки новый
|
|
309
|
+
// процесс не подхватывает незавершённые процессы старого. Своя на модуль: в общей системной
|
|
310
|
+
// базе DBOS требует уникальных имён версий.
|
|
311
|
+
DBOS.setConfig({
|
|
312
|
+
name: def.app,
|
|
313
|
+
systemDatabaseUrl: sysDb(),
|
|
314
|
+
logLevel: "warn",
|
|
315
|
+
applicationVersion: process.env.DBOS_APPLICATION_VERSION ?? `${def.app}-patching`,
|
|
316
|
+
});
|
|
317
|
+
await underLaunchLock(db, () => DBOS.launch());
|
|
318
|
+
// Опрос очереди — главный вклад в задержку перехода между модулями (по умолчанию ~1 с).
|
|
319
|
+
const poll = Number(process.env.DBOS_QUEUE_POLL_MS) || undefined;
|
|
320
|
+
await DBOS.registerQueue(def.app, { minPollingIntervalMs: poll, onConflict: "always_update" });
|
|
321
|
+
},
|
|
322
|
+
async stop() {
|
|
323
|
+
await DBOS.shutdown();
|
|
324
|
+
await pool?.end();
|
|
325
|
+
},
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export async function dbosClient(): Promise<EngineClient> {
|
|
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
|
+
};
|
|
337
|
+
const wrap = <T>(id: string): FlowHandle<T> => ({
|
|
338
|
+
id,
|
|
339
|
+
result: () =>
|
|
340
|
+
(client.retrieveWorkflow<T>(id).getResult(resultPoll()) as Promise<T>).catch(async (e) => {
|
|
341
|
+
throw await flowFailure(e, () => status(id));
|
|
342
|
+
}),
|
|
343
|
+
signal: (topic, value) => client.send(id, value, topic),
|
|
344
|
+
// Нет ожидания: query читает текущее значение, как у Temporal.
|
|
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
|
+
},
|
|
354
|
+
});
|
|
355
|
+
return {
|
|
356
|
+
async start(target, args, opts) {
|
|
357
|
+
const h = await client.enqueue(
|
|
358
|
+
{
|
|
359
|
+
queueName: target.app,
|
|
360
|
+
workflowName: target.flow,
|
|
361
|
+
workflowID: opts.id,
|
|
362
|
+
applicationName: target.app,
|
|
363
|
+
...(opts.timeoutMs ? { workflowTimeoutMS: opts.timeoutMs } : {}),
|
|
364
|
+
},
|
|
365
|
+
...args,
|
|
366
|
+
);
|
|
367
|
+
return wrap(h.workflowID);
|
|
368
|
+
},
|
|
369
|
+
handle: (id) => wrap(id),
|
|
370
|
+
close: () => client.destroy(),
|
|
371
|
+
};
|
|
372
|
+
}
|