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
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `postgres` signal driver — transactional emit (dual-write fix).
|
|
3
3
|
*
|
|
4
|
-
* `fx.emit` inserts into the outbox on the caller's connection.
|
|
5
|
-
* claim with `FOR UPDATE SKIP LOCKED`.
|
|
6
|
-
*
|
|
4
|
+
* `fx.emit` inserts into the outbox on the caller's connection. `once`
|
|
5
|
+
* consumers claim with `FOR UPDATE SKIP LOCKED`. Broadcast and live stay
|
|
6
|
+
* unread in the table; each instance polls `db_at` from an in-memory cursor.
|
|
7
|
+
* Wakeups use `LISTEN` / `NOTIFY` when the client has them; otherwise the
|
|
8
|
+
* boot scheduler polls `drain`.
|
|
7
9
|
*/
|
|
8
10
|
|
|
9
11
|
import type { SignalDecl } from "../elements/signal/declare.ts";
|
|
@@ -52,6 +54,8 @@ interface MsgRow {
|
|
|
52
54
|
lease_expires_at: number | null;
|
|
53
55
|
delivered_to: string;
|
|
54
56
|
parent_run_id: string | null;
|
|
57
|
+
/** Database clock at insert (`clock_timestamp`), not the application clock. */
|
|
58
|
+
db_at: number | null;
|
|
55
59
|
}
|
|
56
60
|
|
|
57
61
|
/** Minimal SQL + listen surface for the postgres signal driver. */
|
|
@@ -87,6 +91,215 @@ const CHANNEL = "oke_signal";
|
|
|
87
91
|
/** Claim next once-row: pending/unlocked or lease-expired, blocked by same-key inflight. */
|
|
88
92
|
const CLAIM_ONCE_SQL = `SELECT * FROM oke_signal_messages WHERE signal=? AND delivery='once' AND available_at<=? AND ((status='pending' AND locked_by IS NULL) OR (status='inflight' AND lease_expires_at IS NOT NULL AND lease_expires_at<=?)) AND (ordering_key IS NULL OR NOT EXISTS (SELECT 1 FROM oke_signal_messages h WHERE h.signal=oke_signal_messages.signal AND h.ordering_key=oke_signal_messages.ordering_key AND h.id<>oke_signal_messages.id AND h.status='inflight' AND h.lease_expires_at IS NOT NULL AND h.lease_expires_at>?)) ORDER BY created_at ASC LIMIT 1 FOR UPDATE SKIP LOCKED`;
|
|
89
93
|
|
|
94
|
+
/** `clock_timestamp()` in milliseconds. Stamped in SQL, not from the app clock. */
|
|
95
|
+
const DB_CLOCK_MS_SQL = `(extract(epoch from clock_timestamp())*1000)::bigint`;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Default overlap window for postgres broadcast and live polls (ms).
|
|
99
|
+
* A commit that lands more than this long after its `db_at` can be missed.
|
|
100
|
+
*/
|
|
101
|
+
export const POSTGRES_SIGNAL_DEFAULT_LAG_MS = 30_000;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Broadcast/live statements the in-memory fake routes to one dedicated branch.
|
|
105
|
+
* Inserts stamp `db_at` from `clock_timestamp()`.
|
|
106
|
+
*/
|
|
107
|
+
export const POSTGRES_SIGNAL_FANOUT_SQL = {
|
|
108
|
+
/** Insert including `db_at` from the database clock. */
|
|
109
|
+
insert: `INSERT INTO oke_signal_messages (id, signal, payload, ordering_key, delivery, attempts, failures, created_at, available_at, status, locked_by, lease_expires_at, delivered_to, parent_run_id, db_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ${DB_CLOCK_MS_SQL})`,
|
|
110
|
+
/** Poll `db_at >=` and read the database clock from the same statement. */
|
|
111
|
+
poll: `SELECT m.id, m.signal, m.payload, m.ordering_key, m.delivery, m.attempts, m.failures, m.created_at, m.available_at, m.status, m.locked_by, m.lease_expires_at, m.delivered_to, m.parent_run_id, m.db_at, ${DB_CLOCK_MS_SQL} AS db_now FROM (SELECT ${DB_CLOCK_MS_SQL} AS db_now) AS clock LEFT JOIN oke_signal_messages m ON m.delivery IN ('broadcast', 'live') AND m.db_at >= ?`,
|
|
112
|
+
/** Backfill `db_at` from `created_at` (not 0). */
|
|
113
|
+
backfill: `UPDATE oke_signal_messages SET db_at = created_at WHERE db_at IS NULL`,
|
|
114
|
+
/** Supports the fan-out poll. */
|
|
115
|
+
index: `CREATE INDEX IF NOT EXISTS oke_signal_messages_delivery_db_at ON oke_signal_messages (signal, delivery, db_at)`,
|
|
116
|
+
} as const;
|
|
117
|
+
|
|
118
|
+
/** Drop broadcast rows only after the overlap window has closed. */
|
|
119
|
+
const BROADCAST_TTL_DELETE_SQL = `DELETE FROM oke_signal_messages WHERE delivery = 'broadcast' AND db_at < ?`;
|
|
120
|
+
|
|
121
|
+
function isOnceClaim(text: string): boolean {
|
|
122
|
+
return (
|
|
123
|
+
/FOR\s+UPDATE\s+SKIP\s+LOCKED/i.test(text) &&
|
|
124
|
+
/oke_signal_messages/i.test(text) &&
|
|
125
|
+
/delivery\s*=\s*'once'/i.test(text)
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function isFanoutPoll(text: string): boolean {
|
|
130
|
+
return /^SELECT\b/i.test(text) && /db_at\s*>=/i.test(text) && /clock_timestamp\s*\(/i.test(text);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function isSelectStatus(text: string): boolean {
|
|
134
|
+
return /^SELECT\s+\*\s+FROM\s+oke_signal_messages\s+WHERE\s+signal\s*=\s*\?\s+AND\s+status\s*=\s*\?\s*$/i.test(
|
|
135
|
+
text,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function isSelectPending(text: string): boolean {
|
|
140
|
+
return /^SELECT\s+\*\s+FROM\s+oke_signal_messages\s+WHERE\s+status\s+IN\s*\('pending',\s*'inflight'\)\s*$/i.test(
|
|
141
|
+
text,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function isSelectSignal(text: string): boolean {
|
|
146
|
+
return /^SELECT\s+\*\s+FROM\s+oke_signal_messages\s+WHERE\s+signal\s*=\s*\?\s*$/i.test(text);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function isSelectWrite(text: string): boolean {
|
|
150
|
+
return /^SELECT\s+value\s+FROM\s+oke_signal_writes\s+WHERE\s+key\s*=\s*\?\s*$/i.test(text);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function isSelectId(text: string): boolean {
|
|
154
|
+
return /^SELECT\s+\*\s+FROM\s+oke_signal_messages\s+WHERE\s+id\s*=\s*\?\s*$/i.test(text);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function isSelectLive(text: string): boolean {
|
|
158
|
+
return /^SELECT\s+\*\s+FROM\s+oke_signal_messages\s+WHERE\s+signal\s*=\s*\?\s+AND\s+delivery\s*=\s*'live'\s+ORDER\s+BY\s+created_at\s+ASC\s*$/i.test(
|
|
159
|
+
text,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function isCreateTable(text: string): boolean {
|
|
164
|
+
return /^CREATE\s+TABLE/i.test(text);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function isDeliveryDbAtIndex(text: string): boolean {
|
|
168
|
+
return /^CREATE\s+INDEX\b/i.test(text) && /oke_signal_messages_delivery_db_at/i.test(text);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function isCreateIndex(text: string): boolean {
|
|
172
|
+
return /^CREATE\s+INDEX/i.test(text) && !isDeliveryDbAtIndex(text);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function isBroadcastTtlDelete(text: string): boolean {
|
|
176
|
+
return /^DELETE\s+FROM\s+oke_signal_messages\s+WHERE\s+delivery\s*=\s*'broadcast'\s+AND\s+db_at\s*<\s*\?\s*$/i.test(
|
|
177
|
+
text,
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function isDeleteLive(text: string): boolean {
|
|
182
|
+
return /^DELETE\s+FROM\s+oke_signal_messages\s+WHERE\s+id\s*=\s*\?\s+AND\s+delivery\s*=\s*'live'\s*$/i.test(
|
|
183
|
+
text,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function isDeleteDead(text: string): boolean {
|
|
188
|
+
return /^DELETE\s+FROM\s+oke_signal_messages\s+WHERE\s+id\s*=\s*\?\s+AND\s+signal\s*=\s*\?\s+AND\s+status\s*=\s*'dead'\s*$/i.test(
|
|
189
|
+
text,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function isInsertDbAt(text: string): boolean {
|
|
194
|
+
return /^INSERT\s+INTO\s+oke_signal_messages\b/i.test(text) && /clock_timestamp\s*\(/i.test(text);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function isInsertMessage(text: string): boolean {
|
|
198
|
+
return (
|
|
199
|
+
/^INSERT\s+INTO\s+oke_signal_messages\s*\(([^)]+)\)\s*VALUES\s*\(([^)]+)\)\s*$/i.test(text) &&
|
|
200
|
+
!isInsertDbAt(text)
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function isInsertWrite(text: string): boolean {
|
|
205
|
+
return /^INSERT\s+INTO\s+oke_signal_writes\s*\(key,\s*value\)\s*VALUES\s*\(\?,\s*\?\)\s*$/i.test(
|
|
206
|
+
text,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function isBackfillDbAt(text: string): boolean {
|
|
211
|
+
return /^UPDATE\s+oke_signal_messages\s+SET\s+db_at\s*=\s*created_at\s+WHERE\s+db_at\s+IS\s+NULL\s*$/i.test(
|
|
212
|
+
text,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function isUpdateById(text: string): boolean {
|
|
217
|
+
return (
|
|
218
|
+
/^UPDATE\s+oke_signal_messages\s+SET\s+(.+)\s+WHERE\s+id\s*=\s*\?\s*$/i.test(text) &&
|
|
219
|
+
!isBackfillDbAt(text)
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function isAlterAddColumn(text: string): boolean {
|
|
224
|
+
return /^ALTER\s+TABLE\s+oke_signal_messages\s+ADD\s+COLUMN/i.test(text);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const POSTGRES_SIGNAL_FAKE_BRANCHES: ReadonlyArray<{
|
|
228
|
+
readonly name: string;
|
|
229
|
+
readonly test: (text: string) => boolean;
|
|
230
|
+
}> = [
|
|
231
|
+
{ name: "once-claim", test: isOnceClaim },
|
|
232
|
+
{ name: "fanout-poll", test: isFanoutPoll },
|
|
233
|
+
{ name: "select-status", test: isSelectStatus },
|
|
234
|
+
{ name: "select-pending", test: isSelectPending },
|
|
235
|
+
{ name: "select-signal", test: isSelectSignal },
|
|
236
|
+
{ name: "select-write", test: isSelectWrite },
|
|
237
|
+
{ name: "select-id", test: isSelectId },
|
|
238
|
+
{ name: "select-live", test: isSelectLive },
|
|
239
|
+
{ name: "create-table", test: isCreateTable },
|
|
240
|
+
{ name: "index-delivery-db-at", test: isDeliveryDbAtIndex },
|
|
241
|
+
{ name: "create-index", test: isCreateIndex },
|
|
242
|
+
{ name: "delete-broadcast-ttl", test: isBroadcastTtlDelete },
|
|
243
|
+
{ name: "delete-live", test: isDeleteLive },
|
|
244
|
+
{ name: "delete-dead", test: isDeleteDead },
|
|
245
|
+
{ name: "insert-db-at", test: isInsertDbAt },
|
|
246
|
+
{ name: "insert-message", test: isInsertMessage },
|
|
247
|
+
{ name: "insert-write", test: isInsertWrite },
|
|
248
|
+
{ name: "backfill-db-at", test: isBackfillDbAt },
|
|
249
|
+
{ name: "update-by-id", test: isUpdateById },
|
|
250
|
+
{ name: "alter-add-column", test: isAlterAddColumn },
|
|
251
|
+
];
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* In-memory fake branches whose predicates match `sql`.
|
|
255
|
+
*
|
|
256
|
+
* Broadcast/live statements must match exactly one branch so a broader
|
|
257
|
+
* regex cannot swallow them.
|
|
258
|
+
*
|
|
259
|
+
* @param sql - Statement text
|
|
260
|
+
*/
|
|
261
|
+
export function postgresSignalFakeBranches(sql: string): readonly string[] {
|
|
262
|
+
const text = sql.trim();
|
|
263
|
+
const names: string[] = [];
|
|
264
|
+
for (const branch of POSTGRES_SIGNAL_FAKE_BRANCHES) {
|
|
265
|
+
if (branch.test(text)) names.push(branch.name);
|
|
266
|
+
}
|
|
267
|
+
return names;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function fieldText(value: unknown, fallback: string): string {
|
|
271
|
+
if (typeof value === "string") return value;
|
|
272
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
273
|
+
return fallback;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function msgRowFromFields(row: Record<string, unknown>): MsgRow {
|
|
277
|
+
const orderingKey = row.ordering_key;
|
|
278
|
+
const parentRunId = row.parent_run_id;
|
|
279
|
+
return {
|
|
280
|
+
id: fieldText(row.id, ""),
|
|
281
|
+
signal: fieldText(row.signal, ""),
|
|
282
|
+
payload: fieldText(row.payload, "null"),
|
|
283
|
+
ordering_key:
|
|
284
|
+
orderingKey === undefined || orderingKey === null ? null : fieldText(orderingKey, ""),
|
|
285
|
+
delivery: row.delivery as SignalDelivery,
|
|
286
|
+
attempts: Number(row.attempts ?? 0),
|
|
287
|
+
failures: fieldText(row.failures, "[]"),
|
|
288
|
+
created_at: Number(row.created_at),
|
|
289
|
+
available_at: Number(row.available_at),
|
|
290
|
+
status: (row.status as MsgRow["status"]) ?? "pending",
|
|
291
|
+
locked_by: (row.locked_by as string | null) ?? null,
|
|
292
|
+
lease_expires_at:
|
|
293
|
+
row.lease_expires_at === undefined || row.lease_expires_at === null
|
|
294
|
+
? null
|
|
295
|
+
: Number(row.lease_expires_at),
|
|
296
|
+
delivered_to: fieldText(row.delivered_to, "[]"),
|
|
297
|
+
parent_run_id:
|
|
298
|
+
parentRunId === undefined || parentRunId === null ? null : fieldText(parentRunId, ""),
|
|
299
|
+
db_at: row.db_at === undefined || row.db_at === null ? null : Number(row.db_at),
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
90
303
|
/**
|
|
91
304
|
* In-memory Postgres-protocol fake with transactions, SKIP LOCKED, LISTEN/NOTIFY.
|
|
92
305
|
*/
|
|
@@ -97,6 +310,7 @@ export function createPostgresSignalFake(options?: {
|
|
|
97
310
|
/** Force-kill mid-transaction (drops uncommitted state). */
|
|
98
311
|
killActiveTransaction(): void;
|
|
99
312
|
} {
|
|
313
|
+
const clock = options?.now ?? (() => Date.now());
|
|
100
314
|
const listeners = new Map<string, Set<(payload: string) => void>>();
|
|
101
315
|
|
|
102
316
|
type State = {
|
|
@@ -123,6 +337,7 @@ export function createPostgresSignalFake(options?: {
|
|
|
123
337
|
ordering_key: m.ordering_key ?? null,
|
|
124
338
|
lease_expires_at: m.lease_expires_at ?? null,
|
|
125
339
|
parent_run_id: m.parent_run_id ?? null,
|
|
340
|
+
db_at: m.db_at ?? null,
|
|
126
341
|
})),
|
|
127
342
|
writes: new Map(snap.writes),
|
|
128
343
|
};
|
|
@@ -175,11 +390,7 @@ export function createPostgresSignalFake(options?: {
|
|
|
175
390
|
|
|
176
391
|
// Claim: SELECT … FOR UPDATE SKIP LOCKED (pending or lease-expired inflight),
|
|
177
392
|
// with per-key serialization when ordering_key is set.
|
|
178
|
-
|
|
179
|
-
/FOR\s+UPDATE\s+SKIP\s+LOCKED/i.test(text) &&
|
|
180
|
-
/oke_signal_messages/i.test(text) &&
|
|
181
|
-
/delivery\s*=\s*'once'/i.test(text);
|
|
182
|
-
if (isOnceClaim) {
|
|
393
|
+
if (isOnceClaim(text)) {
|
|
183
394
|
const signal = String(params[0]);
|
|
184
395
|
const t = Number(params[1]);
|
|
185
396
|
const leaseCutoff = Number(params[2]);
|
|
@@ -213,52 +424,47 @@ export function createPostgresSignalFake(options?: {
|
|
|
213
424
|
return [{ ...row }];
|
|
214
425
|
}
|
|
215
426
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
427
|
+
// Before broader SELECTs: poll reads db_at and the database clock together.
|
|
428
|
+
if (isFanoutPoll(text)) {
|
|
429
|
+
const lower = Number(params[0]);
|
|
430
|
+
const dbNow = clock();
|
|
431
|
+
const matched = state.messages.filter(
|
|
432
|
+
(m) =>
|
|
433
|
+
(m.delivery === "broadcast" || m.delivery === "live") &&
|
|
434
|
+
m.db_at != null &&
|
|
435
|
+
m.db_at >= lower,
|
|
219
436
|
);
|
|
220
|
-
|
|
437
|
+
if (matched.length === 0) return [{ db_now: dbNow }];
|
|
438
|
+
return matched.map((m) => ({ ...m, db_now: dbNow }));
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (isSelectStatus(text)) {
|
|
221
442
|
return state.messages
|
|
222
443
|
.filter((m) => m.signal === params[0] && m.status === params[1])
|
|
223
444
|
.map((m) => ({ ...m }));
|
|
224
445
|
}
|
|
225
446
|
|
|
226
|
-
|
|
227
|
-
/^SELECT\s+\*\s+FROM\s+oke_signal_messages\s+WHERE\s+status\s+IN\s*\('pending',\s*'inflight'\)\s*$/i.exec(
|
|
228
|
-
text,
|
|
229
|
-
);
|
|
230
|
-
if (selPending) {
|
|
447
|
+
if (isSelectPending(text)) {
|
|
231
448
|
return state.messages
|
|
232
449
|
.filter((m) => m.status === "pending" || m.status === "inflight")
|
|
233
450
|
.map((m) => ({ ...m }));
|
|
234
451
|
}
|
|
235
452
|
|
|
236
|
-
|
|
237
|
-
/^SELECT\s+\*\s+FROM\s+oke_signal_messages\s+WHERE\s+signal\s*=\s*\?\s*$/i.exec(text);
|
|
238
|
-
if (selAll) {
|
|
453
|
+
if (isSelectSignal(text)) {
|
|
239
454
|
return state.messages.filter((m) => m.signal === params[0]).map((m) => ({ ...m }));
|
|
240
455
|
}
|
|
241
456
|
|
|
242
|
-
|
|
243
|
-
/^SELECT\s+value\s+FROM\s+oke_signal_writes\s+WHERE\s+key\s*=\s*\?\s*$/i.exec(text);
|
|
244
|
-
if (selWrite) {
|
|
457
|
+
if (isSelectWrite(text)) {
|
|
245
458
|
const v = state.writes.get(String(params[0]));
|
|
246
459
|
if (v === undefined) return [];
|
|
247
460
|
return [{ value: JSON.stringify(v) }];
|
|
248
461
|
}
|
|
249
462
|
|
|
250
|
-
|
|
251
|
-
text,
|
|
252
|
-
);
|
|
253
|
-
if (byId) {
|
|
463
|
+
if (isSelectId(text)) {
|
|
254
464
|
return state.messages.filter((m) => m.id === params[0]).map((m) => ({ ...m }));
|
|
255
465
|
}
|
|
256
466
|
|
|
257
|
-
|
|
258
|
-
/^SELECT\s+\*\s+FROM\s+oke_signal_messages\s+WHERE\s+signal\s*=\s*\?\s+AND\s+delivery\s*=\s*'live'\s+ORDER\s+BY\s+created_at\s+ASC\s*$/i.exec(
|
|
259
|
-
text,
|
|
260
|
-
);
|
|
261
|
-
if (selLive) {
|
|
467
|
+
if (isSelectLive(text)) {
|
|
262
468
|
return state.messages
|
|
263
469
|
.filter((m) => m.signal === params[0] && m.delivery === "live")
|
|
264
470
|
.map((m) => ({ ...m }));
|
|
@@ -271,14 +477,25 @@ export function createPostgresSignalFake(options?: {
|
|
|
271
477
|
const text = sql.trim();
|
|
272
478
|
const state = view();
|
|
273
479
|
|
|
274
|
-
if (
|
|
275
|
-
|
|
480
|
+
if (isCreateTable(text)) return { changes: 0 };
|
|
481
|
+
// Dedicated index branch before the broader CREATE INDEX match.
|
|
482
|
+
if (isDeliveryDbAtIndex(text)) return { changes: 0 };
|
|
483
|
+
if (isCreateIndex(text)) return { changes: 0 };
|
|
276
484
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
)
|
|
281
|
-
|
|
485
|
+
if (isBroadcastTtlDelete(text)) {
|
|
486
|
+
const cutoff = Number(params[0]);
|
|
487
|
+
let changes = 0;
|
|
488
|
+
for (let i = state.messages.length - 1; i >= 0; i--) {
|
|
489
|
+
const m = state.messages[i]!;
|
|
490
|
+
if (m.delivery === "broadcast" && m.db_at != null && m.db_at < cutoff) {
|
|
491
|
+
state.messages.splice(i, 1);
|
|
492
|
+
changes += 1;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
return { changes };
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
if (isDeleteLive(text)) {
|
|
282
499
|
let changes = 0;
|
|
283
500
|
for (let i = state.messages.length - 1; i >= 0; i--) {
|
|
284
501
|
const m = state.messages[i]!;
|
|
@@ -290,11 +507,7 @@ export function createPostgresSignalFake(options?: {
|
|
|
290
507
|
return { changes };
|
|
291
508
|
}
|
|
292
509
|
|
|
293
|
-
|
|
294
|
-
/^DELETE\s+FROM\s+oke_signal_messages\s+WHERE\s+id\s*=\s*\?\s+AND\s+signal\s*=\s*\?\s+AND\s+status\s*=\s*'dead'\s*$/i.exec(
|
|
295
|
-
text,
|
|
296
|
-
);
|
|
297
|
-
if (delDead) {
|
|
510
|
+
if (isDeleteDead(text)) {
|
|
298
511
|
let changes = 0;
|
|
299
512
|
for (let i = state.messages.length - 1; i >= 0; i--) {
|
|
300
513
|
const m = state.messages[i]!;
|
|
@@ -306,54 +519,60 @@ export function createPostgresSignalFake(options?: {
|
|
|
306
519
|
return { changes };
|
|
307
520
|
}
|
|
308
521
|
|
|
309
|
-
|
|
310
|
-
|
|
522
|
+
// Before the generic INSERT: `db_at` is `clock_timestamp()`, not a placeholder.
|
|
523
|
+
if (isInsertDbAt(text)) {
|
|
524
|
+
const head =
|
|
525
|
+
/^INSERT\s+INTO\s+oke_signal_messages\s*\(([^)]+)\)\s*VALUES\s*\((.*)\)\s*$/i.exec(text);
|
|
526
|
+
if (!head) throw new Error(`postgres signal fake: unsupported exec: ${sql}`);
|
|
527
|
+
const cols = head[1]!.split(",").map((c) => c.trim());
|
|
528
|
+
const fields: Record<string, unknown> = {};
|
|
529
|
+
let pi = 0;
|
|
530
|
+
for (const col of cols) {
|
|
531
|
+
if (col === "db_at") {
|
|
532
|
+
fields.db_at = clock();
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
fields[col] = params[pi++];
|
|
536
|
+
}
|
|
537
|
+
state.messages.push(msgRowFromFields(fields));
|
|
538
|
+
return { changes: 1 };
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const insertMsg = isInsertMessage(text)
|
|
542
|
+
? /^INSERT\s+INTO\s+oke_signal_messages\s*\(([^)]+)\)\s*VALUES\s*\(([^)]+)\)\s*$/i.exec(
|
|
543
|
+
text,
|
|
544
|
+
)
|
|
545
|
+
: null;
|
|
311
546
|
if (insertMsg) {
|
|
312
547
|
const cols = insertMsg[1]!.split(",").map((c) => c.trim());
|
|
313
548
|
const row: Record<string, unknown> = {};
|
|
314
549
|
cols.forEach((c, i) => {
|
|
315
550
|
row[c] = params[i];
|
|
316
551
|
});
|
|
317
|
-
state.messages.push(
|
|
318
|
-
id: String(row.id),
|
|
319
|
-
signal: String(row.signal),
|
|
320
|
-
payload: String(row.payload),
|
|
321
|
-
ordering_key:
|
|
322
|
-
row.ordering_key === undefined || row.ordering_key === null
|
|
323
|
-
? null
|
|
324
|
-
: String(row.ordering_key),
|
|
325
|
-
delivery: row.delivery as SignalDelivery,
|
|
326
|
-
attempts: Number(row.attempts ?? 0),
|
|
327
|
-
failures: String(row.failures ?? "[]"),
|
|
328
|
-
created_at: Number(row.created_at),
|
|
329
|
-
available_at: Number(row.available_at),
|
|
330
|
-
status: (row.status as MsgRow["status"]) ?? "pending",
|
|
331
|
-
locked_by: (row.locked_by as string | null) ?? null,
|
|
332
|
-
lease_expires_at:
|
|
333
|
-
row.lease_expires_at === undefined || row.lease_expires_at === null
|
|
334
|
-
? null
|
|
335
|
-
: Number(row.lease_expires_at),
|
|
336
|
-
delivered_to: String(row.delivered_to ?? "[]"),
|
|
337
|
-
parent_run_id:
|
|
338
|
-
row.parent_run_id === undefined || row.parent_run_id === null
|
|
339
|
-
? null
|
|
340
|
-
: String(row.parent_run_id),
|
|
341
|
-
});
|
|
552
|
+
state.messages.push(msgRowFromFields(row));
|
|
342
553
|
return { changes: 1 };
|
|
343
554
|
}
|
|
344
555
|
|
|
345
|
-
|
|
346
|
-
/^INSERT\s+INTO\s+oke_signal_writes\s*\(key,\s*value\)\s*VALUES\s*\(\?,\s*\?\)\s*$/i.exec(
|
|
347
|
-
text,
|
|
348
|
-
);
|
|
349
|
-
if (insertWrite) {
|
|
556
|
+
if (isInsertWrite(text)) {
|
|
350
557
|
state.writes.set(String(params[0]), JSON.parse(String(params[1])));
|
|
351
558
|
return { changes: 1 };
|
|
352
559
|
}
|
|
353
560
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
561
|
+
// Before the generic UPDATE: backfill `db_at` from `created_at`.
|
|
562
|
+
if (isBackfillDbAt(text)) {
|
|
563
|
+
let changes = 0;
|
|
564
|
+
for (const m of state.messages) {
|
|
565
|
+
if (m.db_at == null) {
|
|
566
|
+
m.db_at = m.created_at;
|
|
567
|
+
changes += 1;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
return { changes };
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
const upd = isUpdateById(text)
|
|
574
|
+
? /^UPDATE\s+oke_signal_messages\s+SET\s+(.+)\s+WHERE\s+id\s*=\s*\?\s*$/i.exec(text)
|
|
575
|
+
: null;
|
|
357
576
|
if (upd) {
|
|
358
577
|
const id = String(params[params.length - 1]);
|
|
359
578
|
const row = state.messages.find((m) => m.id === id);
|
|
@@ -377,7 +596,7 @@ export function createPostgresSignalFake(options?: {
|
|
|
377
596
|
return { changes: 1 };
|
|
378
597
|
}
|
|
379
598
|
|
|
380
|
-
if (
|
|
599
|
+
if (isAlterAddColumn(text)) {
|
|
381
600
|
return { changes: 0 };
|
|
382
601
|
}
|
|
383
602
|
|
|
@@ -444,7 +663,8 @@ async function ensureSchema(sql: PostgresSignalSql): Promise<void> {
|
|
|
444
663
|
locked_by TEXT,
|
|
445
664
|
lease_expires_at BIGINT,
|
|
446
665
|
delivered_to TEXT,
|
|
447
|
-
parent_run_id TEXT
|
|
666
|
+
parent_run_id TEXT,
|
|
667
|
+
db_at BIGINT
|
|
448
668
|
)`);
|
|
449
669
|
// Existing tables created before leases / keys: add columns in place.
|
|
450
670
|
try {
|
|
@@ -475,6 +695,21 @@ async function ensureSchema(sql: PostgresSignalSql): Promise<void> {
|
|
|
475
695
|
} catch {
|
|
476
696
|
/* fake / older engines without IF NOT EXISTS — ignore */
|
|
477
697
|
}
|
|
698
|
+
try {
|
|
699
|
+
await sql.exec(`ALTER TABLE oke_signal_messages ADD COLUMN IF NOT EXISTS db_at BIGINT`);
|
|
700
|
+
} catch {
|
|
701
|
+
/* fake / older engines without IF NOT EXISTS — ignore */
|
|
702
|
+
}
|
|
703
|
+
try {
|
|
704
|
+
await sql.exec(POSTGRES_SIGNAL_FANOUT_SQL.backfill);
|
|
705
|
+
} catch {
|
|
706
|
+
/* fake / older engines — ignore */
|
|
707
|
+
}
|
|
708
|
+
try {
|
|
709
|
+
await sql.exec(POSTGRES_SIGNAL_FANOUT_SQL.index);
|
|
710
|
+
} catch {
|
|
711
|
+
/* fake / older engines without IF NOT EXISTS — ignore */
|
|
712
|
+
}
|
|
478
713
|
}
|
|
479
714
|
|
|
480
715
|
function rowToMessage(row: Record<string, unknown>): SignalMessage {
|
|
@@ -501,15 +736,31 @@ function rowToMessage(row: Record<string, unknown>): SignalMessage {
|
|
|
501
736
|
};
|
|
502
737
|
}
|
|
503
738
|
|
|
739
|
+
/**
|
|
740
|
+
* Options for {@link openPostgresSignal}.
|
|
741
|
+
*/
|
|
742
|
+
export interface PostgresSignalOpenOptions extends SignalOpenOptions {
|
|
743
|
+
/**
|
|
744
|
+
* Overlap window (ms) for broadcast and live polls.
|
|
745
|
+
* Rows with `db_at >= cursor - lagMs` are re-read and deduped by id.
|
|
746
|
+
* Defaults to {@link POSTGRES_SIGNAL_DEFAULT_LAG_MS}.
|
|
747
|
+
*/
|
|
748
|
+
readonly lagMs?: number;
|
|
749
|
+
}
|
|
750
|
+
|
|
504
751
|
/**
|
|
505
752
|
* Open a postgres-backed signal bus.
|
|
506
753
|
*
|
|
507
|
-
* @param options - Declarations / sql fake / clock
|
|
754
|
+
* @param options - Declarations / sql fake / clock / lag window
|
|
508
755
|
*/
|
|
509
|
-
export async function openPostgresSignal(options:
|
|
756
|
+
export async function openPostgresSignal(options: PostgresSignalOpenOptions): Promise<SignalBus> {
|
|
510
757
|
const now = options.now ?? (() => Date.now());
|
|
511
758
|
const signals = options.signals;
|
|
512
759
|
const leaseMs = options.leaseMs ?? SIGNAL_DEFAULT_LEASE_MS;
|
|
760
|
+
const lagMs = options.lagMs ?? POSTGRES_SIGNAL_DEFAULT_LAG_MS;
|
|
761
|
+
// Broadcast retention stays strictly longer than the overlap window.
|
|
762
|
+
// Live retention stays on pruneLive (default unbounded).
|
|
763
|
+
const broadcastTtlMs = lagMs + 1_000;
|
|
513
764
|
const sql =
|
|
514
765
|
(options.sql as PostgresSignalSql | undefined) ??
|
|
515
766
|
createPostgresSignalFake({
|
|
@@ -522,6 +773,37 @@ export async function openPostgresSignal(options: SignalOpenOptions): Promise<Si
|
|
|
522
773
|
}
|
|
523
774
|
await ensureSchema(sql);
|
|
524
775
|
|
|
776
|
+
// Best-effort within lagMs. A transaction that commits more than lagMs
|
|
777
|
+
// after its db_at can be missed. Not gap-free ordering. No persisted cursor table.
|
|
778
|
+
let fanoutCursor = 0;
|
|
779
|
+
const fanoutSeen = new Map<string, number>();
|
|
780
|
+
|
|
781
|
+
async function readFanout(lower: number): Promise<{
|
|
782
|
+
rows: Record<string, unknown>[];
|
|
783
|
+
dbNow: number;
|
|
784
|
+
}> {
|
|
785
|
+
const raw = await sql.query(POSTGRES_SIGNAL_FANOUT_SQL.poll, [lower]);
|
|
786
|
+
let dbNow = Number.NaN;
|
|
787
|
+
const rows: Record<string, unknown>[] = [];
|
|
788
|
+
for (const row of raw) {
|
|
789
|
+
if (Number.isNaN(dbNow)) dbNow = Number(row.db_now);
|
|
790
|
+
if (row.id == null) continue;
|
|
791
|
+
rows.push(row);
|
|
792
|
+
}
|
|
793
|
+
if (!Number.isFinite(dbNow)) {
|
|
794
|
+
throw new Error("postgres signal: fanout poll did not return db_now");
|
|
795
|
+
}
|
|
796
|
+
return { rows, dbNow };
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// Start at the current database time so messages committed while this
|
|
800
|
+
// instance was offline are already behind the cursor.
|
|
801
|
+
const bootSnap = await readFanout(Number.MIN_SAFE_INTEGER);
|
|
802
|
+
fanoutCursor = bootSnap.dbNow;
|
|
803
|
+
for (const row of bootSnap.rows) {
|
|
804
|
+
fanoutSeen.set(String(row.id), bootSnap.dbNow);
|
|
805
|
+
}
|
|
806
|
+
|
|
525
807
|
const consumers: Array<{
|
|
526
808
|
signal: string;
|
|
527
809
|
subscriberId: string;
|
|
@@ -619,25 +901,22 @@ export async function openPostgresSignal(options: SignalOpenOptions): Promise<Si
|
|
|
619
901
|
typeof options?.parentRunId === "string" && options.parentRunId.length > 0
|
|
620
902
|
? options.parentRunId
|
|
621
903
|
: null;
|
|
622
|
-
await tx.exec(
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
parentRunId,
|
|
639
|
-
],
|
|
640
|
-
);
|
|
904
|
+
await tx.exec(POSTGRES_SIGNAL_FANOUT_SQL.insert, [
|
|
905
|
+
okid(),
|
|
906
|
+
signal,
|
|
907
|
+
JSON.stringify(payload ?? null),
|
|
908
|
+
orderingKey,
|
|
909
|
+
decl.delivery,
|
|
910
|
+
0,
|
|
911
|
+
"[]",
|
|
912
|
+
t,
|
|
913
|
+
t,
|
|
914
|
+
"pending",
|
|
915
|
+
null,
|
|
916
|
+
null,
|
|
917
|
+
"[]",
|
|
918
|
+
parentRunId,
|
|
919
|
+
]);
|
|
641
920
|
}
|
|
642
921
|
|
|
643
922
|
async function begin(): Promise<SignalTransaction> {
|
|
@@ -867,87 +1146,64 @@ export async function openPostgresSignal(options: SignalOpenOptions): Promise<Si
|
|
|
867
1146
|
return progress;
|
|
868
1147
|
}
|
|
869
1148
|
|
|
870
|
-
async function
|
|
1149
|
+
async function drainFanoutPass(): Promise<boolean> {
|
|
871
1150
|
let progress = false;
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
const
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
consumer.signal,
|
|
884
|
-
]);
|
|
885
|
-
const current = fresh.find((r) => r.id === row.id);
|
|
886
|
-
if (!current || current.status === "delivered") continue;
|
|
887
|
-
const deliveredTo = new Set(JSON.parse(String(current.delivered_to ?? "[]")) as string[]);
|
|
888
|
-
if (deliveredTo.has(consumer.subscriberId)) continue;
|
|
889
|
-
// Claim this subscriber slot before invoking the handler.
|
|
890
|
-
deliveredTo.add(consumer.subscriberId);
|
|
891
|
-
const subs = consumers.filter((c) => c.signal === consumer.signal);
|
|
892
|
-
const status = subs.every((s) => deliveredTo.has(s.subscriberId)) ? "delivered" : "pending";
|
|
893
|
-
await sql.exec(
|
|
894
|
-
`UPDATE oke_signal_messages SET delivered_to = ?, status = ?, attempts = ? WHERE id = ?`,
|
|
895
|
-
[JSON.stringify([...deliveredTo]), status, Number(current.attempts) + 1, row.id],
|
|
896
|
-
);
|
|
1151
|
+
const snap = await readFanout(fanoutCursor - lagMs);
|
|
1152
|
+
const liveSignals = new Set<string>();
|
|
1153
|
+
for (const row of snap.rows) {
|
|
1154
|
+
const id = String(row.id);
|
|
1155
|
+
if (fanoutSeen.has(id)) continue;
|
|
1156
|
+
const delivery = String(row.delivery);
|
|
1157
|
+
if (delivery === "broadcast") {
|
|
1158
|
+
const name = String(row.signal);
|
|
1159
|
+
const subs = consumers.filter((c) => c.signal === name);
|
|
1160
|
+
if (subs.length === 0) continue;
|
|
1161
|
+
fanoutSeen.set(id, snap.dbNow);
|
|
897
1162
|
progress = true;
|
|
898
|
-
const msg = rowToMessage(
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
1163
|
+
const msg = rowToMessage(row);
|
|
1164
|
+
for (const consumer of subs) {
|
|
1165
|
+
try {
|
|
1166
|
+
await consumer.handler(msg);
|
|
1167
|
+
noteDelivered();
|
|
1168
|
+
} catch (err) {
|
|
1169
|
+
const failures = [...msg.failures, failureFromError(err, msg.attempts + 1)];
|
|
1170
|
+
const key = `${name}::${consumer.subscriberId}`;
|
|
1171
|
+
subscriberErrors.set(key, (subscriberErrors.get(key) ?? 0) + 1);
|
|
1172
|
+
await sql.exec(`UPDATE oke_signal_messages SET failures = ? WHERE id = ?`, [
|
|
1173
|
+
JSON.stringify(failures),
|
|
1174
|
+
id,
|
|
1175
|
+
]);
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
} else if (delivery === "live") {
|
|
1179
|
+
fanoutSeen.set(id, snap.dbNow);
|
|
1180
|
+
progress = true;
|
|
1181
|
+
const name = String(row.signal);
|
|
1182
|
+
liveSignals.add(name);
|
|
1183
|
+
const handlers = liveHandlers.get(name);
|
|
1184
|
+
const payload = JSON.parse(String(row.payload));
|
|
1185
|
+
if (handlers) {
|
|
1186
|
+
const event: LiveEvent = { id, payload };
|
|
1187
|
+
for (const h of handlers) await h(event);
|
|
910
1188
|
}
|
|
1189
|
+
let list = recentLive.get(name);
|
|
1190
|
+
if (!list) {
|
|
1191
|
+
list = [];
|
|
1192
|
+
recentLive.set(name, list);
|
|
1193
|
+
}
|
|
1194
|
+
list.push(payload);
|
|
1195
|
+
while (list.length > 50) list.shift();
|
|
1196
|
+
noteDelivered();
|
|
911
1197
|
}
|
|
912
1198
|
}
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
async function drainLivePass(): Promise<boolean> {
|
|
917
|
-
let progress = false;
|
|
918
|
-
const pending = await sql.query(
|
|
919
|
-
`SELECT * FROM oke_signal_messages WHERE status IN ('pending', 'inflight')`,
|
|
920
|
-
);
|
|
921
|
-
for (const row of pending) {
|
|
922
|
-
if (row.delivery !== "live") continue;
|
|
923
|
-
progress = true;
|
|
924
|
-
const handlers = liveHandlers.get(String(row.signal));
|
|
925
|
-
const payload = JSON.parse(String(row.payload));
|
|
926
|
-
if (handlers) {
|
|
927
|
-
const event: LiveEvent = { id: String(row.id), payload };
|
|
928
|
-
for (const h of handlers) await h(event);
|
|
929
|
-
}
|
|
930
|
-
const sig = String(row.signal);
|
|
931
|
-
let list = recentLive.get(sig);
|
|
932
|
-
if (!list) {
|
|
933
|
-
list = [];
|
|
934
|
-
recentLive.set(sig, list);
|
|
935
|
-
}
|
|
936
|
-
list.push(payload);
|
|
937
|
-
while (list.length > 50) list.shift();
|
|
938
|
-
noteDelivered();
|
|
939
|
-
await sql.exec(
|
|
940
|
-
`UPDATE oke_signal_messages SET status = 'delivered', locked_by = NULL, lease_expires_at = NULL WHERE id = ?`,
|
|
941
|
-
[row.id],
|
|
942
|
-
);
|
|
1199
|
+
const pruneBefore = snap.dbNow - lagMs;
|
|
1200
|
+
for (const [id, seenAt] of fanoutSeen) {
|
|
1201
|
+
if (seenAt < pruneBefore) fanoutSeen.delete(id);
|
|
943
1202
|
}
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
if (pruned.has(sig)) continue;
|
|
949
|
-
pruned.add(sig);
|
|
950
|
-
await pruneLive(sig);
|
|
1203
|
+
fanoutCursor = snap.dbNow;
|
|
1204
|
+
await sql.exec(BROADCAST_TTL_DELETE_SQL, [snap.dbNow - broadcastTtlMs]);
|
|
1205
|
+
for (const name of liveSignals) {
|
|
1206
|
+
await pruneLive(name);
|
|
951
1207
|
}
|
|
952
1208
|
return progress;
|
|
953
1209
|
}
|
|
@@ -960,9 +1216,8 @@ export async function openPostgresSignal(options: SignalOpenOptions): Promise<Si
|
|
|
960
1216
|
draining = (async () => {
|
|
961
1217
|
for (let i = 0; i < 1000; i++) {
|
|
962
1218
|
const a = await drainOncePass();
|
|
963
|
-
const b = await
|
|
964
|
-
|
|
965
|
-
if (!a && !b && !c) break;
|
|
1219
|
+
const b = await drainFanoutPass();
|
|
1220
|
+
if (!a && !b) break;
|
|
966
1221
|
}
|
|
967
1222
|
})();
|
|
968
1223
|
try {
|
|
@@ -1012,22 +1267,20 @@ export async function openPostgresSignal(options: SignalOpenOptions): Promise<Si
|
|
|
1012
1267
|
}
|
|
1013
1268
|
}
|
|
1014
1269
|
const subs = consumers.filter((c) => c.signal === name);
|
|
1015
|
-
const subscribers =
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
}),
|
|
1030
|
-
);
|
|
1270
|
+
const subscribers = subs.map((c) => {
|
|
1271
|
+
let lag = 0;
|
|
1272
|
+
for (const row of rows) {
|
|
1273
|
+
if (row.delivery !== "broadcast") continue;
|
|
1274
|
+
if (row.status === "dead") continue;
|
|
1275
|
+
if (fanoutSeen.has(String(row.id))) continue;
|
|
1276
|
+
lag += 1;
|
|
1277
|
+
}
|
|
1278
|
+
return {
|
|
1279
|
+
id: c.subscriberId,
|
|
1280
|
+
lag,
|
|
1281
|
+
errorCount: subscriberErrors.get(`${name}::${c.subscriberId}`) ?? 0,
|
|
1282
|
+
};
|
|
1283
|
+
});
|
|
1031
1284
|
return {
|
|
1032
1285
|
signal: name,
|
|
1033
1286
|
delivery: decl.delivery,
|