okengine 0.23.0 → 0.23.2
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 +1 -1
- package/site/content/docs/ai/mcp.mdx +4 -4
- package/site/content/docs/elements/channel/index.mdx +4 -4
- package/site/content/docs/elements/channel/receipts.mdx +7 -5
- package/site/content/docs/elements/channel/sms.mdx +2 -1
- package/site/content/docs/elements/flow/index.mdx +22 -18
- package/site/content/docs/elements/signal/broadcast.mdx +4 -2
- package/site/content/docs/elements/signal/index.mdx +15 -14
- package/site/content/docs/plugins/otp.mdx +3 -3
- package/site/content/docs/reference/configuration.mdx +16 -0
- package/site/content/docs/reference/errors.mdx +31 -19
- package/site/content/docs/reference/fx.mdx +12 -4
- package/src/auth/tenants.ts +17 -0
- package/src/cli/doctor-diff.test.ts +29 -1
- package/src/cli/doctor-diff.ts +35 -0
- package/src/cli/manifest-pr-diff.ts +16 -0
- package/src/compiler/aot.test.ts +3 -2
- package/src/compiler/aot.ts +45 -1
- package/src/compiler/dynamic.ts +1 -1
- package/src/compiler/effects-fetch.test.ts +77 -0
- package/src/compiler/effects-infer.ts +87 -16
- package/src/compiler/effects-join.test.ts +48 -0
- package/src/compiler/extract.test.ts +28 -0
- package/src/compiler/extract.ts +11 -1
- package/src/compiler/fx-index.ts +58 -4
- package/src/compiler/http-parse.test.ts +101 -0
- package/src/compiler/http-parse.ts +188 -9
- package/src/compiler/interpret.ts +25 -2
- package/src/console/index.ts +1 -1
- package/src/console/ui-next/dist/assets/{access-page-DDKkhT9v.js → access-page-zBMhZnFm.js} +1 -1
- package/src/console/ui-next/dist/assets/{agent-disclosure-62Xts8Xi.js → agent-disclosure-CBj9f7Ju.js} +1 -1
- package/src/console/ui-next/dist/assets/{cache-glyph-DyeoKHKA.js → cache-glyph-D3MY49h3.js} +1 -1
- package/src/console/ui-next/dist/assets/{call-pii-button-DvfpLXsU.js → call-pii-button-L9JxdBhp.js} +1 -1
- package/src/console/ui-next/dist/assets/{collapsible-BY03SeCg.js → collapsible-BvFaX-Zt.js} +1 -1
- package/src/console/ui-next/dist/assets/{decisions-page-CgkKlA56.js → decisions-page-DgB76m-X.js} +1 -1
- package/src/console/ui-next/dist/assets/{duration-tone-Y6HLjyJ5.js → duration-tone-l1DSJ2Vz.js} +1 -1
- package/src/console/ui-next/dist/assets/{flows-page-DLPfNy-_.js → flows-page-Bb9t1lu6.js} +1 -1
- package/src/console/ui-next/dist/assets/{highlighted-json-D7nNzpgB.js → highlighted-json-DMvsi3Ti.js} +1 -1
- package/src/console/ui-next/dist/assets/{http-method-DdL19zzh.js → http-method-BwhJBTcQ.js} +1 -1
- package/src/console/ui-next/dist/assets/{index-C0lc_s9d.js → index-RhV2jT_7.js} +3 -3
- package/src/console/ui-next/dist/assets/{observability-page-CRlcyLCC.js → observability-page-BRfUILBq.js} +1 -1
- package/src/console/ui-next/dist/assets/{replica-lag-Bb3FWUu9.js → replica-lag-BsceAzIG.js} +1 -1
- package/src/console/ui-next/dist/assets/{request-meta-DYBMWhX8.js → request-meta-C6lTyCXp.js} +1 -1
- package/src/console/ui-next/dist/assets/{store-page-CcE-SXC8.js → store-page-YmE806bJ.js} +1 -1
- package/src/console/ui-next/dist/assets/{trace-detail-sheet-CKMSEQ4s.js → trace-detail-sheet-Di4irS49.js} +1 -1
- package/src/console/ui-next/dist/assets/{tree-expand-toggle-QOfk0M0H.js → tree-expand-toggle-DYeL6Rye.js} +1 -1
- package/src/console/ui-next/dist/assets/{units-page-DKC1CDDd.js → units-page-07agasRA.js} +1 -1
- package/src/console/ui-next/dist/assets/{vault-page-B54v4Yit.js → vault-page-CHBck4n_.js} +1 -1
- package/src/console/ui-next/dist/index.html +1 -1
- package/src/console/ui-next/seed-parked-approval.ts +5 -1
- package/src/drivers/journal-postgres.test.ts +106 -2
- package/src/drivers/journal-postgres.ts +475 -14
- package/src/drivers/postgres.test.ts +60 -0
- package/src/drivers/postgres.ts +79 -5
- package/src/drivers/signal-compete.test.ts +238 -0
- package/src/drivers/signal-postgres.test.ts +170 -0
- package/src/drivers/signal-postgres.ts +486 -213
- package/src/drivers/signal-redis.test.ts +224 -0
- package/src/drivers/signal-redis.ts +934 -93
- package/src/drivers/signal-types.ts +12 -0
- package/src/elements/ai/approval.test.ts +15 -3
- package/src/elements/ai/approval.ts +15 -9
- package/src/elements/ai/mcp-protocol.ts +5 -6
- package/src/elements/ai/runtime.ts +20 -19
- package/src/elements/channel/runtime.ts +42 -4
- package/src/elements/channel/sql-ledger.test.ts +404 -0
- package/src/elements/channel/sql-ledger.ts +395 -0
- package/src/elements/clock/chaos-child.ts +12 -2
- package/src/elements/clock/durable.ts +11 -0
- package/src/elements/signal/runtime.ts +9 -0
- package/src/elements/signal.test.ts +1 -0
- package/src/elements/store/cache-bus.test.ts +121 -0
- package/src/elements/store/cache-bus.ts +278 -0
- package/src/elements/store/cache.test.ts +179 -15
- package/src/elements/store/cache.ts +357 -51
- package/src/elements/store/prepare-row.test.ts +6 -2
- package/src/elements/store/runtime.ts +44 -2
- package/src/elements/store/sql-errors.test.ts +131 -0
- package/src/elements/store/sql-errors.ts +39 -16
- package/src/elements/store/sql-nested-tx.test.ts +344 -0
- package/src/elements/store/sql-session.test.ts +2 -0
- package/src/elements/store/sql-session.ts +275 -9
- package/src/elements/store.ts +1 -0
- package/src/kernel/app.ts +207 -43
- package/src/kernel/auto-cache.test.ts +228 -0
- package/src/kernel/auto-registry.test.ts +5 -1
- package/src/kernel/boot-bind/channel.ts +63 -2
- package/src/kernel/boot-bind/honor-config.test.ts +40 -10
- package/src/kernel/boot-bind/signal.ts +72 -25
- package/src/kernel/boot-bind/store.ts +16 -1
- package/src/kernel/boot.test.ts +2 -2
- package/src/kernel/boot.ts +54 -10
- package/src/kernel/boundary-contract.ts +7 -0
- package/src/kernel/builtin-errors.ts +2 -0
- package/src/kernel/call.test.ts +1 -0
- package/src/kernel/errors-compiler.ts +11 -0
- package/src/kernel/errors-text.ts +16 -0
- package/src/kernel/errors.ts +8 -0
- package/src/kernel/flow.ts +33 -3
- package/src/kernel/fx-call-types.test.ts +32 -0
- package/src/kernel/fx-decide.ts +11 -7
- package/src/kernel/fx-sql-handle.ts +52 -7
- package/src/kernel/fx.test.ts +1 -3
- package/src/kernel/fx.ts +27 -16
- package/src/kernel/horizontal-child.ts +54 -1
- package/src/kernel/horizontal.integration.test.ts +131 -7
- package/src/kernel/idempotency.test.ts +4 -4
- package/src/kernel/journal-boot.test.ts +83 -6
- package/src/kernel/journal.test.ts +158 -0
- package/src/kernel/journal.ts +515 -54
- package/src/kernel/pipeline-tenant.ts +5 -1
- package/src/kernel/pipeline.ts +5 -0
- package/src/kernel/signal-tx.ts +28 -0
- package/src/kernel/store-transaction.test.ts +95 -0
- package/src/mcp/authorization.ts +5 -11
- package/src/mcp/confirmation.ts +203 -38
- package/src/mcp/docs-server.ts +26 -4
- package/src/mcp/index.ts +4 -0
- package/src/mcp/mcp.test.ts +404 -33
- package/src/mcp/protocol.ts +8 -2
- package/src/mcp/server.ts +31 -5
- package/src/mcp/session.ts +7 -0
- package/src/mcp/tools.ts +68 -19
- package/src/mcp/versions.ts +60 -0
- package/src/plugins/index.ts +1 -0
- package/src/plugins/mena.ts +56 -0
- package/src/runtime/bun.ts +5 -0
- package/src/runtime/types.ts +5 -0
|
@@ -20,6 +20,8 @@ import { dirname, join } from "node:path";
|
|
|
20
20
|
import { createPostgresCronStore } from "../drivers/clock-postgres.ts";
|
|
21
21
|
import { createPostgresJournalStore } from "../drivers/journal-postgres.ts";
|
|
22
22
|
import { memorySignalDriver } from "../drivers/signal-memory.ts";
|
|
23
|
+
import { createBunSignalRedisClient, redisSignalDriver } from "../drivers/signal-redis.ts";
|
|
24
|
+
import { bunRedisCacheClient } from "../elements/store/cache-bus.ts";
|
|
23
25
|
import { postgresDriver } from "../drivers/postgres.ts";
|
|
24
26
|
import { redisDriver } from "../drivers/redis.ts";
|
|
25
27
|
import { clock } from "../elements/clock/declare.ts";
|
|
@@ -99,14 +101,40 @@ clockRt.onCron(CRON, async () => {
|
|
|
99
101
|
await Bun.write(path, prev + line);
|
|
100
102
|
});
|
|
101
103
|
|
|
104
|
+
const redisSignals = process.env.OKE_HORIZONTAL_REDIS_SIGNALS === "1";
|
|
102
105
|
const signalRt = createSignalRuntime({
|
|
103
|
-
driver: memorySignalDriver,
|
|
106
|
+
driver: redisSignals ? redisSignalDriver : memorySignalDriver,
|
|
107
|
+
...(redisSignals
|
|
108
|
+
? { redis: createBunSignalRedisClient(redisUrl), compete: true, consumerId: instanceId }
|
|
109
|
+
: {}),
|
|
104
110
|
durablePath: signalPath,
|
|
105
111
|
leaseMs: Number.isFinite(leaseMs) ? leaseMs : 300,
|
|
106
112
|
});
|
|
107
113
|
const job = signal.once(SIG, { retries: 3, deadLetter: true, optional: true });
|
|
108
114
|
signalRt.register(job);
|
|
115
|
+
const news = signal.broadcast("horizontal-news", { optional: true });
|
|
116
|
+
const feed = signal.live("horizontal-feed", { optional: true });
|
|
117
|
+
if (redisSignals) {
|
|
118
|
+
signalRt.register(news);
|
|
119
|
+
signalRt.register(feed);
|
|
120
|
+
}
|
|
109
121
|
const bus = await signalRt.start();
|
|
122
|
+
if (redisSignals) {
|
|
123
|
+
await bus.subscribe("horizontal-news", `news-${instanceId}`, async (msg) => {
|
|
124
|
+
const line = `${JSON.stringify({ instanceId, kind: "broadcast", id: msg.id, at: Date.now() })}\n`;
|
|
125
|
+
const path = join(workDir, "broadcast.jsonl");
|
|
126
|
+
const prev = (await Bun.file(path).exists()) ? await Bun.file(path).text() : "";
|
|
127
|
+
await Bun.write(path, prev + line);
|
|
128
|
+
});
|
|
129
|
+
void (async () => {
|
|
130
|
+
for await (const event of bus.live("horizontal-feed")) {
|
|
131
|
+
const line = `${JSON.stringify({ instanceId, kind: "live", id: event.id, at: Date.now() })}\n`;
|
|
132
|
+
const path = join(workDir, "live.jsonl");
|
|
133
|
+
const prev = (await Bun.file(path).exists()) ? await Bun.file(path).text() : "";
|
|
134
|
+
await Bun.write(path, prev + line);
|
|
135
|
+
}
|
|
136
|
+
})();
|
|
137
|
+
}
|
|
110
138
|
await bus.subscribe(SIG, `consumer-${instanceId}`, async (msg) => {
|
|
111
139
|
const line = `${JSON.stringify({ instanceId, kind: "signal", id: msg.id, at: Date.now() })}\n`;
|
|
112
140
|
const path = join(workDir, "signal.jsonl");
|
|
@@ -129,6 +157,7 @@ const storeRt = createStoreRuntime({
|
|
|
129
157
|
drivers: { sql: postgresDriver },
|
|
130
158
|
sql: { db: { name: "db", primary: { url: pgUrl } } },
|
|
131
159
|
now: () => Date.now(),
|
|
160
|
+
cacheBus: { kind: "redis", redis: bunRedisCacheClient(redisUrl), origin: instanceId },
|
|
132
161
|
});
|
|
133
162
|
storeRt.register(db);
|
|
134
163
|
|
|
@@ -138,7 +167,16 @@ const ping = flow("horizontal.ping", {
|
|
|
138
167
|
const rate = flow("horizontal.rate", {
|
|
139
168
|
do: () => ({ ok: true as const, instanceId }),
|
|
140
169
|
});
|
|
170
|
+
let cachedReads = 0;
|
|
171
|
+
const cached = flow("horizontal.cached", {
|
|
172
|
+
effects: { reads: ["sql:db"] },
|
|
173
|
+
do: () => {
|
|
174
|
+
cachedReads += 1;
|
|
175
|
+
return { reads: cachedReads, instanceId };
|
|
176
|
+
},
|
|
177
|
+
});
|
|
141
178
|
const write = flow("horizontal.write", {
|
|
179
|
+
effects: { writes: ["sql:db"] },
|
|
142
180
|
do: async (_input, fx) => {
|
|
143
181
|
const id = okid();
|
|
144
182
|
await fx
|
|
@@ -159,6 +197,18 @@ const emit = flow("horizontal.emit", {
|
|
|
159
197
|
return { emitted: true as const };
|
|
160
198
|
},
|
|
161
199
|
});
|
|
200
|
+
const publishNews = flow("horizontal.news", {
|
|
201
|
+
do: async (_input, fx) => {
|
|
202
|
+
await fx.emit("horizontal-news", { from: instanceId, at: Date.now() });
|
|
203
|
+
return { emitted: true as const };
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
const publishFeed = flow("horizontal.feed", {
|
|
207
|
+
do: async (_input, fx) => {
|
|
208
|
+
await fx.emit("horizontal-feed", { from: instanceId, at: Date.now() });
|
|
209
|
+
return { emitted: true as const };
|
|
210
|
+
},
|
|
211
|
+
});
|
|
162
212
|
const charge = flow("horizontal.charge", {
|
|
163
213
|
durable: true,
|
|
164
214
|
do: async (_input, fx) => {
|
|
@@ -193,8 +243,11 @@ const charge = flow("horizontal.charge", {
|
|
|
193
243
|
const bindings: Binding[] = [
|
|
194
244
|
{ trigger: http.get("/ping").public(), flow: ping as AnyFlowDef },
|
|
195
245
|
{ trigger: http.get("/rate").gate(rateGate), flow: rate as AnyFlowDef },
|
|
246
|
+
{ trigger: http.get("/cached").public(), flow: cached as AnyFlowDef },
|
|
196
247
|
{ trigger: http.post("/write").public(), flow: write as AnyFlowDef },
|
|
197
248
|
{ trigger: http.post("/emit").public(), flow: emit as AnyFlowDef },
|
|
249
|
+
{ trigger: http.post("/news").public(), flow: publishNews as AnyFlowDef },
|
|
250
|
+
{ trigger: http.post("/feed").public(), flow: publishFeed as AnyFlowDef },
|
|
198
251
|
{ trigger: http.post("/charge").public(), flow: charge as AnyFlowDef },
|
|
199
252
|
];
|
|
200
253
|
|
|
@@ -124,10 +124,26 @@ describe.skipIf(!LIVE)("horizontal — two OS processes, Postgres + Redis", () =
|
|
|
124
124
|
const traffic = async () => {
|
|
125
125
|
for (let i = 0; i < 8; i++) {
|
|
126
126
|
await Promise.all([
|
|
127
|
-
fetch(`${urlA}/write`, {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
127
|
+
fetch(`${urlA}/write`, {
|
|
128
|
+
method: "POST",
|
|
129
|
+
headers: { "content-type": "application/json" },
|
|
130
|
+
body: "{}",
|
|
131
|
+
}),
|
|
132
|
+
fetch(`${urlB}/write`, {
|
|
133
|
+
method: "POST",
|
|
134
|
+
headers: { "content-type": "application/json" },
|
|
135
|
+
body: "{}",
|
|
136
|
+
}),
|
|
137
|
+
fetch(`${urlA}/emit`, {
|
|
138
|
+
method: "POST",
|
|
139
|
+
headers: { "content-type": "application/json" },
|
|
140
|
+
body: "{}",
|
|
141
|
+
}),
|
|
142
|
+
fetch(`${urlB}/emit`, {
|
|
143
|
+
method: "POST",
|
|
144
|
+
headers: { "content-type": "application/json" },
|
|
145
|
+
body: "{}",
|
|
146
|
+
}),
|
|
131
147
|
fetch(`${urlA}/ping`),
|
|
132
148
|
fetch(`${urlB}/ping`),
|
|
133
149
|
]);
|
|
@@ -150,7 +166,11 @@ describe.skipIf(!LIVE)("horizontal — two OS processes, Postgres + Redis", () =
|
|
|
150
166
|
expect(limited).toBeGreaterThanOrEqual(1);
|
|
151
167
|
|
|
152
168
|
// (2) Start durable on A; hang mid-step.
|
|
153
|
-
void fetch(`${urlA}/charge`, {
|
|
169
|
+
void fetch(`${urlA}/charge`, {
|
|
170
|
+
method: "POST",
|
|
171
|
+
headers: { "content-type": "application/json" },
|
|
172
|
+
body: "{}",
|
|
173
|
+
});
|
|
154
174
|
expect(await waitForFile(join(dir, "hang-inst-a.json"), 15_000)).toBe(true);
|
|
155
175
|
|
|
156
176
|
// (5) Kill A mid combined scenario — B keeps serving.
|
|
@@ -177,8 +197,24 @@ describe.skipIf(!LIVE)("horizontal — two OS processes, Postgres + Redis", () =
|
|
|
177
197
|
// Keep traffic going on survivor (no deadlock).
|
|
178
198
|
await trafficPromise.catch(() => {});
|
|
179
199
|
for (let i = 0; i < 4; i++) {
|
|
180
|
-
expect(
|
|
181
|
-
|
|
200
|
+
expect(
|
|
201
|
+
(
|
|
202
|
+
await fetch(`${urlB}/write`, {
|
|
203
|
+
method: "POST",
|
|
204
|
+
headers: { "content-type": "application/json" },
|
|
205
|
+
body: "{}",
|
|
206
|
+
})
|
|
207
|
+
).status,
|
|
208
|
+
).toBe(200);
|
|
209
|
+
expect(
|
|
210
|
+
(
|
|
211
|
+
await fetch(`${urlB}/emit`, {
|
|
212
|
+
method: "POST",
|
|
213
|
+
headers: { "content-type": "application/json" },
|
|
214
|
+
body: "{}",
|
|
215
|
+
})
|
|
216
|
+
).status,
|
|
217
|
+
).toBe(200);
|
|
182
218
|
}
|
|
183
219
|
|
|
184
220
|
// (1) Cron fired exactly once across both instances.
|
|
@@ -220,6 +256,94 @@ describe.skipIf(!LIVE)("horizontal — two OS processes, Postgres + Redis", () =
|
|
|
220
256
|
await cleanupC.close();
|
|
221
257
|
}
|
|
222
258
|
}, 90_000);
|
|
259
|
+
|
|
260
|
+
test("broadcast, live, and cache invalidation reach the other instance", async () => {
|
|
261
|
+
const pg = LIVE!.pg;
|
|
262
|
+
const redis = LIVE!.redis;
|
|
263
|
+
const dir = await mkdtemp(join(tmpdir(), "oke-horizontal-bus-"));
|
|
264
|
+
const signalPath = join(dir, "signal.json");
|
|
265
|
+
const post = (url: string) =>
|
|
266
|
+
fetch(url, {
|
|
267
|
+
method: "POST",
|
|
268
|
+
headers: { "content-type": "application/json" },
|
|
269
|
+
body: "{}",
|
|
270
|
+
});
|
|
271
|
+
const spawn = (instanceId: string, port: number) =>
|
|
272
|
+
Bun.spawn({
|
|
273
|
+
cmd: [
|
|
274
|
+
"bun",
|
|
275
|
+
childPath,
|
|
276
|
+
"serve",
|
|
277
|
+
instanceId,
|
|
278
|
+
String(port),
|
|
279
|
+
pg,
|
|
280
|
+
redis,
|
|
281
|
+
signalPath,
|
|
282
|
+
dir,
|
|
283
|
+
"5000",
|
|
284
|
+
],
|
|
285
|
+
stdout: "pipe",
|
|
286
|
+
stderr: "pipe",
|
|
287
|
+
env: {
|
|
288
|
+
...process.env,
|
|
289
|
+
DATABASE_URL: pg,
|
|
290
|
+
REDIS_URL: redis,
|
|
291
|
+
OKE_HORIZONTAL_REDIS_SIGNALS: "1",
|
|
292
|
+
},
|
|
293
|
+
});
|
|
294
|
+
const a = spawn("bus-a", 0);
|
|
295
|
+
const b = spawn("bus-b", 0);
|
|
296
|
+
try {
|
|
297
|
+
expect(await waitForFile(join(dir, "ready-bus-a.json"))).toBe(true);
|
|
298
|
+
expect(await waitForFile(join(dir, "ready-bus-b.json"))).toBe(true);
|
|
299
|
+
const readyA = (await Bun.file(join(dir, "ready-bus-a.json")).json()) as { port: number };
|
|
300
|
+
const readyB = (await Bun.file(join(dir, "ready-bus-b.json")).json()) as { port: number };
|
|
301
|
+
const urlA = `http://127.0.0.1:${readyA.port}`;
|
|
302
|
+
const urlB = `http://127.0.0.1:${readyB.port}`;
|
|
303
|
+
const first = (await (await fetch(`${urlA}/cached`)).json()) as { data: { reads: number } };
|
|
304
|
+
const second = (await (await fetch(`${urlA}/cached`)).json()) as { data: { reads: number } };
|
|
305
|
+
expect(first.data.reads).toBe(1);
|
|
306
|
+
expect(second.data.reads).toBe(1);
|
|
307
|
+
expect((await post(`${urlB}/write`)).status).toBe(200);
|
|
308
|
+
expect(
|
|
309
|
+
await waitFor(async () => {
|
|
310
|
+
const body = (await (await fetch(`${urlA}/cached`)).json()) as {
|
|
311
|
+
data: { reads: number };
|
|
312
|
+
};
|
|
313
|
+
return body.data.reads === 2;
|
|
314
|
+
}),
|
|
315
|
+
).toBe(true);
|
|
316
|
+
expect((await post(`${urlA}/news`)).status).toBe(200);
|
|
317
|
+
expect((await post(`${urlA}/feed`)).status).toBe(200);
|
|
318
|
+
expect(
|
|
319
|
+
await waitFor(async () => {
|
|
320
|
+
const broadcast = await loadJsonl(join(dir, "broadcast.jsonl"));
|
|
321
|
+
const live = await loadJsonl(join(dir, "live.jsonl"));
|
|
322
|
+
const ids = (rows: Array<Record<string, unknown>>) =>
|
|
323
|
+
new Set(rows.map((row) => row.instanceId));
|
|
324
|
+
return (
|
|
325
|
+
ids(broadcast).has("bus-a") &&
|
|
326
|
+
ids(broadcast).has("bus-b") &&
|
|
327
|
+
ids(live).has("bus-a") &&
|
|
328
|
+
ids(live).has("bus-b")
|
|
329
|
+
);
|
|
330
|
+
}),
|
|
331
|
+
).toBe(true);
|
|
332
|
+
} finally {
|
|
333
|
+
try {
|
|
334
|
+
a.kill(9);
|
|
335
|
+
} catch {
|
|
336
|
+
/* already dead */
|
|
337
|
+
}
|
|
338
|
+
try {
|
|
339
|
+
b.kill(9);
|
|
340
|
+
} catch {
|
|
341
|
+
/* ignore */
|
|
342
|
+
}
|
|
343
|
+
await Promise.allSettled([a.exited, b.exited]);
|
|
344
|
+
await rm(dir, { recursive: true, force: true });
|
|
345
|
+
}
|
|
346
|
+
}, 60_000);
|
|
223
347
|
});
|
|
224
348
|
|
|
225
349
|
if (!LIVE) {
|
|
@@ -349,12 +349,12 @@ describe("HTTP idempotency", () => {
|
|
|
349
349
|
expect(await durableStore.idempotency!.forfeit(held!, held!.claimToken)).toBe(true);
|
|
350
350
|
await Bun.sleep(120);
|
|
351
351
|
const resumed = durable.fetch(post({ n: 1 }));
|
|
352
|
-
|
|
353
|
-
|
|
352
|
+
// The holder's heartbeat kept the lease, so the same process does not
|
|
353
|
+
// start a second session for the run still inside mid-flight.
|
|
354
|
+
expect((await resumed).status).toBe(409);
|
|
355
|
+
expect(blockers).toHaveLength(1);
|
|
354
356
|
blockers[0]!();
|
|
355
|
-
blockers[1]!();
|
|
356
357
|
expect((await hung).status).toBe(200);
|
|
357
|
-
expect((await resumed).status).toBe(200);
|
|
358
358
|
expect(step1).toEqual(["step1"]);
|
|
359
359
|
}, 30_000);
|
|
360
360
|
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
type PostgresJournalStore,
|
|
23
23
|
} from "../drivers/journal-postgres.ts";
|
|
24
24
|
import { oke, type OkeApp } from "./app.ts";
|
|
25
|
+
import { clearJournalHeartbeats } from "./journal.ts";
|
|
25
26
|
import type { JournalRuntime } from "./boot-bind/journal.ts";
|
|
26
27
|
import { flow, resetFlowSeq, type AnyFlowDef } from "./flow.ts";
|
|
27
28
|
import type { Binding } from "./on.ts";
|
|
@@ -47,6 +48,7 @@ async function bootDurableApp(options: {
|
|
|
47
48
|
readonly name: string;
|
|
48
49
|
readonly journal: JournalRuntime;
|
|
49
50
|
readonly bindings: readonly Binding[];
|
|
51
|
+
readonly now?: () => number;
|
|
50
52
|
}): Promise<OkeApp> {
|
|
51
53
|
const app = oke({
|
|
52
54
|
name: options.name,
|
|
@@ -57,6 +59,7 @@ async function bootDurableApp(options: {
|
|
|
57
59
|
// oke() in the process — sibling apps need their own copies.
|
|
58
60
|
bindings: [...options.bindings],
|
|
59
61
|
elements: { journal: options.journal },
|
|
62
|
+
...(options.now ? { fx: { now: options.now } } : {}),
|
|
60
63
|
});
|
|
61
64
|
await app.boot();
|
|
62
65
|
return app;
|
|
@@ -127,11 +130,18 @@ describe("journal boot — shared store, real boot paths (fake SQL)", () => {
|
|
|
127
130
|
bindings,
|
|
128
131
|
});
|
|
129
132
|
// Start the run through the real HTTP path; it hangs inside step 2.
|
|
130
|
-
void appA.fetch(
|
|
133
|
+
void appA.fetch(
|
|
134
|
+
new Request("http://localhost/charge", {
|
|
135
|
+
method: "POST",
|
|
136
|
+
headers: { "content-type": "application/json" },
|
|
137
|
+
body: "{}",
|
|
138
|
+
}),
|
|
139
|
+
);
|
|
131
140
|
expect(await waitFor(async () => blockers.length === 1)).toBe(true);
|
|
132
141
|
const runId = (await store.list())[0]!.id;
|
|
133
|
-
// "Crash":
|
|
134
|
-
//
|
|
142
|
+
// "Crash": the process dies, so its heartbeat timers die with it.
|
|
143
|
+
// The in-flight promise stays dangling and the lease (120ms) expires.
|
|
144
|
+
clearJournalHeartbeats();
|
|
135
145
|
|
|
136
146
|
await Bun.sleep(300);
|
|
137
147
|
const appB = await bootDurableApp({
|
|
@@ -165,7 +175,11 @@ describe("journal boot — shared store, real boot paths (fake SQL)", () => {
|
|
|
165
175
|
bindings,
|
|
166
176
|
});
|
|
167
177
|
const parked = await appA.fetch(
|
|
168
|
-
new Request("http://localhost/sleep", {
|
|
178
|
+
new Request("http://localhost/sleep", {
|
|
179
|
+
method: "POST",
|
|
180
|
+
headers: { "content-type": "application/json" },
|
|
181
|
+
body: "{}",
|
|
182
|
+
}),
|
|
169
183
|
);
|
|
170
184
|
expect(parked.status).toBe(204); // durable park — lease released, row sleeping
|
|
171
185
|
const runId = (await store.list())[0]!.id;
|
|
@@ -198,7 +212,13 @@ describe("journal boot — shared store, real boot paths (fake SQL)", () => {
|
|
|
198
212
|
journal: journalRuntime(store, "A", 5_000),
|
|
199
213
|
bindings,
|
|
200
214
|
});
|
|
201
|
-
await appA.fetch(
|
|
215
|
+
await appA.fetch(
|
|
216
|
+
new Request("http://localhost/sleep", {
|
|
217
|
+
method: "POST",
|
|
218
|
+
headers: { "content-type": "application/json" },
|
|
219
|
+
body: "{}",
|
|
220
|
+
}),
|
|
221
|
+
);
|
|
202
222
|
await appA.stop();
|
|
203
223
|
|
|
204
224
|
// Both survivors boot while the sleep is future (orphan scan skips it).
|
|
@@ -217,10 +237,61 @@ describe("journal boot — shared store, real boot paths (fake SQL)", () => {
|
|
|
217
237
|
await Promise.all([appB.resumeDurable(), appC.resumeDurable()]);
|
|
218
238
|
|
|
219
239
|
expect(confirms).toHaveLength(1);
|
|
240
|
+
|
|
220
241
|
await appB.stop();
|
|
221
242
|
await appC.stop();
|
|
222
243
|
});
|
|
223
244
|
|
|
245
|
+
test("resume uses a live clock: a lease written after the tick sample is not already expired", async () => {
|
|
246
|
+
let clock = 10_000_000;
|
|
247
|
+
const store = await createPostgresJournalStore({ sql: createPostgresJournalFake() });
|
|
248
|
+
const confirms: string[] = [];
|
|
249
|
+
const bindings = [sleeperBinding("120ms", confirms)];
|
|
250
|
+
const acquiredAt: number[] = [];
|
|
251
|
+
const acquire = store.acquireLease.bind(store);
|
|
252
|
+
store.acquireLease = async (runId, instanceId, at, leaseMs) => {
|
|
253
|
+
acquiredAt.push(at);
|
|
254
|
+
return acquire(runId, instanceId, at, leaseMs);
|
|
255
|
+
};
|
|
256
|
+
const claim = store.claimDueSleep.bind(store);
|
|
257
|
+
store.claimDueSleep = async (instanceId, at, leaseMs) => {
|
|
258
|
+
const due = await claim(instanceId, at, leaseMs);
|
|
259
|
+
// The tick already sampled `at`. A live run clock must see this jump.
|
|
260
|
+
if (due) clock += 2_700;
|
|
261
|
+
return due;
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const appA = await bootDurableApp({
|
|
265
|
+
name: "clock-a",
|
|
266
|
+
journal: journalRuntime(store, "A", 5_000),
|
|
267
|
+
bindings,
|
|
268
|
+
now: () => clock,
|
|
269
|
+
});
|
|
270
|
+
await appA.fetch(
|
|
271
|
+
new Request("http://localhost/sleep", {
|
|
272
|
+
method: "POST",
|
|
273
|
+
headers: { "content-type": "application/json" },
|
|
274
|
+
body: "{}",
|
|
275
|
+
}),
|
|
276
|
+
);
|
|
277
|
+
await appA.stop();
|
|
278
|
+
|
|
279
|
+
const appB = await bootDurableApp({
|
|
280
|
+
name: "clock-b",
|
|
281
|
+
journal: journalRuntime(store, "B", 5_000),
|
|
282
|
+
bindings,
|
|
283
|
+
now: () => clock,
|
|
284
|
+
});
|
|
285
|
+
const before = clock;
|
|
286
|
+
clock = before + 500;
|
|
287
|
+
acquiredAt.length = 0;
|
|
288
|
+
await appB.resumeDurable();
|
|
289
|
+
|
|
290
|
+
const stamped = acquiredAt.find((at) => at >= before + 2_700);
|
|
291
|
+
expect(stamped).toBeDefined();
|
|
292
|
+
await appB.stop();
|
|
293
|
+
});
|
|
294
|
+
|
|
224
295
|
test("boot orphan scan skips runs held under a live lease", async () => {
|
|
225
296
|
const store = await createPostgresJournalStore({ sql: createPostgresJournalFake() });
|
|
226
297
|
const step1Calls: string[] = [];
|
|
@@ -233,7 +304,13 @@ describe("journal boot — shared store, real boot paths (fake SQL)", () => {
|
|
|
233
304
|
journal: journalRuntime(store, "A", 30_000),
|
|
234
305
|
bindings,
|
|
235
306
|
});
|
|
236
|
-
void appA.fetch(
|
|
307
|
+
void appA.fetch(
|
|
308
|
+
new Request("http://localhost/charge", {
|
|
309
|
+
method: "POST",
|
|
310
|
+
headers: { "content-type": "application/json" },
|
|
311
|
+
body: "{}",
|
|
312
|
+
}),
|
|
313
|
+
);
|
|
237
314
|
expect(await waitFor(async () => blockers.length === 1)).toBe(true);
|
|
238
315
|
|
|
239
316
|
// B boots while A's lease is live — must not steal the run.
|
|
@@ -5,7 +5,10 @@
|
|
|
5
5
|
|
|
6
6
|
import { describe, expect, test } from "bun:test";
|
|
7
7
|
|
|
8
|
+
import { runDurable } from "../elements/clock/durable.ts";
|
|
9
|
+
import { flow } from "./flow.ts";
|
|
8
10
|
import {
|
|
11
|
+
clearJournalHeartbeats,
|
|
9
12
|
createJournal,
|
|
10
13
|
createMemoryJournalStore,
|
|
11
14
|
hasJournalLease,
|
|
@@ -13,6 +16,7 @@ import {
|
|
|
13
16
|
isJournalSuspend,
|
|
14
17
|
JOURNAL_DEFAULT_LEASE_MS,
|
|
15
18
|
} from "./journal.ts";
|
|
19
|
+
import { OkeError } from "./errors.ts";
|
|
16
20
|
|
|
17
21
|
describe("journal lease (memory store)", () => {
|
|
18
22
|
test("built-in stores expose the lease surface", () => {
|
|
@@ -139,4 +143,158 @@ describe("journal lease (memory store)", () => {
|
|
|
139
143
|
const again = await journal.resume(session.runId);
|
|
140
144
|
expect(await again.step("s", () => 2)).toBe(1);
|
|
141
145
|
});
|
|
146
|
+
|
|
147
|
+
test("a stale lease holder cannot append", async () => {
|
|
148
|
+
let t = 1_000;
|
|
149
|
+
const store = createMemoryJournalStore();
|
|
150
|
+
const a = createJournal({
|
|
151
|
+
store,
|
|
152
|
+
now: () => t,
|
|
153
|
+
lease: { instanceId: "a", leaseMs: 100 },
|
|
154
|
+
codeVersion: "v1",
|
|
155
|
+
});
|
|
156
|
+
const session = await a.start("charge");
|
|
157
|
+
await session.step("create-intent", () => ({ id: "pi_1" }));
|
|
158
|
+
t = 1_200;
|
|
159
|
+
const b = createJournal({
|
|
160
|
+
store,
|
|
161
|
+
now: () => t,
|
|
162
|
+
lease: { instanceId: "b", leaseMs: 100 },
|
|
163
|
+
codeVersion: "v1",
|
|
164
|
+
});
|
|
165
|
+
await b.resume(session.runId);
|
|
166
|
+
try {
|
|
167
|
+
await session.step("capture", () => ({ ok: true }));
|
|
168
|
+
expect.unreachable("stale holder must be rejected");
|
|
169
|
+
} catch (err) {
|
|
170
|
+
expect(err).toBeInstanceOf(OkeError);
|
|
171
|
+
expect((err as OkeError).code).toBe(1074);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("heartbeat extends the lease, and a lost lease does not persist the step", async () => {
|
|
176
|
+
let t = 1_000;
|
|
177
|
+
const store = createMemoryJournalStore();
|
|
178
|
+
const a = createJournal({
|
|
179
|
+
store,
|
|
180
|
+
now: () => t,
|
|
181
|
+
lease: { instanceId: "a", leaseMs: 60 },
|
|
182
|
+
codeVersion: "v1",
|
|
183
|
+
});
|
|
184
|
+
const session = await a.start("charge");
|
|
185
|
+
t = 1_030;
|
|
186
|
+
await Bun.sleep(40);
|
|
187
|
+
expect((await store.get(session.runId))?.leaseExpiresAt).toBe(1_090);
|
|
188
|
+
t = 2_000;
|
|
189
|
+
const b = createJournal({
|
|
190
|
+
store,
|
|
191
|
+
now: () => t,
|
|
192
|
+
lease: { instanceId: "b", leaseMs: 60 },
|
|
193
|
+
codeVersion: "v1",
|
|
194
|
+
});
|
|
195
|
+
await b.resume(session.runId);
|
|
196
|
+
await Bun.sleep(40);
|
|
197
|
+
await expect(session.step("capture", () => 1)).rejects.toBeInstanceOf(OkeError);
|
|
198
|
+
expect((await store.get(session.runId))?.entries ?? []).toHaveLength(0);
|
|
199
|
+
expect((await store.get(session.runId))?.lockedBy).toBe("b");
|
|
200
|
+
clearJournalHeartbeats();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("replay throws when the next call does not match the journal", async () => {
|
|
204
|
+
const store = createMemoryJournalStore();
|
|
205
|
+
const journal = createJournal({ store, now: () => 1, codeVersion: "v1" });
|
|
206
|
+
const session = await journal.start("charge");
|
|
207
|
+
await session.step("a", () => 1);
|
|
208
|
+
const resumed = await journal.resume(session.runId);
|
|
209
|
+
try {
|
|
210
|
+
await resumed.step("b", () => 2);
|
|
211
|
+
expect.unreachable("divergent replay must throw");
|
|
212
|
+
} catch (err) {
|
|
213
|
+
expect(err).toBeInstanceOf(OkeError);
|
|
214
|
+
expect((err as OkeError).code).toBe(1075);
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("a non-JSON journal value is OKE1077", async () => {
|
|
219
|
+
const store = createMemoryJournalStore();
|
|
220
|
+
const session = await createJournal({ store, now: () => 1 }).start("charge");
|
|
221
|
+
try {
|
|
222
|
+
await session.effect("call", "charge", () => 1n);
|
|
223
|
+
expect.unreachable("bigint must not journal");
|
|
224
|
+
} catch (err) {
|
|
225
|
+
expect(err).toBeInstanceOf(OkeError);
|
|
226
|
+
expect((err as OkeError).code).toBe(1077);
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("a 0.23.1 stamp resumes and is restamped when codeVersion is set", async () => {
|
|
231
|
+
const store = createMemoryJournalStore();
|
|
232
|
+
const started = await createJournal({ store, now: () => 1, codeVersion: "v1" }).start("charge");
|
|
233
|
+
const row = await store.get(started.runId);
|
|
234
|
+
if (row) {
|
|
235
|
+
row.codeVersion = "0.23.1";
|
|
236
|
+
await store.put(row);
|
|
237
|
+
}
|
|
238
|
+
const resumed = await createJournal({ store, now: () => 2, codeVersion: "v9" }).resume(
|
|
239
|
+
started.runId,
|
|
240
|
+
);
|
|
241
|
+
expect(resumed.run.codeVersion).toBe("app:v9");
|
|
242
|
+
expect((await store.get(started.runId))?.status).not.toBe("failed");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("resume rejects a run stamped with another code version", async () => {
|
|
246
|
+
const store = createMemoryJournalStore();
|
|
247
|
+
const started = await createJournal({ store, now: () => 1, codeVersion: "v1" }).start("charge");
|
|
248
|
+
try {
|
|
249
|
+
await createJournal({ store, now: () => 2, codeVersion: "v2" }).resume(started.runId);
|
|
250
|
+
expect.unreachable("version skew must throw");
|
|
251
|
+
} catch (err) {
|
|
252
|
+
expect(err).toBeInstanceOf(OkeError);
|
|
253
|
+
expect((err as OkeError).code).toBe(1076);
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
describe("journal fetch snapshot", () => {
|
|
259
|
+
test("a durable flow replays fx.fetch without calling the network again", async () => {
|
|
260
|
+
const store = createMemoryJournalStore();
|
|
261
|
+
let calls = 0;
|
|
262
|
+
const original = globalThis.fetch;
|
|
263
|
+
globalThis.fetch = (async () => {
|
|
264
|
+
calls += 1;
|
|
265
|
+
return new Response("hello", { status: 201 });
|
|
266
|
+
}) as unknown as typeof fetch;
|
|
267
|
+
const load = flow("load", {
|
|
268
|
+
durable: true,
|
|
269
|
+
do: async (_input, fx) => {
|
|
270
|
+
const res = await fx.step("load", () => fx.fetch("https://example.com/data"));
|
|
271
|
+
return { status: res.status, text: await res.text() };
|
|
272
|
+
},
|
|
273
|
+
});
|
|
274
|
+
try {
|
|
275
|
+
const first = await runDurable({
|
|
276
|
+
flow: load,
|
|
277
|
+
input: {},
|
|
278
|
+
journalStore: store,
|
|
279
|
+
now: () => 1,
|
|
280
|
+
});
|
|
281
|
+
expect(first).toMatchObject({ status: "completed", output: { status: 201, text: "hello" } });
|
|
282
|
+
expect(calls).toBe(1);
|
|
283
|
+
const row = await store.get(first.runId);
|
|
284
|
+
expect(row?.entries.some((entry) => entry.kind === "step" && entry.name === "load")).toBe(
|
|
285
|
+
true,
|
|
286
|
+
);
|
|
287
|
+
const replay = await createJournal({ store, now: () => 2 }).resume(first.runId);
|
|
288
|
+
const again = await replay.step("load", () => {
|
|
289
|
+
calls += 1;
|
|
290
|
+
return new Response("nope", { status: 500 });
|
|
291
|
+
});
|
|
292
|
+
expect(again).toBeInstanceOf(Response);
|
|
293
|
+
expect(again.status).toBe(201);
|
|
294
|
+
expect(await again.text()).toBe("hello");
|
|
295
|
+
expect(calls).toBe(1);
|
|
296
|
+
} finally {
|
|
297
|
+
globalThis.fetch = original;
|
|
298
|
+
}
|
|
299
|
+
});
|
|
142
300
|
});
|