okengine 0.23.1 → 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 +3 -3
- package/site/content/docs/elements/signal/broadcast.mdx +4 -2
- package/site/content/docs/reference/configuration.mdx +16 -0
- package/site/content/docs/reference/errors.mdx +31 -19
- 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/http-parse.test.ts +101 -0
- package/src/compiler/http-parse.ts +188 -9
- package/src/compiler/interpret.ts +25 -2
- package/src/console/ui-next/dist/assets/{access-page-C_N4qvai.js → access-page-zBMhZnFm.js} +1 -1
- package/src/console/ui-next/dist/assets/{agent-disclosure-Cq1TxniW.js → agent-disclosure-CBj9f7Ju.js} +1 -1
- package/src/console/ui-next/dist/assets/{cache-glyph-BaI4uxZz.js → cache-glyph-D3MY49h3.js} +1 -1
- package/src/console/ui-next/dist/assets/{call-pii-button-DNyK7IIY.js → call-pii-button-L9JxdBhp.js} +1 -1
- package/src/console/ui-next/dist/assets/{collapsible-CRhBJMZR.js → collapsible-BvFaX-Zt.js} +1 -1
- package/src/console/ui-next/dist/assets/{decisions-page-D9twj8Xy.js → decisions-page-DgB76m-X.js} +1 -1
- package/src/console/ui-next/dist/assets/{duration-tone-CsHWlVPU.js → duration-tone-l1DSJ2Vz.js} +1 -1
- package/src/console/ui-next/dist/assets/{flows-page-ZaTJAhR9.js → flows-page-Bb9t1lu6.js} +1 -1
- package/src/console/ui-next/dist/assets/{highlighted-json-DQ-EMAfz.js → highlighted-json-DMvsi3Ti.js} +1 -1
- package/src/console/ui-next/dist/assets/{http-method-Cn_Fl79s.js → http-method-BwhJBTcQ.js} +1 -1
- package/src/console/ui-next/dist/assets/{index-CmRFeWdT.js → index-RhV2jT_7.js} +3 -3
- package/src/console/ui-next/dist/assets/{observability-page-Cn6R_dXz.js → observability-page-BRfUILBq.js} +1 -1
- package/src/console/ui-next/dist/assets/{replica-lag-Bx6EZhSe.js → replica-lag-BsceAzIG.js} +1 -1
- package/src/console/ui-next/dist/assets/{request-meta-DmdiUrfh.js → request-meta-C6lTyCXp.js} +1 -1
- package/src/console/ui-next/dist/assets/{store-page-8P6vGXnn.js → store-page-YmE806bJ.js} +1 -1
- package/src/console/ui-next/dist/assets/{trace-detail-sheet-BfGvpFSv.js → trace-detail-sheet-Di4irS49.js} +1 -1
- package/src/console/ui-next/dist/assets/{tree-expand-toggle-CWdUHbc0.js → tree-expand-toggle-DYeL6Rye.js} +1 -1
- package/src/console/ui-next/dist/assets/{units-page-CZOFZzay.js → units-page-07agasRA.js} +1 -1
- package/src/console/ui-next/dist/assets/{vault-page-ki_ishmX.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 +84 -1
- package/src/drivers/journal-postgres.ts +282 -32
- package/src/drivers/postgres.test.ts +60 -0
- package/src/drivers/postgres.ts +79 -5
- package/src/drivers/signal-compete.test.ts +6 -2
- package/src/drivers/signal-postgres.test.ts +170 -0
- package/src/drivers/signal-postgres.ts +449 -196
- package/src/drivers/signal-redis.test.ts +224 -0
- package/src/drivers/signal-redis.ts +835 -150
- package/src/elements/ai/approval.test.ts +15 -3
- package/src/elements/ai/approval.ts +15 -9
- package/src/elements/channel/runtime.ts +31 -4
- package/src/elements/channel/sql-ledger.test.ts +404 -0
- package/src/elements/channel/sql-ledger.ts +177 -33
- package/src/elements/clock/chaos-child.ts +12 -2
- package/src/elements/clock/durable.ts +11 -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 +59 -0
- package/src/elements/store/cache.ts +157 -12
- 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.ts +183 -3
- package/src/kernel/app.ts +176 -28
- package/src/kernel/auto-registry.test.ts +5 -1
- package/src/kernel/boot-bind/signal.ts +13 -2
- package/src/kernel/boot-bind/store.ts +16 -1
- package/src/kernel/boot.test.ts +2 -2
- package/src/kernel/boot.ts +41 -11
- 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/fx-sql-handle.ts +7 -1
- package/src/kernel/horizontal-child.ts +54 -1
- package/src/kernel/horizontal.integration.test.ts +131 -7
- package/src/kernel/idempotency.test.ts +5 -6
- package/src/kernel/journal-boot.test.ts +83 -6
- package/src/kernel/journal.test.ts +44 -0
- package/src/kernel/journal.ts +171 -26
- package/src/mcp/authorization.ts +5 -11
- package/src/mcp/confirmation.ts +203 -38
- package/src/mcp/docs-server.ts +7 -2
- package/src/mcp/index.ts +4 -0
- package/src/mcp/mcp.test.ts +321 -33
- package/src/mcp/server.ts +8 -3
- package/src/mcp/session.ts +7 -0
- package/src/mcp/tools.ts +68 -19
- package/src/runtime/bun.ts +5 -0
- package/src/runtime/types.ts +5 -0
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Reads are synchronous against a cache. Writes hit SQL and the cache.
|
|
5
5
|
* Call {@link PostgresChannelLedger.reload} on another instance to see them.
|
|
6
|
+
* Receipt reloads merge rows touched since the last read and keep the rest.
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
import type { ChannelMedium } from "../../manifest/types.ts";
|
|
@@ -28,20 +29,42 @@ export interface ChannelLedgerSql {
|
|
|
28
29
|
exec(sql: string, params?: readonly unknown[]): Promise<{ changes: number }>;
|
|
29
30
|
}
|
|
30
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Durable receipt read used when the in-memory cache misses.
|
|
34
|
+
*/
|
|
35
|
+
export interface ChannelReceiptLookup {
|
|
36
|
+
/**
|
|
37
|
+
* Load one receipt by receipt id or provider message id.
|
|
38
|
+
*
|
|
39
|
+
* @param messageId - Receipt id or provider message id
|
|
40
|
+
*/
|
|
41
|
+
lookup(messageId: string): Promise<DeliveryReceipt | undefined>;
|
|
42
|
+
}
|
|
43
|
+
|
|
31
44
|
/** Shared channel ledger (consent + suppression + receipts). */
|
|
32
45
|
export interface PostgresChannelLedger {
|
|
33
46
|
readonly consent: ConsentStore;
|
|
34
47
|
readonly suppression: SuppressionStore;
|
|
35
|
-
readonly receipts: ReceiptLedger;
|
|
48
|
+
readonly receipts: ReceiptLedger & ChannelReceiptLookup;
|
|
36
49
|
/** Wait for queued writes. */
|
|
37
50
|
flush(): Promise<void>;
|
|
38
|
-
/**
|
|
51
|
+
/**
|
|
52
|
+
* Wait for queued writes, then refresh the cache from SQL.
|
|
53
|
+
* Receipts merge by id. Rows absent from the delta stay cached.
|
|
54
|
+
*/
|
|
39
55
|
reload(): Promise<void>;
|
|
40
56
|
}
|
|
41
57
|
|
|
42
58
|
const CONSENT = "oke_channel_consent";
|
|
43
59
|
const BOUNCE = "oke_channel_bounce";
|
|
44
60
|
const RECEIPT = "oke_channel_receipt";
|
|
61
|
+
/** Postgres `clock_timestamp()` in milliseconds. Writes use the database clock. */
|
|
62
|
+
const DB_NOW_MS = "(extract(epoch from clock_timestamp())*1000)::bigint";
|
|
63
|
+
/**
|
|
64
|
+
* Reload overlap. A commit that lands while a read is in flight still
|
|
65
|
+
* appears on the next pass because the cursor moves backward by this much.
|
|
66
|
+
*/
|
|
67
|
+
const RECEIPT_RELOAD_LAG_MS = 30_000;
|
|
45
68
|
|
|
46
69
|
/**
|
|
47
70
|
* Open the ledger tables and load the current rows.
|
|
@@ -58,10 +81,19 @@ export async function openPostgresChannelLedger(
|
|
|
58
81
|
`CREATE TABLE IF NOT EXISTS ${BOUNCE} (subject TEXT NOT NULL, medium TEXT NOT NULL, at BIGINT NOT NULL, PRIMARY KEY (subject, medium))`,
|
|
59
82
|
);
|
|
60
83
|
await sql.exec(`CREATE TABLE IF NOT EXISTS ${RECEIPT} (id TEXT PRIMARY KEY, body TEXT NOT NULL)`);
|
|
84
|
+
await sql.exec(
|
|
85
|
+
`ALTER TABLE ${RECEIPT} ADD COLUMN IF NOT EXISTS updated_at BIGINT NOT NULL DEFAULT 0`,
|
|
86
|
+
);
|
|
87
|
+
await sql.exec(`ALTER TABLE ${RECEIPT} ADD COLUMN IF NOT EXISTS message_id TEXT`);
|
|
88
|
+
await sql.exec(
|
|
89
|
+
`CREATE INDEX IF NOT EXISTS oke_channel_receipt_message_id_idx ON ${RECEIPT} (message_id)`,
|
|
90
|
+
);
|
|
91
|
+
await sql.exec(
|
|
92
|
+
`CREATE INDEX IF NOT EXISTS oke_channel_receipt_updated_at_idx ON ${RECEIPT} (updated_at)`,
|
|
93
|
+
);
|
|
61
94
|
|
|
62
95
|
const consents: OptOut[] = [];
|
|
63
96
|
const bounces: OptOut[] = [];
|
|
64
|
-
const receipts: DeliveryReceipt[] = [];
|
|
65
97
|
let chain: Promise<void> = Promise.resolve();
|
|
66
98
|
|
|
67
99
|
function enqueue(work: () => Promise<void>): void {
|
|
@@ -176,50 +208,119 @@ export async function openPostgresChannelLedger(
|
|
|
176
208
|
},
|
|
177
209
|
};
|
|
178
210
|
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
211
|
+
const byId = new Map<string, DeliveryReceipt>();
|
|
212
|
+
const byMessage = new Map<string, string>();
|
|
213
|
+
/** `updated_at` cursor. Starts at 0 so legacy rows (`updated_at = 0`) load. */
|
|
214
|
+
let cursor = 0;
|
|
215
|
+
let reloading: Promise<void> | undefined;
|
|
216
|
+
let messageIdBackfill: Promise<void> | undefined;
|
|
217
|
+
|
|
218
|
+
function rememberReceipt(receipt: DeliveryReceipt): void {
|
|
219
|
+
const prev = byId.get(receipt.id);
|
|
220
|
+
if (prev?.messageId && prev.messageId !== receipt.messageId) {
|
|
221
|
+
if (byMessage.get(prev.messageId) === receipt.id) byMessage.delete(prev.messageId);
|
|
222
|
+
}
|
|
223
|
+
byId.set(receipt.id, receipt);
|
|
224
|
+
byMessage.set(receipt.id, receipt.id);
|
|
225
|
+
if (receipt.messageId) byMessage.set(receipt.messageId, receipt.id);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function findByMessageId(messageId: string): DeliveryReceipt | undefined {
|
|
229
|
+
const id = byMessage.get(messageId);
|
|
230
|
+
if (id === undefined) return undefined;
|
|
231
|
+
return byId.get(id);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function parseReceipt(body: unknown): DeliveryReceipt | undefined {
|
|
235
|
+
if (typeof body !== "string") return undefined;
|
|
236
|
+
let parsed: unknown;
|
|
237
|
+
try {
|
|
238
|
+
parsed = JSON.parse(body);
|
|
239
|
+
} catch {
|
|
240
|
+
return undefined;
|
|
241
|
+
}
|
|
242
|
+
if (
|
|
243
|
+
!parsed ||
|
|
244
|
+
typeof parsed !== "object" ||
|
|
245
|
+
!("id" in parsed) ||
|
|
246
|
+
typeof parsed.id !== "string"
|
|
247
|
+
) {
|
|
248
|
+
return undefined;
|
|
249
|
+
}
|
|
250
|
+
return parsed as DeliveryReceipt;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function readMillis(value: unknown): number | undefined {
|
|
254
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
255
|
+
if (typeof value === "bigint") return Number(value);
|
|
256
|
+
if (typeof value === "string" && value !== "") {
|
|
257
|
+
const parsed = Number(value);
|
|
258
|
+
if (Number.isFinite(parsed)) return parsed;
|
|
259
|
+
}
|
|
260
|
+
return undefined;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const receiptLedger = {
|
|
264
|
+
record(receipt: DeliveryReceipt) {
|
|
265
|
+
rememberReceipt(receipt);
|
|
182
266
|
enqueue(() =>
|
|
183
267
|
sql
|
|
184
|
-
.exec(
|
|
185
|
-
|
|
186
|
-
JSON.stringify(receipt),
|
|
187
|
-
|
|
268
|
+
.exec(
|
|
269
|
+
`INSERT INTO ${RECEIPT} (id, body, message_id, updated_at) VALUES (?, ?, ?, ${DB_NOW_MS})`,
|
|
270
|
+
[receipt.id, JSON.stringify(receipt), receipt.messageId ?? null],
|
|
271
|
+
)
|
|
188
272
|
.then(() => undefined),
|
|
189
273
|
);
|
|
190
274
|
},
|
|
191
275
|
all() {
|
|
192
|
-
return [...
|
|
276
|
+
return [...byId.values()];
|
|
193
277
|
},
|
|
194
|
-
forTemplate(template) {
|
|
195
|
-
return
|
|
278
|
+
forTemplate(template: string) {
|
|
279
|
+
return [...byId.values()].filter((row) => row.template === template);
|
|
196
280
|
},
|
|
197
|
-
byMessageId(messageId) {
|
|
198
|
-
|
|
199
|
-
const row = receipts[i]!;
|
|
200
|
-
if (row.messageId === messageId || row.id === messageId) return row;
|
|
201
|
-
}
|
|
202
|
-
return undefined;
|
|
281
|
+
byMessageId(messageId: string) {
|
|
282
|
+
return findByMessageId(messageId);
|
|
203
283
|
},
|
|
204
|
-
updateStatus(
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
284
|
+
updateStatus(
|
|
285
|
+
messageId: string,
|
|
286
|
+
patch: {
|
|
287
|
+
readonly status: DeliveryReceipt["status"];
|
|
288
|
+
readonly at?: number;
|
|
289
|
+
readonly error?: string;
|
|
290
|
+
},
|
|
291
|
+
) {
|
|
292
|
+
const prev = findByMessageId(messageId);
|
|
293
|
+
if (!prev) return undefined;
|
|
208
294
|
const next: DeliveryReceipt = {
|
|
209
295
|
...prev,
|
|
210
296
|
status: patch.status,
|
|
211
297
|
at: patch.at ?? prev.at,
|
|
212
298
|
...(patch.error !== undefined ? { error: patch.error } : {}),
|
|
213
299
|
};
|
|
214
|
-
|
|
300
|
+
rememberReceipt(next);
|
|
215
301
|
enqueue(() =>
|
|
216
302
|
sql
|
|
217
|
-
.exec(
|
|
303
|
+
.exec(
|
|
304
|
+
`UPDATE ${RECEIPT} SET body = ?, message_id = ?, updated_at = ${DB_NOW_MS} WHERE id = ?`,
|
|
305
|
+
[JSON.stringify(next), next.messageId ?? null, next.id],
|
|
306
|
+
)
|
|
218
307
|
.then(() => undefined),
|
|
219
308
|
);
|
|
220
309
|
return next;
|
|
221
310
|
},
|
|
222
|
-
|
|
311
|
+
async lookup(messageId: string): Promise<DeliveryReceipt | undefined> {
|
|
312
|
+
const rows = await sql.query(
|
|
313
|
+
`SELECT id, body, message_id, updated_at FROM ${RECEIPT} WHERE id = ? OR message_id = ? LIMIT 1`,
|
|
314
|
+
[messageId, messageId],
|
|
315
|
+
);
|
|
316
|
+
const row = rows[0];
|
|
317
|
+
if (!row) return undefined;
|
|
318
|
+
const receipt = parseReceipt(row.body);
|
|
319
|
+
if (!receipt) return undefined;
|
|
320
|
+
rememberReceipt(receipt);
|
|
321
|
+
return receipt;
|
|
322
|
+
},
|
|
323
|
+
} satisfies ReceiptLedger & ChannelReceiptLookup;
|
|
223
324
|
|
|
224
325
|
async function loadOptOuts(table: string, into: OptOut[]): Promise<void> {
|
|
225
326
|
const rows = await sql.query(`SELECT subject, medium, at FROM ${table}`);
|
|
@@ -233,16 +334,59 @@ export async function openPostgresChannelLedger(
|
|
|
233
334
|
}
|
|
234
335
|
}
|
|
235
336
|
|
|
236
|
-
|
|
337
|
+
function backfillMessageIds(): Promise<void> {
|
|
338
|
+
if (messageIdBackfill) return messageIdBackfill;
|
|
339
|
+
const run = (async () => {
|
|
340
|
+
const rows = await sql.query(`SELECT id, body FROM ${RECEIPT} WHERE message_id IS NULL`);
|
|
341
|
+
for (const row of rows) {
|
|
342
|
+
const receipt = parseReceipt(row.body);
|
|
343
|
+
const messageId = receipt?.messageId;
|
|
344
|
+
if (!messageId) continue;
|
|
345
|
+
await sql.exec(`UPDATE ${RECEIPT} SET message_id = ? WHERE id = ? AND message_id IS NULL`, [
|
|
346
|
+
messageId,
|
|
347
|
+
String(row.id),
|
|
348
|
+
]);
|
|
349
|
+
}
|
|
350
|
+
})().catch((err: unknown) => {
|
|
351
|
+
messageIdBackfill = undefined;
|
|
352
|
+
throw err;
|
|
353
|
+
});
|
|
354
|
+
messageIdBackfill = run;
|
|
355
|
+
return run;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async function loadReceiptDelta(): Promise<void> {
|
|
359
|
+
const since = cursor - RECEIPT_RELOAD_LAG_MS;
|
|
360
|
+
const rows = await sql.query(
|
|
361
|
+
`SELECT r.id, r.body, r.message_id, r.updated_at, now_ms.db_now FROM (SELECT ${DB_NOW_MS} AS db_now) AS now_ms LEFT JOIN ${RECEIPT} r ON r.updated_at >= ?`,
|
|
362
|
+
[since],
|
|
363
|
+
);
|
|
364
|
+
let dbNow: number | undefined;
|
|
365
|
+
for (const row of rows) {
|
|
366
|
+
const read = readMillis(row.db_now);
|
|
367
|
+
if (read !== undefined) dbNow = read;
|
|
368
|
+
if (row.id == null || row.body == null) continue;
|
|
369
|
+
const receipt = parseReceipt(row.body);
|
|
370
|
+
if (receipt) rememberReceipt(receipt);
|
|
371
|
+
}
|
|
372
|
+
if (dbNow !== undefined) cursor = dbNow;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async function reloadFromSql(): Promise<void> {
|
|
237
376
|
await flush();
|
|
238
377
|
await loadOptOuts(CONSENT, consents);
|
|
239
378
|
await loadOptOuts(BOUNCE, bounces);
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
379
|
+
await backfillMessageIds();
|
|
380
|
+
await loadReceiptDelta();
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function reload(): Promise<void> {
|
|
384
|
+
if (reloading) return reloading;
|
|
385
|
+
const run = reloadFromSql().finally(() => {
|
|
386
|
+
if (reloading === run) reloading = undefined;
|
|
387
|
+
});
|
|
388
|
+
reloading = run;
|
|
389
|
+
return run;
|
|
246
390
|
}
|
|
247
391
|
|
|
248
392
|
await reload();
|
|
@@ -206,7 +206,13 @@ if (mode === "tick-loop") {
|
|
|
206
206
|
leaseMs,
|
|
207
207
|
flow: chargeFlow(instanceId!, stepLogPath!, blockMs),
|
|
208
208
|
});
|
|
209
|
-
void app.fetch(
|
|
209
|
+
void app.fetch(
|
|
210
|
+
new Request("http://localhost/charge", {
|
|
211
|
+
method: "POST",
|
|
212
|
+
headers: { "content-type": "application/json" },
|
|
213
|
+
body: "{}",
|
|
214
|
+
}),
|
|
215
|
+
);
|
|
210
216
|
// Marker once step 1 is journaled — then hang; the parent SIGKILLs mid-run.
|
|
211
217
|
const deadline = Date.now() + 15_000;
|
|
212
218
|
for (;;) {
|
|
@@ -269,7 +275,11 @@ if (mode === "tick-loop") {
|
|
|
269
275
|
flow: sleeperFlow(instanceId!, stepLogPath!, wakeMs),
|
|
270
276
|
});
|
|
271
277
|
const res = await app.fetch(
|
|
272
|
-
new Request("http://localhost/sleep", {
|
|
278
|
+
new Request("http://localhost/sleep", {
|
|
279
|
+
method: "POST",
|
|
280
|
+
headers: { "content-type": "application/json" },
|
|
281
|
+
body: "{}",
|
|
282
|
+
}),
|
|
273
283
|
);
|
|
274
284
|
if (res.status !== 204) {
|
|
275
285
|
console.error(`journal-pg-park: expected 204 park, got ${res.status}`);
|
|
@@ -14,7 +14,10 @@ import { isFlowFailure } from "../../kernel/hooks.ts";
|
|
|
14
14
|
import { isJsonStreamResult } from "../../kernel/json-result.ts";
|
|
15
15
|
import {
|
|
16
16
|
createJournal,
|
|
17
|
+
isJournalLeaseBusy,
|
|
17
18
|
isJournalSuspend,
|
|
19
|
+
isLostJournalLease,
|
|
20
|
+
JournalLeaseBusy,
|
|
18
21
|
type Journal,
|
|
19
22
|
type JournalLeaseOptions,
|
|
20
23
|
type JournalSession,
|
|
@@ -55,6 +58,8 @@ export interface RunDurableOptions {
|
|
|
55
58
|
* Resume throws `JournalLeaseBusy` when another live instance holds the run.
|
|
56
59
|
*/
|
|
57
60
|
readonly lease?: JournalLeaseOptions;
|
|
61
|
+
/** Opt-in app code version. Unset skips the stamp and the check. */
|
|
62
|
+
readonly codeVersion?: string;
|
|
58
63
|
/** Injectable clock. */
|
|
59
64
|
readonly now?: () => number;
|
|
60
65
|
/** Extra fx options (secrets, store runtime, …). */
|
|
@@ -74,6 +79,7 @@ export async function runDurable<O = unknown>(
|
|
|
74
79
|
store: options.journalStore,
|
|
75
80
|
now,
|
|
76
81
|
...(options.lease ? { lease: options.lease } : {}),
|
|
82
|
+
...(options.codeVersion ? { codeVersion: options.codeVersion } : {}),
|
|
77
83
|
});
|
|
78
84
|
|
|
79
85
|
if (options.runId) {
|
|
@@ -121,6 +127,7 @@ export async function runDurable<O = unknown>(
|
|
|
121
127
|
});
|
|
122
128
|
|
|
123
129
|
if (session.run.status === "compensating") {
|
|
130
|
+
if (isLostJournalLease(session.run.error)) throw new JournalLeaseBusy(session.runId);
|
|
124
131
|
await rebindUndosFromDo(options.flow, options.input, fx, session);
|
|
125
132
|
const priorError = session.run.error ?? "Error";
|
|
126
133
|
await runCompensationPhase({
|
|
@@ -170,6 +177,7 @@ export async function runDurable<O = unknown>(
|
|
|
170
177
|
}
|
|
171
178
|
const output = produced;
|
|
172
179
|
if (isFlowFailure(output)) {
|
|
180
|
+
if (isLostJournalLease(output)) throw new JournalLeaseBusy(session.runId);
|
|
173
181
|
await runCompensationPhase({
|
|
174
182
|
flow: options.flow,
|
|
175
183
|
input: options.input,
|
|
@@ -198,6 +206,9 @@ export async function runDurable<O = unknown>(
|
|
|
198
206
|
label: err.label,
|
|
199
207
|
};
|
|
200
208
|
}
|
|
209
|
+
if (isLostJournalLease(err) || isJournalLeaseBusy(err)) {
|
|
210
|
+
throw isJournalLeaseBusy(err) ? err : new JournalLeaseBusy(session.runId);
|
|
211
|
+
}
|
|
201
212
|
await runCompensationPhase({
|
|
202
213
|
flow: options.flow,
|
|
203
214
|
input: options.input,
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { computedCacheKey, createStoreCache, type StoreCache } from "./cache.ts";
|
|
4
|
+
import {
|
|
5
|
+
CACHE_INVALIDATE_CHANNEL,
|
|
6
|
+
openCacheInvalidation,
|
|
7
|
+
type CacheBusRedis,
|
|
8
|
+
type CacheBusSql,
|
|
9
|
+
} from "./cache-bus.ts";
|
|
10
|
+
|
|
11
|
+
function hub(): { client: () => CacheBusRedis } {
|
|
12
|
+
const subs = new Map<string, Set<(message: string) => void>>();
|
|
13
|
+
return {
|
|
14
|
+
client: () => ({
|
|
15
|
+
async publish(channel, message) {
|
|
16
|
+
const set = subs.get(channel);
|
|
17
|
+
if (!set) return 0;
|
|
18
|
+
for (const listener of [...set]) listener(message);
|
|
19
|
+
return set.size;
|
|
20
|
+
},
|
|
21
|
+
async subscribe(channel, listener) {
|
|
22
|
+
const set = subs.get(channel) ?? new Set<(message: string) => void>();
|
|
23
|
+
subs.set(channel, set);
|
|
24
|
+
set.add(listener);
|
|
25
|
+
return () => {
|
|
26
|
+
set.delete(listener);
|
|
27
|
+
};
|
|
28
|
+
},
|
|
29
|
+
}),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function seed(cache: StoreCache, resource: "sql:notes"): string {
|
|
34
|
+
const key = computedCacheKey(resource);
|
|
35
|
+
cache.set({ tier: 1, key, value: { n: 1 }, resources: [resource], expiresAt: null });
|
|
36
|
+
return key;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
test("redis invalidation drops the other instance and does not republish", async () => {
|
|
40
|
+
const bus = hub();
|
|
41
|
+
let publishA = 0;
|
|
42
|
+
const redisA = bus.client();
|
|
43
|
+
const publish = redisA.publish.bind(redisA);
|
|
44
|
+
redisA.publish = async (channel, message) => {
|
|
45
|
+
publishA += 1;
|
|
46
|
+
return publish(channel, message);
|
|
47
|
+
};
|
|
48
|
+
const cacheA = createStoreCache();
|
|
49
|
+
const cacheB = createStoreCache();
|
|
50
|
+
const a = openCacheInvalidation(cacheA, { kind: "redis", redis: redisA, origin: "a" });
|
|
51
|
+
const b = openCacheInvalidation(cacheB, { kind: "redis", redis: bus.client(), origin: "b" });
|
|
52
|
+
await Promise.resolve();
|
|
53
|
+
const key = seed(cacheA, "sql:notes");
|
|
54
|
+
seed(cacheB, "sql:notes");
|
|
55
|
+
b.publish(["sql:notes"]);
|
|
56
|
+
await Promise.resolve();
|
|
57
|
+
expect(cacheA.get(key)).toBeUndefined();
|
|
58
|
+
expect(cacheB.get<{ n: number }>(key)).toEqual({ n: 1 });
|
|
59
|
+
expect(publishA).toBe(0);
|
|
60
|
+
expect(CACHE_INVALIDATE_CHANNEL).toBe("oke:cache:invalidate");
|
|
61
|
+
await a.stop();
|
|
62
|
+
await b.stop();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("postgres invalidation is polled once and skips the sender", async () => {
|
|
66
|
+
const rows: Array<{ id: string; resource: string; origin: string; created_at: number }> = [];
|
|
67
|
+
let now = 1_000_000;
|
|
68
|
+
const sql = (): CacheBusSql => ({
|
|
69
|
+
async exec(statement, params) {
|
|
70
|
+
if (statement.startsWith("CREATE")) return { changes: 0 };
|
|
71
|
+
if (statement.startsWith("INSERT")) {
|
|
72
|
+
rows.push({
|
|
73
|
+
id: String(params?.[0]),
|
|
74
|
+
resource: String(params?.[1]),
|
|
75
|
+
origin: String(params?.[2]),
|
|
76
|
+
created_at: now,
|
|
77
|
+
});
|
|
78
|
+
return { changes: 1 };
|
|
79
|
+
}
|
|
80
|
+
if (statement.startsWith("DELETE")) {
|
|
81
|
+
const cutoff = Number(params?.[0]);
|
|
82
|
+
for (let i = rows.length - 1; i >= 0; i--) {
|
|
83
|
+
if (rows[i]!.created_at < cutoff) rows.splice(i, 1);
|
|
84
|
+
}
|
|
85
|
+
return { changes: 0 };
|
|
86
|
+
}
|
|
87
|
+
throw new Error(`unmatched ${statement}`);
|
|
88
|
+
},
|
|
89
|
+
async query(statement) {
|
|
90
|
+
if (statement.includes("db_now")) return [{ db_now: now }];
|
|
91
|
+
if (statement.includes("FROM oke_cache_invalidations"))
|
|
92
|
+
return rows.map((row) => ({ ...row }));
|
|
93
|
+
throw new Error(`unmatched ${statement}`);
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
const cacheA = createStoreCache();
|
|
97
|
+
const cacheB = createStoreCache();
|
|
98
|
+
const a = openCacheInvalidation(cacheA, {
|
|
99
|
+
kind: "postgres",
|
|
100
|
+
origin: "a",
|
|
101
|
+
sql: async () => sql(),
|
|
102
|
+
});
|
|
103
|
+
const b = openCacheInvalidation(cacheB, {
|
|
104
|
+
kind: "postgres",
|
|
105
|
+
origin: "b",
|
|
106
|
+
sql: async () => sql(),
|
|
107
|
+
});
|
|
108
|
+
await a.poll();
|
|
109
|
+
await b.poll();
|
|
110
|
+
const key = seed(cacheA, "sql:notes");
|
|
111
|
+
seed(cacheB, "sql:notes");
|
|
112
|
+
now += 5;
|
|
113
|
+
b.publish(["sql:notes"]);
|
|
114
|
+
for (let i = 0; i < 20; i++) await Promise.resolve();
|
|
115
|
+
await a.poll();
|
|
116
|
+
await b.poll();
|
|
117
|
+
expect(cacheA.get(key)).toBeUndefined();
|
|
118
|
+
expect(cacheB.get<{ n: number }>(key)).toEqual({ n: 1 });
|
|
119
|
+
await a.stop();
|
|
120
|
+
await b.stop();
|
|
121
|
+
});
|