okengine 0.23.0 → 0.23.1
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 +1 -1
- 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/index.mdx +15 -14
- package/site/content/docs/plugins/otp.mdx +3 -3
- 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/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/console/index.ts +1 -1
- package/src/console/ui-next/dist/assets/{access-page-DDKkhT9v.js → access-page-C_N4qvai.js} +1 -1
- package/src/console/ui-next/dist/assets/{agent-disclosure-62Xts8Xi.js → agent-disclosure-Cq1TxniW.js} +1 -1
- package/src/console/ui-next/dist/assets/{cache-glyph-DyeoKHKA.js → cache-glyph-BaI4uxZz.js} +1 -1
- package/src/console/ui-next/dist/assets/{call-pii-button-DvfpLXsU.js → call-pii-button-DNyK7IIY.js} +1 -1
- package/src/console/ui-next/dist/assets/{collapsible-BY03SeCg.js → collapsible-CRhBJMZR.js} +1 -1
- package/src/console/ui-next/dist/assets/{decisions-page-CgkKlA56.js → decisions-page-D9twj8Xy.js} +1 -1
- package/src/console/ui-next/dist/assets/{duration-tone-Y6HLjyJ5.js → duration-tone-CsHWlVPU.js} +1 -1
- package/src/console/ui-next/dist/assets/{flows-page-DLPfNy-_.js → flows-page-ZaTJAhR9.js} +1 -1
- package/src/console/ui-next/dist/assets/{highlighted-json-D7nNzpgB.js → highlighted-json-DQ-EMAfz.js} +1 -1
- package/src/console/ui-next/dist/assets/{http-method-DdL19zzh.js → http-method-Cn_Fl79s.js} +1 -1
- package/src/console/ui-next/dist/assets/{index-C0lc_s9d.js → index-CmRFeWdT.js} +3 -3
- package/src/console/ui-next/dist/assets/{observability-page-CRlcyLCC.js → observability-page-Cn6R_dXz.js} +1 -1
- package/src/console/ui-next/dist/assets/{replica-lag-Bb3FWUu9.js → replica-lag-Bx6EZhSe.js} +1 -1
- package/src/console/ui-next/dist/assets/{request-meta-DYBMWhX8.js → request-meta-DmdiUrfh.js} +1 -1
- package/src/console/ui-next/dist/assets/{store-page-CcE-SXC8.js → store-page-8P6vGXnn.js} +1 -1
- package/src/console/ui-next/dist/assets/{trace-detail-sheet-CKMSEQ4s.js → trace-detail-sheet-BfGvpFSv.js} +1 -1
- package/src/console/ui-next/dist/assets/{tree-expand-toggle-QOfk0M0H.js → tree-expand-toggle-CWdUHbc0.js} +1 -1
- package/src/console/ui-next/dist/assets/{units-page-DKC1CDDd.js → units-page-CZOFZzay.js} +1 -1
- package/src/console/ui-next/dist/assets/{vault-page-B54v4Yit.js → vault-page-ki_ishmX.js} +1 -1
- package/src/console/ui-next/dist/index.html +1 -1
- package/src/drivers/journal-postgres.test.ts +22 -1
- package/src/drivers/journal-postgres.ts +223 -12
- package/src/drivers/signal-compete.test.ts +234 -0
- package/src/drivers/signal-postgres.ts +39 -19
- package/src/drivers/signal-redis.ts +167 -11
- package/src/drivers/signal-types.ts +12 -0
- package/src/elements/ai/mcp-protocol.ts +5 -6
- package/src/elements/ai/runtime.ts +20 -19
- package/src/elements/channel/runtime.ts +11 -0
- package/src/elements/channel/sql-ledger.ts +251 -0
- package/src/elements/signal/runtime.ts +9 -0
- package/src/elements/signal.test.ts +1 -0
- package/src/elements/store/cache.test.ts +120 -15
- package/src/elements/store/cache.ts +200 -39
- package/src/elements/store/prepare-row.test.ts +6 -2
- package/src/elements/store/sql-session.test.ts +2 -0
- package/src/elements/store/sql-session.ts +95 -9
- package/src/elements/store.ts +1 -0
- package/src/kernel/app.ts +32 -16
- package/src/kernel/auto-cache.test.ts +228 -0
- 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 +61 -25
- package/src/kernel/boot.ts +16 -2
- 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 +46 -7
- package/src/kernel/fx.test.ts +1 -3
- package/src/kernel/fx.ts +27 -16
- package/src/kernel/idempotency.test.ts +2 -1
- package/src/kernel/journal.test.ts +114 -0
- package/src/kernel/journal.ts +369 -53
- 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/docs-server.ts +19 -2
- package/src/mcp/mcp.test.ts +83 -0
- package/src/mcp/protocol.ts +8 -2
- package/src/mcp/server.ts +23 -2
- package/src/mcp/versions.ts +60 -0
- package/src/plugins/index.ts +1 -0
- package/src/plugins/mena.ts +56 -0
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { describe, expect, test } from "bun:test";
|
|
7
7
|
|
|
8
|
-
import type
|
|
8
|
+
import { createJournal, type JournalRun } from "../kernel/journal.ts";
|
|
9
9
|
import { createPostgresJournalFake, createPostgresJournalStore } from "./journal-postgres.ts";
|
|
10
10
|
|
|
11
11
|
function seedRun(patch: Partial<JournalRun> & { id: string }): JournalRun {
|
|
@@ -174,4 +174,25 @@ describe("postgres JournalStore (fake)", () => {
|
|
|
174
174
|
expect(row?.leaseExpiresAt).toBeUndefined();
|
|
175
175
|
await store.close();
|
|
176
176
|
});
|
|
177
|
+
|
|
178
|
+
test("step writes stay linear as the journal grows", async () => {
|
|
179
|
+
const store = await createPostgresJournalStore({ sql: createPostgresJournalFake() });
|
|
180
|
+
const journal = createJournal({ store, now: () => 1, codeVersion: "v1" });
|
|
181
|
+
const session = await journal.start("long");
|
|
182
|
+
const steps = 40;
|
|
183
|
+
const payload = "x".repeat(200);
|
|
184
|
+
for (let i = 0; i < steps; i++) {
|
|
185
|
+
await session.step(`s${i}`, () => payload);
|
|
186
|
+
}
|
|
187
|
+
const perStep = JSON.stringify({
|
|
188
|
+
kind: "step",
|
|
189
|
+
name: "s0",
|
|
190
|
+
value: payload,
|
|
191
|
+
at: 1,
|
|
192
|
+
}).length;
|
|
193
|
+
expect(store.writeBytes).toBeLessThan(steps * (perStep + 80) * 3);
|
|
194
|
+
const row = await store.get(session.runId);
|
|
195
|
+
expect(row?.entries).toHaveLength(steps);
|
|
196
|
+
await store.close();
|
|
197
|
+
});
|
|
177
198
|
});
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Same concurrency physics as Signal's `once` delivery and Clock's postgres
|
|
5
5
|
* CronStore: claim with `FOR UPDATE SKIP LOCKED`; a crashed holder's lease is
|
|
6
|
-
* reclaimed lazily on the next claim attempt (no sweeper
|
|
7
|
-
*
|
|
6
|
+
* reclaimed lazily on the next claim attempt (no sweeper). Each acquire bumps
|
|
7
|
+
* a fencing token; writes from a previous holder are rejected. Entries are
|
|
8
|
+
* append-only rows, not a rewritten JSON blob.
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
11
|
import {
|
|
@@ -22,6 +23,7 @@ import {
|
|
|
22
23
|
} from "../kernel/idempotency-store.ts";
|
|
23
24
|
import type { DecisionLabelStore } from "../kernel/decision-label-store.ts";
|
|
24
25
|
import type { AgentEventStore } from "../kernel/agent-event-store.ts";
|
|
26
|
+
import { throwOke } from "../kernel/errors.ts";
|
|
25
27
|
import { lazyRequire } from "../kernel/lazy-require.ts";
|
|
26
28
|
import {
|
|
27
29
|
JOURNAL_DEFAULT_LEASE_MS,
|
|
@@ -29,6 +31,7 @@ import {
|
|
|
29
31
|
type JournalLeaseStore,
|
|
30
32
|
type JournalRun,
|
|
31
33
|
type JournalStore,
|
|
34
|
+
type JournalWriteFence,
|
|
32
35
|
} from "../kernel/journal.ts";
|
|
33
36
|
import {
|
|
34
37
|
resolvePostgresUrl,
|
|
@@ -53,6 +56,19 @@ interface JournalDbRow {
|
|
|
53
56
|
created_at: number;
|
|
54
57
|
updated_at: number;
|
|
55
58
|
tenant: string | null;
|
|
59
|
+
lease_token: number | null;
|
|
60
|
+
code_version: string | null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** One append-only journal entry row. */
|
|
64
|
+
interface JournalEntryDbRow {
|
|
65
|
+
run_id: string;
|
|
66
|
+
seq: number;
|
|
67
|
+
kind: string;
|
|
68
|
+
name: string | null;
|
|
69
|
+
resource: string | null;
|
|
70
|
+
value: string | null;
|
|
71
|
+
at: number;
|
|
56
72
|
}
|
|
57
73
|
|
|
58
74
|
/** Minimal SQL + transaction surface for the postgres journal store. */
|
|
@@ -85,7 +101,11 @@ const CLAIM_DUE_SQL = `SELECT * FROM oke_journal_runs WHERE status='sleeping' AN
|
|
|
85
101
|
/** Boot-time orphan discovery: running/sleeping runs with no live lease. */
|
|
86
102
|
const ORPHANS_SQL = `SELECT * FROM oke_journal_runs WHERE (status='running' OR status='sleeping' OR status='compensating') AND ((locked_by IS NULL) OR (lease_expires_at IS NOT NULL AND lease_expires_at<=?))`;
|
|
87
103
|
|
|
88
|
-
const UPDATE_LEASE_SQL = `UPDATE oke_journal_runs SET locked_by = ?, lease_expires_at =
|
|
104
|
+
const UPDATE_LEASE_SQL = `UPDATE oke_journal_runs SET locked_by = ?, lease_expires_at = ?, lease_token = COALESCE(lease_token, 0) + 1 WHERE id = ?`;
|
|
105
|
+
|
|
106
|
+
const INSERT_ENTRY_SQL = `INSERT INTO oke_journal_entries (run_id, seq, kind, name, resource, value, at) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT (run_id, seq) DO UPDATE SET kind = EXCLUDED.kind, name = EXCLUDED.name, resource = EXCLUDED.resource, value = EXCLUDED.value, at = EXCLUDED.at`;
|
|
107
|
+
|
|
108
|
+
const SELECT_ENTRIES_SQL = `SELECT * FROM oke_journal_entries WHERE run_id = ? ORDER BY seq ASC`;
|
|
89
109
|
|
|
90
110
|
/** Guarded release — never clears another holder's lease. */
|
|
91
111
|
const RELEASE_LEASE_SQL = `UPDATE oke_journal_runs SET locked_by = NULL, lease_expires_at = NULL WHERE id = ? AND locked_by = ?`;
|
|
@@ -171,9 +191,70 @@ function rowToRun(row: Record<string, unknown>): JournalRun {
|
|
|
171
191
|
if (r.tenant !== null && r.tenant !== undefined && String(r.tenant).length > 0) {
|
|
172
192
|
run.tenant = String(r.tenant);
|
|
173
193
|
}
|
|
194
|
+
const leaseToken = numOrNull(r.lease_token);
|
|
195
|
+
if (leaseToken !== null) run.leaseToken = leaseToken;
|
|
196
|
+
if (
|
|
197
|
+
r.code_version !== null &&
|
|
198
|
+
r.code_version !== undefined &&
|
|
199
|
+
String(r.code_version).length > 0
|
|
200
|
+
) {
|
|
201
|
+
run.codeVersion = String(r.code_version);
|
|
202
|
+
}
|
|
174
203
|
return run;
|
|
175
204
|
}
|
|
176
205
|
|
|
206
|
+
function entryToParams(runId: string, seq: number, entry: JournalEntry): unknown[] {
|
|
207
|
+
if (entry.kind === "step") {
|
|
208
|
+
return [runId, seq, "step", entry.name, null, JSON.stringify(entry.value ?? null), entry.at];
|
|
209
|
+
}
|
|
210
|
+
if (entry.kind === "sleep") {
|
|
211
|
+
return [
|
|
212
|
+
runId,
|
|
213
|
+
seq,
|
|
214
|
+
"sleep",
|
|
215
|
+
entry.label,
|
|
216
|
+
entry.duration,
|
|
217
|
+
JSON.stringify(entry.wakeAt),
|
|
218
|
+
entry.at,
|
|
219
|
+
];
|
|
220
|
+
}
|
|
221
|
+
return [
|
|
222
|
+
runId,
|
|
223
|
+
seq,
|
|
224
|
+
"effect",
|
|
225
|
+
entry.effectKind,
|
|
226
|
+
entry.resource,
|
|
227
|
+
JSON.stringify(entry.value ?? null),
|
|
228
|
+
entry.at,
|
|
229
|
+
];
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function entryFromRow(row: Record<string, unknown>): JournalEntry {
|
|
233
|
+
const kind = String(row.kind);
|
|
234
|
+
const at = Number(row.at);
|
|
235
|
+
const value =
|
|
236
|
+
row.value === null || row.value === undefined ? null : JSON.parse(String(row.value));
|
|
237
|
+
if (kind === "step") {
|
|
238
|
+
return { kind: "step", name: String(row.name ?? ""), value, at };
|
|
239
|
+
}
|
|
240
|
+
if (kind === "sleep") {
|
|
241
|
+
return {
|
|
242
|
+
kind: "sleep",
|
|
243
|
+
label: String(row.name ?? ""),
|
|
244
|
+
duration: String(row.resource ?? ""),
|
|
245
|
+
wakeAt: Number(value),
|
|
246
|
+
at,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
return {
|
|
250
|
+
kind: "effect",
|
|
251
|
+
effectKind: String(row.name ?? ""),
|
|
252
|
+
resource: String(row.resource ?? ""),
|
|
253
|
+
value,
|
|
254
|
+
at,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
177
258
|
function runToParams(run: JournalRun): unknown[] {
|
|
178
259
|
return [
|
|
179
260
|
run.id,
|
|
@@ -189,6 +270,8 @@ function runToParams(run: JournalRun): unknown[] {
|
|
|
189
270
|
run.createdAt,
|
|
190
271
|
run.updatedAt,
|
|
191
272
|
run.tenant ?? null,
|
|
273
|
+
run.leaseToken ?? null,
|
|
274
|
+
run.codeVersion ?? null,
|
|
192
275
|
];
|
|
193
276
|
}
|
|
194
277
|
|
|
@@ -209,6 +292,18 @@ async function ensureSchema(sql: PostgresJournalSql): Promise<void> {
|
|
|
209
292
|
tenant TEXT
|
|
210
293
|
)`);
|
|
211
294
|
await sql.exec(`ALTER TABLE oke_journal_runs ADD COLUMN IF NOT EXISTS tenant TEXT`);
|
|
295
|
+
await sql.exec(`ALTER TABLE oke_journal_runs ADD COLUMN IF NOT EXISTS lease_token BIGINT`);
|
|
296
|
+
await sql.exec(`ALTER TABLE oke_journal_runs ADD COLUMN IF NOT EXISTS code_version TEXT`);
|
|
297
|
+
await sql.exec(`CREATE TABLE IF NOT EXISTS oke_journal_entries (
|
|
298
|
+
run_id TEXT NOT NULL,
|
|
299
|
+
seq BIGINT NOT NULL,
|
|
300
|
+
kind TEXT NOT NULL,
|
|
301
|
+
name TEXT,
|
|
302
|
+
resource TEXT,
|
|
303
|
+
value TEXT,
|
|
304
|
+
at BIGINT NOT NULL,
|
|
305
|
+
PRIMARY KEY (run_id, seq)
|
|
306
|
+
)`);
|
|
212
307
|
await sql.exec(
|
|
213
308
|
`CREATE INDEX IF NOT EXISTS oke_journal_runs_wake ON oke_journal_runs (status, wake_at)`,
|
|
214
309
|
);
|
|
@@ -290,6 +385,7 @@ export function createPostgresJournalFake(): PostgresJournalSql & {
|
|
|
290
385
|
event_type: string | null;
|
|
291
386
|
event_name: string | null;
|
|
292
387
|
}[];
|
|
388
|
+
entries: JournalEntryDbRow[];
|
|
293
389
|
};
|
|
294
390
|
|
|
295
391
|
let committed: State = {
|
|
@@ -300,6 +396,7 @@ export function createPostgresJournalFake(): PostgresJournalSql & {
|
|
|
300
396
|
candidates: [],
|
|
301
397
|
agentRuns: [],
|
|
302
398
|
agentEvents: [],
|
|
399
|
+
entries: [],
|
|
303
400
|
};
|
|
304
401
|
let active: { state: State; locked: Set<string>; done: boolean } | null = null;
|
|
305
402
|
/** Run ids held by other active transactions (SKIP LOCKED). */
|
|
@@ -320,6 +417,7 @@ export function createPostgresJournalFake(): PostgresJournalSql & {
|
|
|
320
417
|
candidates: s.candidates.map((r) => ({ ...r })),
|
|
321
418
|
agentRuns: s.agentRuns.map((r) => ({ ...r })),
|
|
322
419
|
agentEvents: s.agentEvents.map((r) => ({ ...r })),
|
|
420
|
+
entries: s.entries.map((r) => ({ ...r })),
|
|
323
421
|
};
|
|
324
422
|
}
|
|
325
423
|
|
|
@@ -481,6 +579,14 @@ export function createPostgresJournalFake(): PostgresJournalSql & {
|
|
|
481
579
|
.map((row) => ({ ...row }));
|
|
482
580
|
}
|
|
483
581
|
|
|
582
|
+
if (/FROM\s+oke_journal_entries/i.test(text)) {
|
|
583
|
+
const runId = String(params[0] ?? "");
|
|
584
|
+
return state.entries
|
|
585
|
+
.filter((row) => row.run_id === runId)
|
|
586
|
+
.sort((a, b) => a.seq - b.seq)
|
|
587
|
+
.map((row) => ({ ...row }));
|
|
588
|
+
}
|
|
589
|
+
|
|
484
590
|
throw new Error(`postgres journal fake: unsupported query: ${sql}`);
|
|
485
591
|
},
|
|
486
592
|
async exec(sql, params = []) {
|
|
@@ -518,6 +624,14 @@ export function createPostgresJournalFake(): PostgresJournalSql & {
|
|
|
518
624
|
created_at: Number(record.created_at ?? 0),
|
|
519
625
|
updated_at: Number(record.updated_at ?? 0),
|
|
520
626
|
tenant: (record.tenant as string | null) ?? null,
|
|
627
|
+
lease_token:
|
|
628
|
+
record.lease_token === undefined || record.lease_token === null
|
|
629
|
+
? (state.rows.find((r) => r.id === String(record.id))?.lease_token ?? null)
|
|
630
|
+
: Number(record.lease_token),
|
|
631
|
+
code_version:
|
|
632
|
+
record.code_version === undefined || record.code_version === null
|
|
633
|
+
? (state.rows.find((r) => r.id === String(record.id))?.code_version ?? null)
|
|
634
|
+
: String(record.code_version),
|
|
521
635
|
};
|
|
522
636
|
const idx = state.rows.findIndex((r) => r.id === next.id);
|
|
523
637
|
if (idx >= 0) state.rows[idx] = next;
|
|
@@ -526,15 +640,33 @@ export function createPostgresJournalFake(): PostgresJournalSql & {
|
|
|
526
640
|
}
|
|
527
641
|
|
|
528
642
|
const updLease =
|
|
529
|
-
/^UPDATE\s+oke_journal_runs\s+SET\s+locked_by\s*=\s*\?,\s*lease_expires_at\s*=\s
|
|
643
|
+
/^UPDATE\s+oke_journal_runs\s+SET\s+locked_by\s*=\s*\?,\s*lease_expires_at\s*=\s*\?/i.exec(
|
|
530
644
|
text,
|
|
531
645
|
);
|
|
532
|
-
if (updLease) {
|
|
533
|
-
const
|
|
646
|
+
if (updLease && /WHERE\s+id\s*=\s*\?/i.test(text) && !/locked_by\s*=\s*NULL/i.test(text)) {
|
|
647
|
+
const id = String(params[params.length - 1]);
|
|
648
|
+
const row = state.rows.find((r) => r.id === id);
|
|
534
649
|
if (!row) return { changes: 0 };
|
|
535
650
|
row.locked_by = params[0] === null ? null : String(params[0]);
|
|
536
651
|
row.lease_expires_at =
|
|
537
652
|
params[1] === null || params[1] === undefined ? null : Number(params[1]);
|
|
653
|
+
if (/lease_token/i.test(text)) row.lease_token = (row.lease_token ?? 0) + 1;
|
|
654
|
+
return { changes: 1 };
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
if (/^INSERT\s+INTO\s+oke_journal_entries\b/i.test(text)) {
|
|
658
|
+
const row: JournalEntryDbRow = {
|
|
659
|
+
run_id: String(params[0]),
|
|
660
|
+
seq: Number(params[1]),
|
|
661
|
+
kind: String(params[2]),
|
|
662
|
+
name: params[3] === null || params[3] === undefined ? null : String(params[3]),
|
|
663
|
+
resource: params[4] === null || params[4] === undefined ? null : String(params[4]),
|
|
664
|
+
value: params[5] === null || params[5] === undefined ? null : String(params[5]),
|
|
665
|
+
at: Number(params[6]),
|
|
666
|
+
};
|
|
667
|
+
const idx = state.entries.findIndex((e) => e.run_id === row.run_id && e.seq === row.seq);
|
|
668
|
+
if (idx >= 0) state.entries[idx] = row;
|
|
669
|
+
else state.entries.push(row);
|
|
538
670
|
return { changes: 1 };
|
|
539
671
|
}
|
|
540
672
|
|
|
@@ -807,12 +939,14 @@ export function createPostgresJournalFake(): PostgresJournalSql & {
|
|
|
807
939
|
return api;
|
|
808
940
|
}
|
|
809
941
|
|
|
810
|
-
const UPSERT_SQL = `INSERT INTO oke_journal_runs (id, flow, input, status, entries, wake_at, error, output, locked_by, lease_expires_at, created_at, updated_at, tenant) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (id) DO UPDATE SET flow = EXCLUDED.flow, input = EXCLUDED.input, status = EXCLUDED.status, entries = EXCLUDED.entries, wake_at = EXCLUDED.wake_at, error = EXCLUDED.error, output = EXCLUDED.output, locked_by = EXCLUDED.locked_by, lease_expires_at = EXCLUDED.lease_expires_at, created_at = EXCLUDED.created_at, updated_at = EXCLUDED.updated_at, tenant = EXCLUDED.tenant`;
|
|
942
|
+
const UPSERT_SQL = `INSERT INTO oke_journal_runs (id, flow, input, status, entries, wake_at, error, output, locked_by, lease_expires_at, created_at, updated_at, tenant, lease_token, code_version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (id) DO UPDATE SET flow = EXCLUDED.flow, input = EXCLUDED.input, status = EXCLUDED.status, entries = EXCLUDED.entries, wake_at = EXCLUDED.wake_at, error = EXCLUDED.error, output = EXCLUDED.output, locked_by = EXCLUDED.locked_by, lease_expires_at = EXCLUDED.lease_expires_at, created_at = EXCLUDED.created_at, updated_at = EXCLUDED.updated_at, tenant = EXCLUDED.tenant, lease_token = EXCLUDED.lease_token, code_version = EXCLUDED.code_version`;
|
|
811
943
|
|
|
812
944
|
/** Postgres journal store with run-level lease coordination. */
|
|
813
945
|
export type PostgresJournalStore = JournalStore &
|
|
814
946
|
JournalLeaseStore & {
|
|
815
947
|
readonly sql: PostgresJournalSql;
|
|
948
|
+
/** Bytes written for entry rows plus header entry payloads. */
|
|
949
|
+
readonly writeBytes: number;
|
|
816
950
|
close(): Promise<void>;
|
|
817
951
|
};
|
|
818
952
|
|
|
@@ -888,22 +1022,77 @@ export async function createPostgresJournalStore(
|
|
|
888
1022
|
|
|
889
1023
|
await ensureSchema(sql);
|
|
890
1024
|
|
|
1025
|
+
let writeBytes = 0;
|
|
1026
|
+
|
|
1027
|
+
async function hydrate(row: Record<string, unknown>): Promise<JournalRun> {
|
|
1028
|
+
const run = rowToRun(row);
|
|
1029
|
+
const entryRows = await sql.query(SELECT_ENTRIES_SQL, [run.id]);
|
|
1030
|
+
if (entryRows.length > 0) {
|
|
1031
|
+
(run as { entries: JournalEntry[] }).entries = entryRows.map((entry) => entryFromRow(entry));
|
|
1032
|
+
return run;
|
|
1033
|
+
}
|
|
1034
|
+
if (run.entries.length > 0) {
|
|
1035
|
+
for (let seq = 0; seq < run.entries.length; seq++) {
|
|
1036
|
+
const entry = run.entries[seq];
|
|
1037
|
+
if (!entry) continue;
|
|
1038
|
+
const params = entryToParams(run.id, seq, entry);
|
|
1039
|
+
writeBytes += JSON.stringify(entry).length;
|
|
1040
|
+
await sql.exec(INSERT_ENTRY_SQL, params);
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
return run;
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
function assertFence(run: JournalRun | undefined, fence: JournalWriteFence | undefined): void {
|
|
1047
|
+
if (!fence || !run) return;
|
|
1048
|
+
const expired = run.leaseExpiresAt !== undefined && run.leaseExpiresAt <= fence.now;
|
|
1049
|
+
if (run.lockedBy !== fence.lockedBy || run.leaseToken !== fence.leaseToken || expired) {
|
|
1050
|
+
throwOke("JOURNAL_STALE_LEASE", { runId: run.id });
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
|
|
891
1054
|
const store: PostgresJournalStore = {
|
|
892
1055
|
sql,
|
|
1056
|
+
get writeBytes() {
|
|
1057
|
+
return writeBytes;
|
|
1058
|
+
},
|
|
893
1059
|
idempotency: createPostgresIdempotencyStore(sql),
|
|
894
1060
|
decisions: lazyDecisionLabels(sql),
|
|
895
1061
|
agentEvents: lazyAgentEvents(sql),
|
|
896
1062
|
async get(runId) {
|
|
897
1063
|
const rows = await sql.query(`SELECT * FROM oke_journal_runs WHERE id = ?`, [runId]);
|
|
898
1064
|
if (!rows[0]) return undefined;
|
|
899
|
-
return
|
|
1065
|
+
return hydrate(rows[0]);
|
|
900
1066
|
},
|
|
901
|
-
async put(run) {
|
|
1067
|
+
async put(run, fence) {
|
|
1068
|
+
if (fence) {
|
|
1069
|
+
const rows = await sql.query(`SELECT * FROM oke_journal_runs WHERE id = ?`, [run.id]);
|
|
1070
|
+
assertFence(rows[0] ? rowToRun(rows[0]) : undefined, fence);
|
|
1071
|
+
}
|
|
1072
|
+
writeBytes += JSON.stringify(run.entries).length;
|
|
902
1073
|
await sql.exec(UPSERT_SQL, runToParams(run));
|
|
1074
|
+
for (let seq = 0; seq < run.entries.length; seq++) {
|
|
1075
|
+
const entry = run.entries[seq];
|
|
1076
|
+
if (!entry) continue;
|
|
1077
|
+
writeBytes += JSON.stringify(entry).length;
|
|
1078
|
+
await sql.exec(INSERT_ENTRY_SQL, entryToParams(run.id, seq, entry));
|
|
1079
|
+
}
|
|
1080
|
+
},
|
|
1081
|
+
async appendEntry(runId, seq, entry, fence) {
|
|
1082
|
+
if (fence) {
|
|
1083
|
+
const rows = await sql.query(`SELECT * FROM oke_journal_runs WHERE id = ?`, [runId]);
|
|
1084
|
+
const current = rows[0] ? rowToRun(rows[0]) : undefined;
|
|
1085
|
+
if (!current) throw new Error(`journal: run "${runId}" not found`);
|
|
1086
|
+
assertFence(current, fence);
|
|
1087
|
+
}
|
|
1088
|
+
writeBytes += JSON.stringify(entry).length;
|
|
1089
|
+
await sql.exec(INSERT_ENTRY_SQL, entryToParams(runId, seq, entry));
|
|
903
1090
|
},
|
|
904
1091
|
async list() {
|
|
905
1092
|
const rows = await sql.query(`SELECT * FROM oke_journal_runs`);
|
|
906
|
-
|
|
1093
|
+
const runs: JournalRun[] = [];
|
|
1094
|
+
for (const row of rows) runs.push(await hydrate(row));
|
|
1095
|
+
return runs;
|
|
907
1096
|
},
|
|
908
1097
|
async acquireLease(runId, instanceId, now, leaseMs) {
|
|
909
1098
|
return sql.begin(async (tx) => {
|
|
@@ -925,10 +1114,28 @@ export async function createPostgresJournalStore(
|
|
|
925
1114
|
return { lease: true, leaseExpiresAt: current.leaseExpiresAt };
|
|
926
1115
|
}
|
|
927
1116
|
const current = rowToRun(row);
|
|
1117
|
+
const entryRows = await tx.query(SELECT_ENTRIES_SQL, [runId]);
|
|
1118
|
+
if (entryRows.length > 0) {
|
|
1119
|
+
(current as { entries: JournalEntry[] }).entries = entryRows.map((entry) =>
|
|
1120
|
+
entryFromRow(entry),
|
|
1121
|
+
);
|
|
1122
|
+
}
|
|
928
1123
|
const next = update(current) ?? current;
|
|
1124
|
+
const renew =
|
|
1125
|
+
current.lockedBy === instanceId &&
|
|
1126
|
+
current.leaseExpiresAt !== undefined &&
|
|
1127
|
+
current.leaseExpiresAt > now &&
|
|
1128
|
+
current.leaseToken !== undefined;
|
|
929
1129
|
next.lockedBy = instanceId;
|
|
930
1130
|
next.leaseExpiresAt = now + leaseMs;
|
|
931
|
-
|
|
1131
|
+
next.leaseToken = renew ? current.leaseToken : (current.leaseToken ?? 0) + 1;
|
|
1132
|
+
for (let seq = 0; seq < next.entries.length; seq++) {
|
|
1133
|
+
const entry = next.entries[seq];
|
|
1134
|
+
if (!entry) continue;
|
|
1135
|
+
await tx.exec(INSERT_ENTRY_SQL, entryToParams(runId, seq, entry));
|
|
1136
|
+
}
|
|
1137
|
+
const header = { ...next, entries: [] as JournalEntry[] };
|
|
1138
|
+
await tx.exec(UPSERT_SQL, runToParams(header));
|
|
932
1139
|
return "ok";
|
|
933
1140
|
});
|
|
934
1141
|
},
|
|
@@ -941,7 +1148,11 @@ export async function createPostgresJournalStore(
|
|
|
941
1148
|
const row = rows[0];
|
|
942
1149
|
if (!row) return undefined;
|
|
943
1150
|
await tx.exec(UPDATE_LEASE_SQL, [instanceId, now + leaseMs, String(row.id)]);
|
|
944
|
-
|
|
1151
|
+
const run = rowToRun(row);
|
|
1152
|
+
run.lockedBy = instanceId;
|
|
1153
|
+
run.leaseExpiresAt = now + leaseMs;
|
|
1154
|
+
run.leaseToken = (run.leaseToken ?? 0) + 1;
|
|
1155
|
+
return run;
|
|
945
1156
|
});
|
|
946
1157
|
},
|
|
947
1158
|
async listOrphans(now) {
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Two consumers, one bus: postgres SKIP LOCKED and redis consumer groups.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { signal } from "../elements/signal.ts";
|
|
7
|
+
import {
|
|
8
|
+
openPostgresChannelLedger,
|
|
9
|
+
type ChannelLedgerSql,
|
|
10
|
+
} from "../elements/channel/sql-ledger.ts";
|
|
11
|
+
import { createPostgresSignalFake, openPostgresSignal } from "./signal-postgres.ts";
|
|
12
|
+
import {
|
|
13
|
+
createBunSignalRedisClient,
|
|
14
|
+
createSignalRedisFake,
|
|
15
|
+
openRedisSignal,
|
|
16
|
+
} from "./signal-redis.ts";
|
|
17
|
+
|
|
18
|
+
function decls(name: string, retries = 3) {
|
|
19
|
+
const decl = signal.once(name, { retries, deadLetter: true });
|
|
20
|
+
return new Map([[decl.name, decl]]);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("postgres signal competing consumers", () => {
|
|
24
|
+
test("one message is delivered once across two buses", async () => {
|
|
25
|
+
const sql = createPostgresSignalFake();
|
|
26
|
+
const name = "order-placed";
|
|
27
|
+
const a = await openPostgresSignal({ signals: decls(name), sql });
|
|
28
|
+
const b = await openPostgresSignal({ signals: decls(name), sql });
|
|
29
|
+
const got: string[] = [];
|
|
30
|
+
await a.subscribe(name, "a", async () => {
|
|
31
|
+
got.push("a");
|
|
32
|
+
});
|
|
33
|
+
await b.subscribe(name, "b", async () => {
|
|
34
|
+
got.push("b");
|
|
35
|
+
});
|
|
36
|
+
await a.emit(name, { id: "1" });
|
|
37
|
+
await Bun.sleep(5);
|
|
38
|
+
await a.drain();
|
|
39
|
+
await b.drain();
|
|
40
|
+
expect(got).toHaveLength(1);
|
|
41
|
+
await a.close();
|
|
42
|
+
await b.close();
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("redis streams competing consumers", () => {
|
|
47
|
+
test("one message is delivered once across two groups", async () => {
|
|
48
|
+
const redis = createSignalRedisFake();
|
|
49
|
+
const name = "order-placed";
|
|
50
|
+
const shared = { signals: decls(name), redis, compete: true as const };
|
|
51
|
+
const a = await openRedisSignal({ ...shared, consumerId: "a" });
|
|
52
|
+
const b = await openRedisSignal({ ...shared, consumerId: "b" });
|
|
53
|
+
const got: string[] = [];
|
|
54
|
+
await a.subscribe(name, "a", async () => {
|
|
55
|
+
got.push("a");
|
|
56
|
+
});
|
|
57
|
+
await b.subscribe(name, "b", async () => {
|
|
58
|
+
got.push("b");
|
|
59
|
+
});
|
|
60
|
+
await a.emit(name, { id: "1" });
|
|
61
|
+
await a.drain();
|
|
62
|
+
await b.drain();
|
|
63
|
+
expect(got).toHaveLength(1);
|
|
64
|
+
await a.close();
|
|
65
|
+
await b.close();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("a failed handler leaves the entry pending for redelivery", async () => {
|
|
69
|
+
const redis = createSignalRedisFake();
|
|
70
|
+
const name = "flaky";
|
|
71
|
+
const bus = await openRedisSignal({
|
|
72
|
+
signals: decls(name, 2),
|
|
73
|
+
redis,
|
|
74
|
+
compete: true,
|
|
75
|
+
consumerId: "a",
|
|
76
|
+
});
|
|
77
|
+
let calls = 0;
|
|
78
|
+
await bus.subscribe(name, "a", async () => {
|
|
79
|
+
calls += 1;
|
|
80
|
+
if (calls === 1) throw new Error("boom");
|
|
81
|
+
});
|
|
82
|
+
await bus.emit(name, { id: "1" });
|
|
83
|
+
await bus.drain();
|
|
84
|
+
expect(calls).toBe(1);
|
|
85
|
+
await bus.drain();
|
|
86
|
+
expect(calls).toBe(2);
|
|
87
|
+
await bus.close();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("another consumer reclaims an unacked entry", async () => {
|
|
91
|
+
const redis = createSignalRedisFake();
|
|
92
|
+
const name = "handoff";
|
|
93
|
+
const shared = { signals: decls(name, 3), redis, compete: true as const };
|
|
94
|
+
const a = await openRedisSignal({ ...shared, consumerId: "a" });
|
|
95
|
+
const b = await openRedisSignal({ ...shared, consumerId: "b" });
|
|
96
|
+
let aCalls = 0;
|
|
97
|
+
const got: string[] = [];
|
|
98
|
+
await a.subscribe(name, "a", async () => {
|
|
99
|
+
aCalls += 1;
|
|
100
|
+
throw new Error("down");
|
|
101
|
+
});
|
|
102
|
+
await b.subscribe(name, "b", async () => {
|
|
103
|
+
got.push("b");
|
|
104
|
+
});
|
|
105
|
+
await a.emit(name, { id: "1" });
|
|
106
|
+
await a.drain();
|
|
107
|
+
await b.drain();
|
|
108
|
+
expect(aCalls).toBe(1);
|
|
109
|
+
expect(got).toEqual(["b"]);
|
|
110
|
+
await a.close();
|
|
111
|
+
await b.close();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test.skipIf(!process.env.REDIS_URL)("REDIS_URL: two clients ack once", async () => {
|
|
115
|
+
const url = process.env.REDIS_URL;
|
|
116
|
+
const name = `oke-compete-${Date.now()}`;
|
|
117
|
+
const redisA = createBunSignalRedisClient(url);
|
|
118
|
+
const redisB = createBunSignalRedisClient(url);
|
|
119
|
+
const a = await openRedisSignal({
|
|
120
|
+
signals: decls(name),
|
|
121
|
+
redis: redisA,
|
|
122
|
+
compete: true,
|
|
123
|
+
consumerId: "a",
|
|
124
|
+
});
|
|
125
|
+
const b = await openRedisSignal({
|
|
126
|
+
signals: decls(name),
|
|
127
|
+
redis: redisB,
|
|
128
|
+
compete: true,
|
|
129
|
+
consumerId: "b",
|
|
130
|
+
});
|
|
131
|
+
const got: string[] = [];
|
|
132
|
+
await a.subscribe(name, "a", async () => {
|
|
133
|
+
got.push("a");
|
|
134
|
+
});
|
|
135
|
+
await b.subscribe(name, "b", async () => {
|
|
136
|
+
got.push("b");
|
|
137
|
+
});
|
|
138
|
+
await a.emit(name, { id: "1" });
|
|
139
|
+
await a.drain();
|
|
140
|
+
await b.drain();
|
|
141
|
+
expect(got).toHaveLength(1);
|
|
142
|
+
await a.close();
|
|
143
|
+
await b.close();
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
function memoryLedgerSql(): ChannelLedgerSql {
|
|
148
|
+
const consent = new Map<string, { subject: string; medium: string; at: number }>();
|
|
149
|
+
const bounce = new Map<string, { subject: string; medium: string; at: number }>();
|
|
150
|
+
const receipts = new Map<string, string>();
|
|
151
|
+
|
|
152
|
+
function pair(params: readonly unknown[]): string {
|
|
153
|
+
return `${String(params[0])}\0${String(params[1])}`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
async query(sql) {
|
|
158
|
+
if (sql.includes("oke_channel_consent")) {
|
|
159
|
+
return [...consent.values()];
|
|
160
|
+
}
|
|
161
|
+
if (sql.includes("oke_channel_bounce")) {
|
|
162
|
+
return [...bounce.values()];
|
|
163
|
+
}
|
|
164
|
+
if (sql.includes("oke_channel_receipt")) {
|
|
165
|
+
return [...receipts.entries()].map(([id, body]) => ({ id, body }));
|
|
166
|
+
}
|
|
167
|
+
return [];
|
|
168
|
+
},
|
|
169
|
+
async exec(sql, params = []) {
|
|
170
|
+
if (sql.startsWith("CREATE")) return { changes: 0 };
|
|
171
|
+
if (sql.includes("INSERT INTO oke_channel_consent")) {
|
|
172
|
+
consent.set(pair(params), {
|
|
173
|
+
subject: String(params[0]),
|
|
174
|
+
medium: String(params[1]),
|
|
175
|
+
at: Number(params[2]),
|
|
176
|
+
});
|
|
177
|
+
return { changes: 1 };
|
|
178
|
+
}
|
|
179
|
+
if (sql.includes("DELETE FROM oke_channel_consent")) {
|
|
180
|
+
consent.delete(pair(params));
|
|
181
|
+
return { changes: 1 };
|
|
182
|
+
}
|
|
183
|
+
if (sql.includes("INSERT INTO oke_channel_bounce")) {
|
|
184
|
+
bounce.set(pair(params), {
|
|
185
|
+
subject: String(params[0]),
|
|
186
|
+
medium: String(params[1]),
|
|
187
|
+
at: Number(params[2]),
|
|
188
|
+
});
|
|
189
|
+
return { changes: 1 };
|
|
190
|
+
}
|
|
191
|
+
if (sql.includes("DELETE FROM oke_channel_bounce")) {
|
|
192
|
+
bounce.delete(pair(params));
|
|
193
|
+
return { changes: 1 };
|
|
194
|
+
}
|
|
195
|
+
if (sql.includes("INSERT INTO oke_channel_receipt")) {
|
|
196
|
+
receipts.set(String(params[0]), String(params[1]));
|
|
197
|
+
return { changes: 1 };
|
|
198
|
+
}
|
|
199
|
+
if (sql.includes("UPDATE oke_channel_receipt")) {
|
|
200
|
+
receipts.set(String(params[1]), String(params[0]));
|
|
201
|
+
return { changes: 1 };
|
|
202
|
+
}
|
|
203
|
+
return { changes: 0 };
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
describe("channel ledger across instances", () => {
|
|
209
|
+
test("consent and suppression written on A are visible to B", async () => {
|
|
210
|
+
const sql = memoryLedgerSql();
|
|
211
|
+
const a = await openPostgresChannelLedger(sql);
|
|
212
|
+
const b = await openPostgresChannelLedger(sql);
|
|
213
|
+
a.suppression.optOut("a@b.c", "email");
|
|
214
|
+
a.suppression.addPriorBounce("c@d.e", "sms");
|
|
215
|
+
a.receipts.record({
|
|
216
|
+
id: "r1",
|
|
217
|
+
template: "note",
|
|
218
|
+
to: "a@b.c",
|
|
219
|
+
medium: "email",
|
|
220
|
+
status: "sent",
|
|
221
|
+
attempts: [],
|
|
222
|
+
at: 1,
|
|
223
|
+
});
|
|
224
|
+
await a.flush();
|
|
225
|
+
await b.reload();
|
|
226
|
+
expect(b.consent.isOptedOut("a@b.c", "email")).toBe(true);
|
|
227
|
+
expect(b.suppression.isSuppressed("c@d.e", "sms")).toEqual({
|
|
228
|
+
suppressed: true,
|
|
229
|
+
reason: "prior-bounce",
|
|
230
|
+
});
|
|
231
|
+
expect(b.receipts.all()).toHaveLength(1);
|
|
232
|
+
expect(b.receipts.all()[0]?.id).toBe("r1");
|
|
233
|
+
});
|
|
234
|
+
});
|