@iworkflow/dbos 0.0.0-stage → 0.1.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 +186 -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.1.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.1.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,186 @@
|
|
|
1
|
+
// DBOS: общая системная база Postgres, у модуля своё приложение (name) и своя очередь.
|
|
2
|
+
// Дочерний процесс в чужом модуле — enqueueWorkflowWithOptions по имени, без ссылки на функцию.
|
|
3
|
+
import { DBOS, DBOSClient } from "@dbos-inc/dbos-sdk";
|
|
4
|
+
import {
|
|
5
|
+
bindStepRuntime,
|
|
6
|
+
type AppDefinition,
|
|
7
|
+
type EngineClient,
|
|
8
|
+
type EngineRuntime,
|
|
9
|
+
type FlowContext,
|
|
10
|
+
type FlowHandle,
|
|
11
|
+
} from "@iworkflow/core";
|
|
12
|
+
import { Pool } from "pg";
|
|
13
|
+
|
|
14
|
+
// Ожидание результата дочернего процесса — опрос базы, по умолчанию раз в секунду: это и есть
|
|
15
|
+
// задержка каждого перехода между модулями. DBOS_RESULT_POLL_MS уменьшает её ценой запросов к базе.
|
|
16
|
+
const resultPoll = () => ({ pollingIntervalMs: Number(process.env.DBOS_RESULT_POLL_MS) || undefined });
|
|
17
|
+
|
|
18
|
+
const sysDb = () =>
|
|
19
|
+
process.env.DBOS_SYSTEM_DATABASE_URL ?? "postgres://iworkflow:iworkflow@localhost:55432/iworkflow";
|
|
20
|
+
|
|
21
|
+
// У DBOS нет heartbeat: прогресс шага — строка в системной базе по (workflow, номер шага).
|
|
22
|
+
// Номер шага детерминирован, поэтому повтор после рестарта читает прогресс своей прошлой попытки.
|
|
23
|
+
const HEARTBEAT_DDL = [
|
|
24
|
+
"CREATE SCHEMA IF NOT EXISTS iwf",
|
|
25
|
+
`CREATE TABLE IF NOT EXISTS iwf.birth (
|
|
26
|
+
workflow_id text PRIMARY KEY, app text NOT NULL, patches text[] NOT NULL, at timestamptz NOT NULL DEFAULT now())`,
|
|
27
|
+
`CREATE TABLE IF NOT EXISTS iwf.heartbeat (
|
|
28
|
+
workflow_id text NOT NULL, step_id integer NOT NULL, details jsonb, at timestamptz NOT NULL DEFAULT now(),
|
|
29
|
+
PRIMARY KEY (workflow_id, step_id))`,
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
// Модули стартуют разом на одной системной базе: миграции DBOS на пустой базе ловят deadlock и
|
|
33
|
+
// "tuple concurrently updated", IF NOT EXISTS — гонку. Запуск целиком — под общим advisory-замком.
|
|
34
|
+
// Ждать замок — опросом pg_try_advisory_lock: висящий pg_advisory_lock держит транзакцию, и
|
|
35
|
+
// CREATE INDEX CONCURRENTLY из миграций DBOS ждёт его вечно.
|
|
36
|
+
const LAUNCH_LOCK = "hashtext('iworkflow/dbos launch')";
|
|
37
|
+
|
|
38
|
+
async function underLaunchLock<T>(pool: Pool, fn: () => Promise<T>): Promise<T> {
|
|
39
|
+
const c = await pool.connect();
|
|
40
|
+
try {
|
|
41
|
+
while (!(await c.query(`SELECT pg_try_advisory_lock(${LAUNCH_LOCK}) AS ok`)).rows[0].ok) await Bun.sleep(200);
|
|
42
|
+
for (const sql of HEARTBEAT_DDL) await c.query(sql);
|
|
43
|
+
return await fn();
|
|
44
|
+
} finally {
|
|
45
|
+
await c.query(`SELECT pg_advisory_unlock(${LAUNCH_LOCK})`).catch(() => {});
|
|
46
|
+
c.release();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// patched без DBOS.patch: тот смотрит слот текущей операции, а у процесса, ждущего в recv, слот ещё
|
|
51
|
+
// пуст — старый процесс уходит в новую ветку и ломает детерминизм на следующем шаге.
|
|
52
|
+
// Здесь при первом исполнении процесса записывается список патчей кода, который его начал
|
|
53
|
+
// (iwf.birth), и patched отвечает по нему: ответ одинаков при каждом переигрывании, номер операции
|
|
54
|
+
// не тратится, откат и повторная выкатка версии не путают ветки.
|
|
55
|
+
function patchBook(db: Pool, app: string, declared: string[]) {
|
|
56
|
+
const births = new Map<string, Set<string>>();
|
|
57
|
+
return {
|
|
58
|
+
async patched(workflowId: string, id: string) {
|
|
59
|
+
if (!declared.includes(id)) throw new Error(`патч "${id}" не объявлен в AppDefinition.patches модуля ${app}`);
|
|
60
|
+
let set = births.get(workflowId);
|
|
61
|
+
if (!set) {
|
|
62
|
+
await db.query(
|
|
63
|
+
"INSERT INTO iwf.birth (workflow_id, app, patches) VALUES ($1, $2, $3) ON CONFLICT (workflow_id) DO NOTHING",
|
|
64
|
+
[workflowId, app, declared],
|
|
65
|
+
);
|
|
66
|
+
const r = await db.query("SELECT patches FROM iwf.birth WHERE workflow_id = $1", [workflowId]);
|
|
67
|
+
set = new Set(r.rows[0].patches as string[]);
|
|
68
|
+
births.set(workflowId, set);
|
|
69
|
+
}
|
|
70
|
+
return set.has(id);
|
|
71
|
+
},
|
|
72
|
+
/** Первое исполнение — запомнить патчи кода. Вызывается до первой операции процесса. */
|
|
73
|
+
async born(workflowId: string) {
|
|
74
|
+
if (births.has(workflowId)) return;
|
|
75
|
+
await db.query(
|
|
76
|
+
"INSERT INTO iwf.birth (workflow_id, app, patches) VALUES ($1, $2, $3) ON CONFLICT (workflow_id) DO NOTHING",
|
|
77
|
+
[workflowId, app, declared],
|
|
78
|
+
);
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function stepKey(): [string, number] {
|
|
84
|
+
const wf = DBOS.workflowID;
|
|
85
|
+
const step = DBOS.stepID;
|
|
86
|
+
if (!wf || step === undefined) throw new Error("heartbeat вне шага DBOS");
|
|
87
|
+
return [wf, step];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function dbosRuntime(): EngineRuntime {
|
|
91
|
+
let pool: Pool | undefined;
|
|
92
|
+
return {
|
|
93
|
+
async start(def: AppDefinition) {
|
|
94
|
+
pool = new Pool({ connectionString: sysDb(), max: 2 });
|
|
95
|
+
const db = pool;
|
|
96
|
+
const patches = patchBook(db, def.app, def.patches ?? []);
|
|
97
|
+
bindStepRuntime({
|
|
98
|
+
async heartbeat(details) {
|
|
99
|
+
await db.query(
|
|
100
|
+
`INSERT INTO iwf.heartbeat (workflow_id, step_id, details) VALUES ($1, $2, $3)
|
|
101
|
+
ON CONFLICT (workflow_id, step_id) DO UPDATE SET details = EXCLUDED.details, at = now()`,
|
|
102
|
+
[...stepKey(), JSON.stringify(details ?? null)],
|
|
103
|
+
);
|
|
104
|
+
},
|
|
105
|
+
async lastHeartbeat() {
|
|
106
|
+
const r = await db.query("SELECT details FROM iwf.heartbeat WHERE workflow_id = $1 AND step_id = $2", stepKey());
|
|
107
|
+
return r.rows[0]?.details ?? undefined;
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
for (const [name, flow] of Object.entries(def.flows)) {
|
|
111
|
+
const run = async (...args: unknown[]) => {
|
|
112
|
+
await patches.born(DBOS.workflowID!);
|
|
113
|
+
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), {
|
|
119
|
+
name: step,
|
|
120
|
+
retriesAllowed: attempts > 1,
|
|
121
|
+
maxAttempts: attempts,
|
|
122
|
+
intervalSeconds: 0.2,
|
|
123
|
+
backoffRate: 1,
|
|
124
|
+
}) as Promise<never>;
|
|
125
|
+
},
|
|
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;
|
|
132
|
+
},
|
|
133
|
+
sleep: (ms) => DBOS.sleep(ms),
|
|
134
|
+
recv: (topic, timeoutMs) => DBOS.recv(topic, { timeoutSeconds: timeoutMs / 1000, ...resultPoll() }),
|
|
135
|
+
signal: (workflowId, topic, value) => DBOS.send(workflowId, value, topic),
|
|
136
|
+
setState: (key, value) => DBOS.setEvent(key, value),
|
|
137
|
+
patched: (id) => patches.patched(DBOS.workflowID!, id),
|
|
138
|
+
// Старых процессов нет — ветка всегда новая; вызов можно удалять.
|
|
139
|
+
deprecatePatch: async () => {},
|
|
140
|
+
};
|
|
141
|
+
return flow(ctx, ...args);
|
|
142
|
+
};
|
|
143
|
+
DBOS.registerWorkflow(run, { name });
|
|
144
|
+
}
|
|
145
|
+
// Версия постоянна, ветки кода — patched. Без этого версия — хэш кода, и после выкатки новый
|
|
146
|
+
// процесс не подхватывает незавершённые процессы старого. Своя на модуль: в общей системной
|
|
147
|
+
// базе DBOS требует уникальных имён версий.
|
|
148
|
+
DBOS.setConfig({
|
|
149
|
+
name: def.app,
|
|
150
|
+
systemDatabaseUrl: sysDb(),
|
|
151
|
+
logLevel: "warn",
|
|
152
|
+
applicationVersion: process.env.DBOS_APPLICATION_VERSION ?? `${def.app}-patching`,
|
|
153
|
+
});
|
|
154
|
+
await underLaunchLock(db, () => DBOS.launch());
|
|
155
|
+
// Опрос очереди — главный вклад в задержку перехода между модулями (по умолчанию ~1 с).
|
|
156
|
+
const poll = Number(process.env.DBOS_QUEUE_POLL_MS) || undefined;
|
|
157
|
+
await DBOS.registerQueue(def.app, { minPollingIntervalMs: poll, onConflict: "always_update" });
|
|
158
|
+
},
|
|
159
|
+
async stop() {
|
|
160
|
+
await DBOS.shutdown();
|
|
161
|
+
await pool?.end();
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export async function dbosClient(): Promise<EngineClient> {
|
|
167
|
+
const client = await DBOSClient.create({ systemDatabaseUrl: sysDb() });
|
|
168
|
+
const wrap = <T>(id: string): FlowHandle<T> => ({
|
|
169
|
+
id,
|
|
170
|
+
result: () => client.retrieveWorkflow<T>(id).getResult(resultPoll()) as Promise<T>,
|
|
171
|
+
signal: (topic, value) => client.send(id, value, topic),
|
|
172
|
+
// Нет ожидания: query читает текущее значение, как у Temporal.
|
|
173
|
+
query: async <V>(key: string) => ((await client.getEvent<V>(id, key, { timeoutSeconds: 0 })) ?? undefined),
|
|
174
|
+
});
|
|
175
|
+
return {
|
|
176
|
+
async start(target, args, opts) {
|
|
177
|
+
const h = await client.enqueue(
|
|
178
|
+
{ queueName: target.app, workflowName: target.flow, workflowID: opts.id, applicationName: target.app },
|
|
179
|
+
...args,
|
|
180
|
+
);
|
|
181
|
+
return wrap(h.workflowID);
|
|
182
|
+
},
|
|
183
|
+
handle: (id) => wrap(id),
|
|
184
|
+
close: () => client.destroy(),
|
|
185
|
+
};
|
|
186
|
+
}
|