okengine 0.7.0 → 0.9.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/package.json +3 -2
- package/site/content/docs/deployment/docker-swarm.mdx +228 -0
- package/site/content/docs/deployment/docker.mdx +212 -0
- package/site/content/docs/deployment/index.mdx +83 -0
- package/site/content/docs/deployment/kubernetes.mdx +176 -0
- package/site/content/docs/deployment/meta.json +5 -0
- package/site/content/docs/deployment/reverse-proxy.mdx +216 -0
- package/site/content/docs/elements/channel.mdx +25 -12
- package/site/content/docs/elements/clock.mdx +17 -15
- package/site/content/docs/elements/flow.mdx +6 -2
- package/site/content/docs/elements/signal.mdx +10 -8
- package/site/content/docs/elements/store.mdx +165 -0
- package/site/content/docs/get-started/index.mdx +5 -0
- package/site/content/docs/get-started/installation.mdx +18 -16
- package/site/content/docs/index.mdx +5 -0
- package/site/content/docs/meta.json +10 -1
- package/site/content/docs/plugins/index.mdx +1 -2
- package/site/content/docs/plugins/magic-link.mdx +44 -2
- package/site/content/docs/plugins/meta.json +1 -2
- package/site/content/docs/plugins/otp.mdx +202 -0
- package/site/content/docs/plugins/two-factor.mdx +2 -1
- package/site/content/docs/reference/cli.md +5 -2
- package/site/content/docs/reference/configuration.mdx +21 -3
- package/site/content/docs/reference/environment-variables.mdx +21 -8
- package/site/content/docs/reference/plugins.mdx +1 -1
- package/src/auth/auth.test.ts +36 -0
- package/src/auth/bindings.ts +3 -12
- package/src/auth/identity.ts +33 -0
- package/src/auth/index.ts +5 -0
- package/src/auth/otp-capability.ts +119 -0
- package/src/auth/otp-seal.test.ts +61 -0
- package/src/auth/otp-seal.ts +84 -0
- package/src/auth/schema.ts +3 -0
- package/src/auth/sessions.ts +26 -27
- package/src/auth/tables.ts +4 -0
- package/src/auth/verification.ts +61 -1
- package/src/cli/db-seed.ts +359 -0
- package/src/cli/db.test.ts +341 -3
- package/src/cli/db.ts +75 -8
- package/src/cli/dev-app-runner.ts +4 -0
- package/src/cli/docker.ts +4 -1
- package/src/cli/load-config.images.test.ts +26 -0
- package/src/cli/load-config.ts +10 -2
- package/src/cli/registry.ts +38 -2
- package/src/compiler/effects-infer.ts +1 -0
- package/src/config/index.ts +4 -0
- package/src/console/server/operator-db.ts +34 -9
- package/src/docker/compose.ts +162 -6
- package/src/docker/derive.ts +60 -3
- package/src/docker/docker.test.ts +374 -1
- package/src/docker/helpers.ts +2 -0
- package/src/docker/index.ts +11 -0
- package/src/docker/recipes/caddy.ts +51 -0
- package/src/docker/recipes/dragonfly.ts +31 -0
- package/src/docker/recipes/index.ts +25 -2
- package/src/docker/recipes/pgdog.ts +84 -0
- package/src/docker/recipes/redis.ts +6 -3
- package/src/docker/recipes/traefik.ts +83 -0
- package/src/docker/recipes/valkey.ts +30 -0
- package/src/docker/stack-id.ts +5 -0
- package/src/docker/types.ts +18 -0
- package/src/drivers/channel-sently.test.ts +8 -0
- package/src/drivers/channel-taqnyat-mail.ts +34 -0
- package/src/drivers/channel-taqnyat-whatsapp.ts +94 -0
- package/src/drivers/channel-types.ts +72 -0
- package/src/drivers/clock-postgres.test.ts +258 -0
- package/src/drivers/clock-postgres.ts +410 -0
- package/src/drivers/index.ts +18 -0
- package/src/drivers/journal-postgres.test.ts +175 -0
- package/src/drivers/journal-postgres.ts +492 -0
- package/src/elements/channel/otp-delivery.test.ts +76 -0
- package/src/elements/channel/otp-delivery.ts +291 -0
- package/src/elements/channel/runtime.ts +203 -114
- package/src/elements/channel.test.ts +71 -0
- package/src/elements/channel.ts +12 -2
- package/src/elements/clock/chaos-child.ts +280 -41
- package/src/elements/clock/durable.ts +7 -0
- package/src/elements/clock/reconcile.ts +2 -2
- package/src/elements/clock/runtime.ts +5 -3
- package/src/elements/clock.ts +1 -1
- package/src/elements/store/seed.test.ts +27 -0
- package/src/elements/store/seed.ts +68 -0
- package/src/elements/store/sql-session.test.ts +39 -0
- package/src/elements/store/sql-session.ts +55 -0
- package/src/elements/store/upsert-app.test.ts +103 -0
- package/src/elements/store.ts +5 -0
- package/src/index.ts +18 -0
- package/src/kernel/app.ts +221 -14
- package/src/kernel/boot-bind/channel.test.ts +16 -0
- package/src/kernel/boot-bind/channel.ts +64 -0
- package/src/kernel/boot-bind/clock.ts +17 -6
- package/src/kernel/boot-bind/gate.ts +14 -19
- package/src/kernel/boot-bind/honor-config.test.ts +123 -4
- package/src/kernel/boot-bind/journal.ts +89 -0
- package/src/kernel/boot-bind/signal.ts +20 -0
- package/src/kernel/boot-bind/store.test.ts +82 -0
- package/src/kernel/boot-bind/store.ts +22 -0
- package/src/kernel/boot.test.ts +6 -4
- package/src/kernel/boot.ts +53 -13
- package/src/kernel/concurrency.ts +1 -1
- package/src/kernel/fx.test.ts +9 -0
- package/src/kernel/fx.ts +175 -5
- package/src/kernel/graceful-shutdown.test.ts +76 -0
- package/src/kernel/graceful-shutdown.ts +106 -0
- package/src/kernel/horizontal-child.ts +257 -0
- package/src/kernel/horizontal.integration.test.ts +229 -0
- package/src/kernel/index.ts +14 -0
- package/src/kernel/journal-boot.test.ts +397 -0
- package/src/kernel/journal-suspend.ts +35 -0
- package/src/kernel/journal.test.ts +142 -0
- package/src/kernel/journal.ts +202 -27
- package/src/kernel/ready.test.ts +76 -0
- package/src/plugins/auth-delivery.mailpit.integration.test.ts +5 -5
- package/src/plugins/auth-methods.security.test.ts +20 -27
- package/src/plugins/auth-methods.test.ts +7 -6
- package/src/plugins/index.ts +12 -8
- package/src/plugins/magic-link.ts +1 -23
- package/src/plugins/otp.test.ts +236 -0
- package/src/plugins/otp.ts +570 -0
- package/src/plugins/taqnyat.live.test.ts +172 -0
- package/src/release/official-plugins.ts +1 -2
- package/site/content/docs/plugins/email-otp.mdx +0 -117
- package/site/content/docs/plugins/phone-number.mdx +0 -111
- package/src/plugins/email-otp.ts +0 -214
- package/src/plugins/phone-number.ts +0 -149
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable journal — boot-level acceptance.
|
|
3
|
+
*
|
|
4
|
+
* Fake suite: two `oke()` apps share one journal store in one process (the
|
|
5
|
+
* store is injected as a prebuilt `elements.journal`; every other path —
|
|
6
|
+
* boot bind, orphan scan, claimDueSleep, run lease — is the real boot path).
|
|
7
|
+
* Covers same-instance crash, cross-instance failover, double-execution
|
|
8
|
+
* prevention against the SKIP LOCKED fake.
|
|
9
|
+
*
|
|
10
|
+
* Live suite: real OS processes + real Postgres (chaos-child journal-pg-*),
|
|
11
|
+
* gated on `OKE_TEST_POSTGRES_URL` (or `OKE_TEST_POSTGRES=1` + `DATABASE_URL`).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
15
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
createPostgresJournalFake,
|
|
21
|
+
createPostgresJournalStore,
|
|
22
|
+
type PostgresJournalStore,
|
|
23
|
+
} from "../drivers/journal-postgres.ts";
|
|
24
|
+
import { oke, type OkeApp } from "./app.ts";
|
|
25
|
+
import type { JournalRuntime } from "./boot-bind/journal.ts";
|
|
26
|
+
import { flow, resetFlowSeq, type AnyFlowDef } from "./flow.ts";
|
|
27
|
+
import type { Binding } from "./on.ts";
|
|
28
|
+
import { http } from "./triggers.ts";
|
|
29
|
+
|
|
30
|
+
const childPath = join(import.meta.dir, "../elements/clock/chaos-child.ts");
|
|
31
|
+
|
|
32
|
+
const LIVE_URL =
|
|
33
|
+
process.env.OKE_TEST_POSTGRES_URL?.trim() ||
|
|
34
|
+
(process.env.OKE_TEST_POSTGRES === "1"
|
|
35
|
+
? (process.env.DATABASE_URL ?? process.env.OKE_STORE_SQL_URL)?.trim()
|
|
36
|
+
: undefined);
|
|
37
|
+
|
|
38
|
+
function journalRuntime(
|
|
39
|
+
store: PostgresJournalStore,
|
|
40
|
+
instanceId: string,
|
|
41
|
+
leaseMs = 120,
|
|
42
|
+
): JournalRuntime {
|
|
43
|
+
return { store, instanceId, leaseMs, driverId: "postgres" };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function bootDurableApp(options: {
|
|
47
|
+
readonly name: string;
|
|
48
|
+
readonly journal: JournalRuntime;
|
|
49
|
+
readonly bindings: readonly Binding[];
|
|
50
|
+
}): Promise<OkeApp> {
|
|
51
|
+
const app = oke({
|
|
52
|
+
name: options.name,
|
|
53
|
+
env: "test",
|
|
54
|
+
startScheduler: false,
|
|
55
|
+
gate: { unguardedHttp: "allow" },
|
|
56
|
+
// Explicit bindings: the global on() registry is consumed by the first
|
|
57
|
+
// oke() in the process — sibling apps need their own copies.
|
|
58
|
+
bindings: [...options.bindings],
|
|
59
|
+
elements: { journal: options.journal },
|
|
60
|
+
});
|
|
61
|
+
await app.boot();
|
|
62
|
+
return app;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function waitFor(cond: () => Promise<boolean>, timeoutMs = 5_000): Promise<boolean> {
|
|
66
|
+
const deadline = Date.now() + timeoutMs;
|
|
67
|
+
while (Date.now() < deadline) {
|
|
68
|
+
if (await cond()) return true;
|
|
69
|
+
await Bun.sleep(10);
|
|
70
|
+
}
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Flow whose step 2 blocks once per execution; the test unblocks survivors. */
|
|
75
|
+
function blockingFlow(step1Calls: string[], blockers: Array<() => void>): Binding {
|
|
76
|
+
const charge = flow({
|
|
77
|
+
name: "charge",
|
|
78
|
+
durable: true,
|
|
79
|
+
do: async (_input, fx) => {
|
|
80
|
+
await fx.step("create-intent", () => {
|
|
81
|
+
step1Calls.push("step1");
|
|
82
|
+
return { id: "pi_1" };
|
|
83
|
+
});
|
|
84
|
+
await fx.step("mid-flight", async () => {
|
|
85
|
+
// Every *execution* parks here; the test unblocks only the resume.
|
|
86
|
+
await new Promise<void>((resolve) => {
|
|
87
|
+
blockers.push(resolve);
|
|
88
|
+
});
|
|
89
|
+
return "done";
|
|
90
|
+
});
|
|
91
|
+
return { ok: true };
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
return { trigger: http.post("/charge"), flow: charge as AnyFlowDef };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Flow that sleeps `wake`, then records one confirm execution. */
|
|
98
|
+
function sleeperBinding(wake: string, confirms: string[]): Binding {
|
|
99
|
+
const sleeper = flow({
|
|
100
|
+
name: "sleeper",
|
|
101
|
+
durable: true,
|
|
102
|
+
do: async (_input, fx) => {
|
|
103
|
+
await fx.step("create-intent", () => ({ id: "pi_1" }));
|
|
104
|
+
await fx.clock.sleep("verify-window", wake);
|
|
105
|
+
await fx.step("confirm", () => {
|
|
106
|
+
confirms.push("confirm");
|
|
107
|
+
return true;
|
|
108
|
+
});
|
|
109
|
+
return { ok: true };
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
return { trigger: http.post("/sleep"), flow: sleeper as AnyFlowDef };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
describe("journal boot — shared store, real boot paths (fake SQL)", () => {
|
|
116
|
+
afterEach(() => {
|
|
117
|
+
resetFlowSeq();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("same-instance crash: boot orphan scan resumes; completed step never re-runs", async () => {
|
|
121
|
+
const store = await createPostgresJournalStore({ sql: createPostgresJournalFake() });
|
|
122
|
+
const step1Calls: string[] = [];
|
|
123
|
+
const blockers: Array<() => void> = [];
|
|
124
|
+
const bindings = [blockingFlow(step1Calls, blockers)];
|
|
125
|
+
|
|
126
|
+
const appA = await bootDurableApp({
|
|
127
|
+
name: "a",
|
|
128
|
+
journal: journalRuntime(store, "A", 120),
|
|
129
|
+
bindings,
|
|
130
|
+
});
|
|
131
|
+
// Start the run through the real HTTP path; it hangs inside step 2.
|
|
132
|
+
void appA.fetch(new Request("http://localhost/charge", { method: "POST", body: "{}" }));
|
|
133
|
+
expect(await waitFor(async () => blockers.length === 1)).toBe(true);
|
|
134
|
+
const runId = (await store.list())[0]!.id;
|
|
135
|
+
// "Crash": no commit, no release — the lease (120ms) is left to expire.
|
|
136
|
+
// appA is deliberately never stopped; its promise stays dangling.
|
|
137
|
+
|
|
138
|
+
await Bun.sleep(300);
|
|
139
|
+
const appB = await bootDurableApp({
|
|
140
|
+
name: "b",
|
|
141
|
+
journal: journalRuntime(store, "B", 120),
|
|
142
|
+
bindings,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// Boot orphan scan (fire-and-forget) claims the expired lease and resumes.
|
|
146
|
+
expect(await waitFor(async () => blockers.length === 2)).toBe(true);
|
|
147
|
+
expect(step1Calls).toHaveLength(1); // completed step replayed, not re-run
|
|
148
|
+
blockers[1]!(); // let the survivor finish
|
|
149
|
+
|
|
150
|
+
expect(await waitFor(async () => (await store.get(runId))?.status === "completed")).toBe(true);
|
|
151
|
+
expect((await store.get(runId))?.output).toEqual({ ok: true });
|
|
152
|
+
// The in-flight (unpersisted) step ran twice — documented at-least-once law.
|
|
153
|
+
expect(blockers).toHaveLength(2);
|
|
154
|
+
expect(step1Calls).toHaveLength(1);
|
|
155
|
+
|
|
156
|
+
await appB.stop();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("cross-instance failover: a sleep parked by a dead instance wakes on the survivor", async () => {
|
|
160
|
+
const store = await createPostgresJournalStore({ sql: createPostgresJournalFake() });
|
|
161
|
+
const confirms: string[] = [];
|
|
162
|
+
const bindings = [sleeperBinding("120ms", confirms)];
|
|
163
|
+
|
|
164
|
+
const appA = await bootDurableApp({
|
|
165
|
+
name: "a",
|
|
166
|
+
journal: journalRuntime(store, "A", 5_000),
|
|
167
|
+
bindings,
|
|
168
|
+
});
|
|
169
|
+
const parked = await appA.fetch(
|
|
170
|
+
new Request("http://localhost/sleep", { method: "POST", body: "{}" }),
|
|
171
|
+
);
|
|
172
|
+
expect(parked.status).toBe(204); // durable park — lease released, row sleeping
|
|
173
|
+
const runId = (await store.list())[0]!.id;
|
|
174
|
+
// "Dead" instance A: parked runs hold no lease, so closing is crash-safe.
|
|
175
|
+
await appA.stop();
|
|
176
|
+
|
|
177
|
+
// Survivor boots while the sleep is still pending — orphan scan leaves it scheduled.
|
|
178
|
+
const appB = await bootDurableApp({
|
|
179
|
+
name: "b",
|
|
180
|
+
journal: journalRuntime(store, "B", 5_000),
|
|
181
|
+
bindings,
|
|
182
|
+
});
|
|
183
|
+
expect(confirms).toHaveLength(0);
|
|
184
|
+
|
|
185
|
+
await Bun.sleep(200);
|
|
186
|
+
await appB.resumeDurable(); // scheduler tick in production
|
|
187
|
+
|
|
188
|
+
expect(confirms).toEqual(["confirm"]);
|
|
189
|
+
expect((await store.get(runId))?.status).toBe("completed");
|
|
190
|
+
await appB.stop();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("double-execution prevention: two instances race one due sleep — exactly one runs it", async () => {
|
|
194
|
+
const store = await createPostgresJournalStore({ sql: createPostgresJournalFake() });
|
|
195
|
+
const confirms: string[] = [];
|
|
196
|
+
const bindings = [sleeperBinding("300ms", confirms)];
|
|
197
|
+
|
|
198
|
+
const appA = await bootDurableApp({
|
|
199
|
+
name: "a",
|
|
200
|
+
journal: journalRuntime(store, "A", 5_000),
|
|
201
|
+
bindings,
|
|
202
|
+
});
|
|
203
|
+
await appA.fetch(new Request("http://localhost/sleep", { method: "POST", body: "{}" }));
|
|
204
|
+
await appA.stop();
|
|
205
|
+
|
|
206
|
+
// Both survivors boot while the sleep is future (orphan scan skips it).
|
|
207
|
+
const appB = await bootDurableApp({
|
|
208
|
+
name: "b",
|
|
209
|
+
journal: journalRuntime(store, "B", 5_000),
|
|
210
|
+
bindings,
|
|
211
|
+
});
|
|
212
|
+
const appC = await bootDurableApp({
|
|
213
|
+
name: "c",
|
|
214
|
+
journal: journalRuntime(store, "C", 5_000),
|
|
215
|
+
bindings,
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
await Bun.sleep(400);
|
|
219
|
+
await Promise.all([appB.resumeDurable(), appC.resumeDurable()]);
|
|
220
|
+
|
|
221
|
+
expect(confirms).toHaveLength(1);
|
|
222
|
+
await appB.stop();
|
|
223
|
+
await appC.stop();
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("boot orphan scan skips runs held under a live lease", async () => {
|
|
227
|
+
const store = await createPostgresJournalStore({ sql: createPostgresJournalFake() });
|
|
228
|
+
const step1Calls: string[] = [];
|
|
229
|
+
const blockers: Array<() => void> = [];
|
|
230
|
+
const bindings = [blockingFlow(step1Calls, blockers)];
|
|
231
|
+
|
|
232
|
+
// Long lease: A is slow but very much alive.
|
|
233
|
+
const appA = await bootDurableApp({
|
|
234
|
+
name: "a",
|
|
235
|
+
journal: journalRuntime(store, "A", 30_000),
|
|
236
|
+
bindings,
|
|
237
|
+
});
|
|
238
|
+
void appA.fetch(new Request("http://localhost/charge", { method: "POST", body: "{}" }));
|
|
239
|
+
expect(await waitFor(async () => blockers.length === 1)).toBe(true);
|
|
240
|
+
|
|
241
|
+
// B boots while A's lease is live — must not steal the run.
|
|
242
|
+
const appB = await bootDurableApp({
|
|
243
|
+
name: "b",
|
|
244
|
+
journal: journalRuntime(store, "B", 30_000),
|
|
245
|
+
bindings,
|
|
246
|
+
});
|
|
247
|
+
await Bun.sleep(150);
|
|
248
|
+
expect(blockers).toHaveLength(1);
|
|
249
|
+
expect(step1Calls).toHaveLength(1);
|
|
250
|
+
|
|
251
|
+
blockers[0]!(); // A finishes cleanly.
|
|
252
|
+
expect(await waitFor(async () => (await store.list())[0]?.status === "completed")).toBe(true);
|
|
253
|
+
expect(step1Calls).toHaveLength(1);
|
|
254
|
+
await appB.stop();
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
describe.skipIf(!LIVE_URL)("chaos — postgres journal multi-process boot", () => {
|
|
259
|
+
test("SIGKILL mid-run: survivor boot orphan scan resumes; completed step never re-runs", async () => {
|
|
260
|
+
const url = LIVE_URL!;
|
|
261
|
+
const dir = await mkdtemp(join(tmpdir(), "oke-journal-pg-crash-"));
|
|
262
|
+
const stepLogPath = join(dir, "steps.jsonl");
|
|
263
|
+
const markerPath = join(dir, "started.json");
|
|
264
|
+
const donePath = join(dir, "done.json");
|
|
265
|
+
const leaseMs = 300;
|
|
266
|
+
|
|
267
|
+
const schema = await createPostgresJournalStore({ url });
|
|
268
|
+
await schema.sql.exec(`DELETE FROM oke_journal_runs WHERE flow LIKE 'chaos.journal.%'`);
|
|
269
|
+
await schema.close();
|
|
270
|
+
|
|
271
|
+
try {
|
|
272
|
+
const doomed = Bun.spawn({
|
|
273
|
+
cmd: [
|
|
274
|
+
"bun",
|
|
275
|
+
childPath,
|
|
276
|
+
"journal-pg-start",
|
|
277
|
+
url,
|
|
278
|
+
"doomed",
|
|
279
|
+
stepLogPath,
|
|
280
|
+
markerPath,
|
|
281
|
+
String(leaseMs),
|
|
282
|
+
"60000",
|
|
283
|
+
],
|
|
284
|
+
stdout: "pipe",
|
|
285
|
+
stderr: "pipe",
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
expect(await waitForFile(markerPath)).toBe(true);
|
|
289
|
+
doomed.kill(9);
|
|
290
|
+
await doomed.exited;
|
|
291
|
+
|
|
292
|
+
const survivor = Bun.spawn({
|
|
293
|
+
cmd: [
|
|
294
|
+
"bun",
|
|
295
|
+
childPath,
|
|
296
|
+
"journal-pg-resume",
|
|
297
|
+
url,
|
|
298
|
+
"survivor",
|
|
299
|
+
stepLogPath,
|
|
300
|
+
donePath,
|
|
301
|
+
String(leaseMs),
|
|
302
|
+
],
|
|
303
|
+
stdout: "pipe",
|
|
304
|
+
stderr: "pipe",
|
|
305
|
+
});
|
|
306
|
+
expect(await survivor.exited).toBe(0);
|
|
307
|
+
|
|
308
|
+
const steps = (await Bun.file(stepLogPath).text())
|
|
309
|
+
.trim()
|
|
310
|
+
.split("\n")
|
|
311
|
+
.filter(Boolean)
|
|
312
|
+
.map((l) => JSON.parse(l) as { instanceId: string; step: string });
|
|
313
|
+
const step1 = steps.filter((s) => s.step === "create-intent");
|
|
314
|
+
const step2 = steps.filter((s) => s.step === "mid-flight");
|
|
315
|
+
expect(step1).toHaveLength(1); // completed step never re-ran — across processes
|
|
316
|
+
expect(step1[0]!.instanceId).toBe("doomed");
|
|
317
|
+
expect(step2).toHaveLength(1);
|
|
318
|
+
expect(step2[0]!.instanceId).toBe("survivor");
|
|
319
|
+
expect(await Bun.file(donePath).exists()).toBe(true);
|
|
320
|
+
} finally {
|
|
321
|
+
await rm(dir, { recursive: true, force: true });
|
|
322
|
+
const cleanup = await createPostgresJournalStore({ url });
|
|
323
|
+
await cleanup.sql.exec(`DELETE FROM oke_journal_runs WHERE flow LIKE 'chaos.journal.%'`);
|
|
324
|
+
await cleanup.close();
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test("two OS processes race one due sleep — exactly one executes it", async () => {
|
|
329
|
+
const url = LIVE_URL!;
|
|
330
|
+
const dir = await mkdtemp(join(tmpdir(), "oke-journal-pg-race-"));
|
|
331
|
+
const stepLogPath = join(dir, "steps.jsonl");
|
|
332
|
+
const parkedPath = join(dir, "parked.json");
|
|
333
|
+
const leaseMs = 300;
|
|
334
|
+
|
|
335
|
+
const schema = await createPostgresJournalStore({ url });
|
|
336
|
+
await schema.sql.exec(`DELETE FROM oke_journal_runs WHERE flow LIKE 'chaos.journal.%'`);
|
|
337
|
+
await schema.close();
|
|
338
|
+
|
|
339
|
+
try {
|
|
340
|
+
const seeder = Bun.spawn({
|
|
341
|
+
cmd: ["bun", childPath, "journal-pg-park", url, "seeder", stepLogPath, parkedPath, "400"],
|
|
342
|
+
stdout: "pipe",
|
|
343
|
+
stderr: "pipe",
|
|
344
|
+
});
|
|
345
|
+
expect(await seeder.exited).toBe(0);
|
|
346
|
+
expect(await Bun.file(parkedPath).exists()).toBe(true);
|
|
347
|
+
|
|
348
|
+
await Bun.sleep(500); // let the sleep come due
|
|
349
|
+
const claim = (instanceId: string) =>
|
|
350
|
+
Bun.spawn({
|
|
351
|
+
cmd: [
|
|
352
|
+
"bun",
|
|
353
|
+
childPath,
|
|
354
|
+
"journal-pg-claim",
|
|
355
|
+
url,
|
|
356
|
+
instanceId,
|
|
357
|
+
stepLogPath,
|
|
358
|
+
String(leaseMs),
|
|
359
|
+
],
|
|
360
|
+
stdout: "pipe",
|
|
361
|
+
stderr: "pipe",
|
|
362
|
+
});
|
|
363
|
+
const b = claim("inst-b");
|
|
364
|
+
const c = claim("inst-c");
|
|
365
|
+
const [exitB, exitC] = await Promise.all([b.exited, c.exited]);
|
|
366
|
+
expect([exitB, exitC].sort()).toEqual([0, 3]); // one claimed, one found nothing
|
|
367
|
+
|
|
368
|
+
const steps = (await Bun.file(stepLogPath).text())
|
|
369
|
+
.trim()
|
|
370
|
+
.split("\n")
|
|
371
|
+
.filter(Boolean)
|
|
372
|
+
.map((l) => JSON.parse(l) as { instanceId: string; step: string });
|
|
373
|
+
expect(steps.filter((s) => s.step === "confirm")).toHaveLength(1);
|
|
374
|
+
} finally {
|
|
375
|
+
await rm(dir, { recursive: true, force: true });
|
|
376
|
+
const cleanup = await createPostgresJournalStore({ url });
|
|
377
|
+
await cleanup.sql.exec(`DELETE FROM oke_journal_runs WHERE flow LIKE 'chaos.journal.%'`);
|
|
378
|
+
await cleanup.close();
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
async function waitForFile(path: string, timeoutMs = 15_000): Promise<boolean> {
|
|
384
|
+
const deadline = Date.now() + timeoutMs;
|
|
385
|
+
while (Date.now() < deadline) {
|
|
386
|
+
if (await Bun.file(path).exists()) return true;
|
|
387
|
+
await Bun.sleep(10);
|
|
388
|
+
}
|
|
389
|
+
return false;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Visible skip reason when the live gate is off (describe.skipIf hides the body).
|
|
393
|
+
if (!LIVE_URL) {
|
|
394
|
+
console.log(
|
|
395
|
+
"skip: postgres journal boot chaos (set OKE_TEST_POSTGRES_URL or OKE_TEST_POSTGRES=1 + DATABASE_URL)",
|
|
396
|
+
);
|
|
397
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable-sleep park signal — shared by journal replay and `fx.retry`.
|
|
3
|
+
*
|
|
4
|
+
* Kept out of {@link ./journal.ts} so the edge profile (retry filter) does
|
|
5
|
+
* not pull Node fs/path journal persistence into the browser bundle.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Thrown inside a durable body when a sleep has not yet elapsed.
|
|
10
|
+
* The runner catches this and parks the run as `sleeping`.
|
|
11
|
+
*/
|
|
12
|
+
export class JournalSuspend extends Error {
|
|
13
|
+
readonly wakeAt: number;
|
|
14
|
+
readonly label: string;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param label - Sleep label
|
|
18
|
+
* @param wakeAt - Absolute wake epoch-ms
|
|
19
|
+
*/
|
|
20
|
+
constructor(label: string, wakeAt: number) {
|
|
21
|
+
super(`journal suspend: sleep "${label}" until ${wakeAt}`);
|
|
22
|
+
this.name = "JournalSuspend";
|
|
23
|
+
this.label = label;
|
|
24
|
+
this.wakeAt = wakeAt;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* True when `err` is a {@link JournalSuspend}.
|
|
30
|
+
*
|
|
31
|
+
* @param err - Unknown
|
|
32
|
+
*/
|
|
33
|
+
export function isJournalSuspend(err: unknown): err is JournalSuspend {
|
|
34
|
+
return err instanceof JournalSuspend;
|
|
35
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Journal lease lifecycle — same SKIP LOCKED + lazy-reclaim law as Signal /
|
|
3
|
+
* Clock, here against the memory store (fake-free, single process).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from "bun:test";
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
createJournal,
|
|
10
|
+
createMemoryJournalStore,
|
|
11
|
+
hasJournalLease,
|
|
12
|
+
isJournalLeaseBusy,
|
|
13
|
+
isJournalSuspend,
|
|
14
|
+
JOURNAL_DEFAULT_LEASE_MS,
|
|
15
|
+
} from "./journal.ts";
|
|
16
|
+
|
|
17
|
+
describe("journal lease (memory store)", () => {
|
|
18
|
+
test("built-in stores expose the lease surface", () => {
|
|
19
|
+
expect(hasJournalLease(createMemoryJournalStore())).toBe(true);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("start inserts already holding the lease; persist renews it", async () => {
|
|
23
|
+
let t = 1_000;
|
|
24
|
+
const store = createMemoryJournalStore();
|
|
25
|
+
const journal = createJournal({
|
|
26
|
+
store,
|
|
27
|
+
now: () => t,
|
|
28
|
+
lease: { instanceId: "a", leaseMs: 500 },
|
|
29
|
+
});
|
|
30
|
+
const session = await journal.start("charge", { orderId: "o1" });
|
|
31
|
+
|
|
32
|
+
let row = await store.get(session.runId);
|
|
33
|
+
expect(row?.lockedBy).toBe("a");
|
|
34
|
+
expect(row?.leaseExpiresAt).toBe(1_500);
|
|
35
|
+
|
|
36
|
+
t = 1_400;
|
|
37
|
+
await session.step("create-intent", () => ({ id: "pi_1" }));
|
|
38
|
+
row = await store.get(session.runId);
|
|
39
|
+
expect(row?.leaseExpiresAt).toBe(1_900);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("a live lease blocks a second claimant until expiry", async () => {
|
|
43
|
+
let t = 1_000;
|
|
44
|
+
const store = createMemoryJournalStore();
|
|
45
|
+
const a = createJournal({ store, now: () => t, lease: { instanceId: "a", leaseMs: 200 } });
|
|
46
|
+
const session = await a.start("charge");
|
|
47
|
+
|
|
48
|
+
const b = createJournal({ store, now: () => t, lease: { instanceId: "b", leaseMs: 200 } });
|
|
49
|
+
try {
|
|
50
|
+
await b.resume(session.runId);
|
|
51
|
+
expect.unreachable("resume under a live lease must throw");
|
|
52
|
+
} catch (err) {
|
|
53
|
+
expect(isJournalLeaseBusy(err)).toBe(true);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// After expiry the survivor reclaims lazily.
|
|
57
|
+
t = 1_300;
|
|
58
|
+
const resumed = await b.resume(session.runId);
|
|
59
|
+
expect(resumed.runId).toBe(session.runId);
|
|
60
|
+
expect((await store.get(session.runId))?.lockedBy).toBe("b");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("parking a sleep releases the lease; run stays claimable when due", async () => {
|
|
64
|
+
let t = 1_000;
|
|
65
|
+
const store = createMemoryJournalStore();
|
|
66
|
+
const journal = createJournal({
|
|
67
|
+
store,
|
|
68
|
+
now: () => t,
|
|
69
|
+
lease: { instanceId: "a", leaseMs: 30_000 },
|
|
70
|
+
});
|
|
71
|
+
const session = await journal.start("charge");
|
|
72
|
+
try {
|
|
73
|
+
await session.sleep("verify-window", "2m", () => 120_000);
|
|
74
|
+
expect.unreachable("sleep must suspend");
|
|
75
|
+
} catch (err) {
|
|
76
|
+
expect(isJournalSuspend(err)).toBe(true);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const parked = await store.get(session.runId);
|
|
80
|
+
expect(parked?.status).toBe("sleeping");
|
|
81
|
+
expect(parked?.lockedBy).toBeUndefined();
|
|
82
|
+
expect(parked?.leaseExpiresAt).toBeUndefined();
|
|
83
|
+
|
|
84
|
+
// Due → another instance claims + resumes without a lease race.
|
|
85
|
+
t += 121_000;
|
|
86
|
+
const claimed = await store.claimDueSleep!("b", t, JOURNAL_DEFAULT_LEASE_MS);
|
|
87
|
+
expect(claimed?.id).toBe(session.runId);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("terminal commit releases the lease", async () => {
|
|
91
|
+
const store = createMemoryJournalStore();
|
|
92
|
+
const journal = createJournal({
|
|
93
|
+
store,
|
|
94
|
+
now: () => 1_000,
|
|
95
|
+
lease: { instanceId: "a", leaseMs: 30_000 },
|
|
96
|
+
});
|
|
97
|
+
const session = await journal.start("charge");
|
|
98
|
+
await session.commit("completed", { output: 42 });
|
|
99
|
+
|
|
100
|
+
const row = await store.get(session.runId);
|
|
101
|
+
expect(row?.status).toBe("completed");
|
|
102
|
+
expect(row?.lockedBy).toBeUndefined();
|
|
103
|
+
expect(row?.leaseExpiresAt).toBeUndefined();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("crash mid-run: orphan discovery sees the expired lease; replay skips completed steps", async () => {
|
|
107
|
+
let t = 1_000;
|
|
108
|
+
const store = createMemoryJournalStore();
|
|
109
|
+
const calls: string[] = [];
|
|
110
|
+
const a = createJournal({ store, now: () => t, lease: { instanceId: "a", leaseMs: 100 } });
|
|
111
|
+
const session = await a.start("charge");
|
|
112
|
+
await session.step("create-intent", () => {
|
|
113
|
+
calls.push("create-intent");
|
|
114
|
+
return { id: "pi_1" };
|
|
115
|
+
});
|
|
116
|
+
// "Crash": no commit, lease left to expire.
|
|
117
|
+
|
|
118
|
+
t = 1_200;
|
|
119
|
+
const orphans = await store.listOrphans!(t);
|
|
120
|
+
expect(orphans.map((r) => r.id)).toEqual([session.runId]);
|
|
121
|
+
|
|
122
|
+
const b = createJournal({ store, now: () => t, lease: { instanceId: "b", leaseMs: 100 } });
|
|
123
|
+
const resumed = await b.resume(session.runId);
|
|
124
|
+
const value = await resumed.step("create-intent", () => {
|
|
125
|
+
calls.push("re-run");
|
|
126
|
+
return { id: "pi_2" };
|
|
127
|
+
});
|
|
128
|
+
expect(value).toEqual({ id: "pi_1" });
|
|
129
|
+
expect(calls).toEqual(["create-intent"]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("no lease option → legacy uncoordinated sessions still work", async () => {
|
|
133
|
+
const store = createMemoryJournalStore();
|
|
134
|
+
const journal = createJournal({ store, now: () => 1_000 });
|
|
135
|
+
const session = await journal.start("charge");
|
|
136
|
+
await session.step("s", () => 1);
|
|
137
|
+
expect((await store.get(session.runId))?.lockedBy).toBeUndefined();
|
|
138
|
+
// Resume without lease never throws busy.
|
|
139
|
+
const again = await journal.resume(session.runId);
|
|
140
|
+
expect(await again.step("s", () => 2)).toBe(1);
|
|
141
|
+
});
|
|
142
|
+
});
|