okengine 0.23.0 → 0.23.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/site/content/docs/ai/mcp.mdx +4 -4
- package/site/content/docs/elements/channel/index.mdx +4 -4
- package/site/content/docs/elements/channel/receipts.mdx +7 -5
- package/site/content/docs/elements/channel/sms.mdx +2 -1
- package/site/content/docs/elements/flow/index.mdx +22 -18
- package/site/content/docs/elements/signal/broadcast.mdx +4 -2
- package/site/content/docs/elements/signal/index.mdx +15 -14
- package/site/content/docs/plugins/otp.mdx +3 -3
- package/site/content/docs/reference/configuration.mdx +16 -0
- package/site/content/docs/reference/errors.mdx +31 -19
- package/site/content/docs/reference/fx.mdx +12 -4
- package/src/auth/tenants.ts +17 -0
- package/src/cli/doctor-diff.test.ts +29 -1
- package/src/cli/doctor-diff.ts +35 -0
- package/src/cli/manifest-pr-diff.ts +16 -0
- package/src/compiler/aot.test.ts +3 -2
- package/src/compiler/aot.ts +45 -1
- package/src/compiler/dynamic.ts +1 -1
- package/src/compiler/effects-fetch.test.ts +77 -0
- package/src/compiler/effects-infer.ts +87 -16
- package/src/compiler/effects-join.test.ts +48 -0
- package/src/compiler/extract.test.ts +28 -0
- package/src/compiler/extract.ts +11 -1
- package/src/compiler/fx-index.ts +58 -4
- package/src/compiler/http-parse.test.ts +101 -0
- package/src/compiler/http-parse.ts +188 -9
- package/src/compiler/interpret.ts +25 -2
- package/src/console/index.ts +1 -1
- package/src/console/ui-next/dist/assets/{access-page-DDKkhT9v.js → access-page-zBMhZnFm.js} +1 -1
- package/src/console/ui-next/dist/assets/{agent-disclosure-62Xts8Xi.js → agent-disclosure-CBj9f7Ju.js} +1 -1
- package/src/console/ui-next/dist/assets/{cache-glyph-DyeoKHKA.js → cache-glyph-D3MY49h3.js} +1 -1
- package/src/console/ui-next/dist/assets/{call-pii-button-DvfpLXsU.js → call-pii-button-L9JxdBhp.js} +1 -1
- package/src/console/ui-next/dist/assets/{collapsible-BY03SeCg.js → collapsible-BvFaX-Zt.js} +1 -1
- package/src/console/ui-next/dist/assets/{decisions-page-CgkKlA56.js → decisions-page-DgB76m-X.js} +1 -1
- package/src/console/ui-next/dist/assets/{duration-tone-Y6HLjyJ5.js → duration-tone-l1DSJ2Vz.js} +1 -1
- package/src/console/ui-next/dist/assets/{flows-page-DLPfNy-_.js → flows-page-Bb9t1lu6.js} +1 -1
- package/src/console/ui-next/dist/assets/{highlighted-json-D7nNzpgB.js → highlighted-json-DMvsi3Ti.js} +1 -1
- package/src/console/ui-next/dist/assets/{http-method-DdL19zzh.js → http-method-BwhJBTcQ.js} +1 -1
- package/src/console/ui-next/dist/assets/{index-C0lc_s9d.js → index-RhV2jT_7.js} +3 -3
- package/src/console/ui-next/dist/assets/{observability-page-CRlcyLCC.js → observability-page-BRfUILBq.js} +1 -1
- package/src/console/ui-next/dist/assets/{replica-lag-Bb3FWUu9.js → replica-lag-BsceAzIG.js} +1 -1
- package/src/console/ui-next/dist/assets/{request-meta-DYBMWhX8.js → request-meta-C6lTyCXp.js} +1 -1
- package/src/console/ui-next/dist/assets/{store-page-CcE-SXC8.js → store-page-YmE806bJ.js} +1 -1
- package/src/console/ui-next/dist/assets/{trace-detail-sheet-CKMSEQ4s.js → trace-detail-sheet-Di4irS49.js} +1 -1
- package/src/console/ui-next/dist/assets/{tree-expand-toggle-QOfk0M0H.js → tree-expand-toggle-DYeL6Rye.js} +1 -1
- package/src/console/ui-next/dist/assets/{units-page-DKC1CDDd.js → units-page-07agasRA.js} +1 -1
- package/src/console/ui-next/dist/assets/{vault-page-B54v4Yit.js → vault-page-CHBck4n_.js} +1 -1
- package/src/console/ui-next/dist/index.html +1 -1
- package/src/console/ui-next/seed-parked-approval.ts +5 -1
- package/src/drivers/journal-postgres.test.ts +106 -2
- package/src/drivers/journal-postgres.ts +475 -14
- package/src/drivers/postgres.test.ts +60 -0
- package/src/drivers/postgres.ts +79 -5
- package/src/drivers/signal-compete.test.ts +238 -0
- package/src/drivers/signal-postgres.test.ts +170 -0
- package/src/drivers/signal-postgres.ts +486 -213
- package/src/drivers/signal-redis.test.ts +224 -0
- package/src/drivers/signal-redis.ts +934 -93
- package/src/drivers/signal-types.ts +12 -0
- package/src/elements/ai/approval.test.ts +15 -3
- package/src/elements/ai/approval.ts +15 -9
- package/src/elements/ai/mcp-protocol.ts +5 -6
- package/src/elements/ai/runtime.ts +20 -19
- package/src/elements/channel/runtime.ts +42 -4
- package/src/elements/channel/sql-ledger.test.ts +404 -0
- package/src/elements/channel/sql-ledger.ts +395 -0
- package/src/elements/clock/chaos-child.ts +12 -2
- package/src/elements/clock/durable.ts +11 -0
- package/src/elements/signal/runtime.ts +9 -0
- package/src/elements/signal.test.ts +1 -0
- package/src/elements/store/cache-bus.test.ts +121 -0
- package/src/elements/store/cache-bus.ts +278 -0
- package/src/elements/store/cache.test.ts +179 -15
- package/src/elements/store/cache.ts +357 -51
- package/src/elements/store/prepare-row.test.ts +6 -2
- package/src/elements/store/runtime.ts +44 -2
- package/src/elements/store/sql-errors.test.ts +131 -0
- package/src/elements/store/sql-errors.ts +39 -16
- package/src/elements/store/sql-nested-tx.test.ts +344 -0
- package/src/elements/store/sql-session.test.ts +2 -0
- package/src/elements/store/sql-session.ts +275 -9
- package/src/elements/store.ts +1 -0
- package/src/kernel/app.ts +207 -43
- package/src/kernel/auto-cache.test.ts +228 -0
- package/src/kernel/auto-registry.test.ts +5 -1
- package/src/kernel/boot-bind/channel.ts +63 -2
- package/src/kernel/boot-bind/honor-config.test.ts +40 -10
- package/src/kernel/boot-bind/signal.ts +72 -25
- package/src/kernel/boot-bind/store.ts +16 -1
- package/src/kernel/boot.test.ts +2 -2
- package/src/kernel/boot.ts +54 -10
- package/src/kernel/boundary-contract.ts +7 -0
- package/src/kernel/builtin-errors.ts +2 -0
- package/src/kernel/call.test.ts +1 -0
- package/src/kernel/errors-compiler.ts +11 -0
- package/src/kernel/errors-text.ts +16 -0
- package/src/kernel/errors.ts +8 -0
- package/src/kernel/flow.ts +33 -3
- package/src/kernel/fx-call-types.test.ts +32 -0
- package/src/kernel/fx-decide.ts +11 -7
- package/src/kernel/fx-sql-handle.ts +52 -7
- package/src/kernel/fx.test.ts +1 -3
- package/src/kernel/fx.ts +27 -16
- package/src/kernel/horizontal-child.ts +54 -1
- package/src/kernel/horizontal.integration.test.ts +131 -7
- package/src/kernel/idempotency.test.ts +4 -4
- package/src/kernel/journal-boot.test.ts +83 -6
- package/src/kernel/journal.test.ts +158 -0
- package/src/kernel/journal.ts +515 -54
- package/src/kernel/pipeline-tenant.ts +5 -1
- package/src/kernel/pipeline.ts +5 -0
- package/src/kernel/signal-tx.ts +28 -0
- package/src/kernel/store-transaction.test.ts +95 -0
- package/src/mcp/authorization.ts +5 -11
- package/src/mcp/confirmation.ts +203 -38
- package/src/mcp/docs-server.ts +26 -4
- package/src/mcp/index.ts +4 -0
- package/src/mcp/mcp.test.ts +404 -33
- package/src/mcp/protocol.ts +8 -2
- package/src/mcp/server.ts +31 -5
- package/src/mcp/session.ts +7 -0
- package/src/mcp/tools.ts +68 -19
- package/src/mcp/versions.ts +60 -0
- package/src/plugins/index.ts +1 -0
- package/src/plugins/mena.ts +56 -0
- package/src/runtime/bun.ts +5 -0
- package/src/runtime/types.ts +5 -0
|
@@ -1,8 +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
|
|
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`.
|
|
6
9
|
*/
|
|
7
10
|
|
|
8
11
|
import type { SignalDecl } from "../elements/signal/declare.ts";
|
|
@@ -51,6 +54,8 @@ interface MsgRow {
|
|
|
51
54
|
lease_expires_at: number | null;
|
|
52
55
|
delivered_to: string;
|
|
53
56
|
parent_run_id: string | null;
|
|
57
|
+
/** Database clock at insert (`clock_timestamp`), not the application clock. */
|
|
58
|
+
db_at: number | null;
|
|
54
59
|
}
|
|
55
60
|
|
|
56
61
|
/** Minimal SQL + listen surface for the postgres signal driver. */
|
|
@@ -65,18 +70,19 @@ export interface PostgresSignalSql {
|
|
|
65
70
|
begin<T>(fn: (sql: PostgresSignalSql) => Promise<T>): Promise<T>;
|
|
66
71
|
/**
|
|
67
72
|
* LISTEN channel; returns unsubscribe.
|
|
73
|
+
* Optional — Bun.SQL has no listen API. Omit it and poll {@link SignalBus.drain}.
|
|
68
74
|
*
|
|
69
75
|
* @param channel - Channel name
|
|
70
76
|
* @param onNotify - Payload callback
|
|
71
77
|
*/
|
|
72
|
-
listen(channel: string, onNotify: (payload: string) => void): Promise<() => void>;
|
|
78
|
+
listen?(channel: string, onNotify: (payload: string) => void): Promise<() => void>;
|
|
73
79
|
/**
|
|
74
|
-
* NOTIFY channel.
|
|
80
|
+
* NOTIFY channel. Optional when the client cannot notify.
|
|
75
81
|
*
|
|
76
82
|
* @param channel - Channel name
|
|
77
83
|
* @param payload - Payload
|
|
78
84
|
*/
|
|
79
|
-
notify(channel: string, payload: string): Promise<void>;
|
|
85
|
+
notify?(channel: string, payload: string): Promise<void>;
|
|
80
86
|
close(): Promise<void>;
|
|
81
87
|
}
|
|
82
88
|
|
|
@@ -85,6 +91,215 @@ const CHANNEL = "oke_signal";
|
|
|
85
91
|
/** Claim next once-row: pending/unlocked or lease-expired, blocked by same-key inflight. */
|
|
86
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`;
|
|
87
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
|
+
|
|
88
303
|
/**
|
|
89
304
|
* In-memory Postgres-protocol fake with transactions, SKIP LOCKED, LISTEN/NOTIFY.
|
|
90
305
|
*/
|
|
@@ -95,6 +310,7 @@ export function createPostgresSignalFake(options?: {
|
|
|
95
310
|
/** Force-kill mid-transaction (drops uncommitted state). */
|
|
96
311
|
killActiveTransaction(): void;
|
|
97
312
|
} {
|
|
313
|
+
const clock = options?.now ?? (() => Date.now());
|
|
98
314
|
const listeners = new Map<string, Set<(payload: string) => void>>();
|
|
99
315
|
|
|
100
316
|
type State = {
|
|
@@ -121,6 +337,7 @@ export function createPostgresSignalFake(options?: {
|
|
|
121
337
|
ordering_key: m.ordering_key ?? null,
|
|
122
338
|
lease_expires_at: m.lease_expires_at ?? null,
|
|
123
339
|
parent_run_id: m.parent_run_id ?? null,
|
|
340
|
+
db_at: m.db_at ?? null,
|
|
124
341
|
})),
|
|
125
342
|
writes: new Map(snap.writes),
|
|
126
343
|
};
|
|
@@ -173,11 +390,7 @@ export function createPostgresSignalFake(options?: {
|
|
|
173
390
|
|
|
174
391
|
// Claim: SELECT … FOR UPDATE SKIP LOCKED (pending or lease-expired inflight),
|
|
175
392
|
// with per-key serialization when ordering_key is set.
|
|
176
|
-
|
|
177
|
-
/FOR\s+UPDATE\s+SKIP\s+LOCKED/i.test(text) &&
|
|
178
|
-
/oke_signal_messages/i.test(text) &&
|
|
179
|
-
/delivery\s*=\s*'once'/i.test(text);
|
|
180
|
-
if (isOnceClaim) {
|
|
393
|
+
if (isOnceClaim(text)) {
|
|
181
394
|
const signal = String(params[0]);
|
|
182
395
|
const t = Number(params[1]);
|
|
183
396
|
const leaseCutoff = Number(params[2]);
|
|
@@ -205,55 +418,53 @@ export function createPostgresSignalFake(options?: {
|
|
|
205
418
|
return true;
|
|
206
419
|
});
|
|
207
420
|
if (!row) return [];
|
|
421
|
+
// Hold the row before yielding so a concurrent claim skips it.
|
|
422
|
+
row.status = "inflight";
|
|
423
|
+
if (row.locked_by === null) row.locked_by = "claimed";
|
|
208
424
|
return [{ ...row }];
|
|
209
425
|
}
|
|
210
426
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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,
|
|
214
436
|
);
|
|
215
|
-
|
|
437
|
+
if (matched.length === 0) return [{ db_now: dbNow }];
|
|
438
|
+
return matched.map((m) => ({ ...m, db_now: dbNow }));
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (isSelectStatus(text)) {
|
|
216
442
|
return state.messages
|
|
217
443
|
.filter((m) => m.signal === params[0] && m.status === params[1])
|
|
218
444
|
.map((m) => ({ ...m }));
|
|
219
445
|
}
|
|
220
446
|
|
|
221
|
-
|
|
222
|
-
/^SELECT\s+\*\s+FROM\s+oke_signal_messages\s+WHERE\s+status\s+IN\s*\('pending',\s*'inflight'\)\s*$/i.exec(
|
|
223
|
-
text,
|
|
224
|
-
);
|
|
225
|
-
if (selPending) {
|
|
447
|
+
if (isSelectPending(text)) {
|
|
226
448
|
return state.messages
|
|
227
449
|
.filter((m) => m.status === "pending" || m.status === "inflight")
|
|
228
450
|
.map((m) => ({ ...m }));
|
|
229
451
|
}
|
|
230
452
|
|
|
231
|
-
|
|
232
|
-
/^SELECT\s+\*\s+FROM\s+oke_signal_messages\s+WHERE\s+signal\s*=\s*\?\s*$/i.exec(text);
|
|
233
|
-
if (selAll) {
|
|
453
|
+
if (isSelectSignal(text)) {
|
|
234
454
|
return state.messages.filter((m) => m.signal === params[0]).map((m) => ({ ...m }));
|
|
235
455
|
}
|
|
236
456
|
|
|
237
|
-
|
|
238
|
-
/^SELECT\s+value\s+FROM\s+oke_signal_writes\s+WHERE\s+key\s*=\s*\?\s*$/i.exec(text);
|
|
239
|
-
if (selWrite) {
|
|
457
|
+
if (isSelectWrite(text)) {
|
|
240
458
|
const v = state.writes.get(String(params[0]));
|
|
241
459
|
if (v === undefined) return [];
|
|
242
460
|
return [{ value: JSON.stringify(v) }];
|
|
243
461
|
}
|
|
244
462
|
|
|
245
|
-
|
|
246
|
-
text,
|
|
247
|
-
);
|
|
248
|
-
if (byId) {
|
|
463
|
+
if (isSelectId(text)) {
|
|
249
464
|
return state.messages.filter((m) => m.id === params[0]).map((m) => ({ ...m }));
|
|
250
465
|
}
|
|
251
466
|
|
|
252
|
-
|
|
253
|
-
/^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(
|
|
254
|
-
text,
|
|
255
|
-
);
|
|
256
|
-
if (selLive) {
|
|
467
|
+
if (isSelectLive(text)) {
|
|
257
468
|
return state.messages
|
|
258
469
|
.filter((m) => m.signal === params[0] && m.delivery === "live")
|
|
259
470
|
.map((m) => ({ ...m }));
|
|
@@ -266,14 +477,25 @@ export function createPostgresSignalFake(options?: {
|
|
|
266
477
|
const text = sql.trim();
|
|
267
478
|
const state = view();
|
|
268
479
|
|
|
269
|
-
if (
|
|
270
|
-
|
|
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 };
|
|
271
484
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
)
|
|
276
|
-
|
|
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)) {
|
|
277
499
|
let changes = 0;
|
|
278
500
|
for (let i = state.messages.length - 1; i >= 0; i--) {
|
|
279
501
|
const m = state.messages[i]!;
|
|
@@ -285,11 +507,7 @@ export function createPostgresSignalFake(options?: {
|
|
|
285
507
|
return { changes };
|
|
286
508
|
}
|
|
287
509
|
|
|
288
|
-
|
|
289
|
-
/^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(
|
|
290
|
-
text,
|
|
291
|
-
);
|
|
292
|
-
if (delDead) {
|
|
510
|
+
if (isDeleteDead(text)) {
|
|
293
511
|
let changes = 0;
|
|
294
512
|
for (let i = state.messages.length - 1; i >= 0; i--) {
|
|
295
513
|
const m = state.messages[i]!;
|
|
@@ -301,54 +519,60 @@ export function createPostgresSignalFake(options?: {
|
|
|
301
519
|
return { changes };
|
|
302
520
|
}
|
|
303
521
|
|
|
304
|
-
|
|
305
|
-
|
|
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;
|
|
306
546
|
if (insertMsg) {
|
|
307
547
|
const cols = insertMsg[1]!.split(",").map((c) => c.trim());
|
|
308
548
|
const row: Record<string, unknown> = {};
|
|
309
549
|
cols.forEach((c, i) => {
|
|
310
550
|
row[c] = params[i];
|
|
311
551
|
});
|
|
312
|
-
state.messages.push(
|
|
313
|
-
id: String(row.id),
|
|
314
|
-
signal: String(row.signal),
|
|
315
|
-
payload: String(row.payload),
|
|
316
|
-
ordering_key:
|
|
317
|
-
row.ordering_key === undefined || row.ordering_key === null
|
|
318
|
-
? null
|
|
319
|
-
: String(row.ordering_key),
|
|
320
|
-
delivery: row.delivery as SignalDelivery,
|
|
321
|
-
attempts: Number(row.attempts ?? 0),
|
|
322
|
-
failures: String(row.failures ?? "[]"),
|
|
323
|
-
created_at: Number(row.created_at),
|
|
324
|
-
available_at: Number(row.available_at),
|
|
325
|
-
status: (row.status as MsgRow["status"]) ?? "pending",
|
|
326
|
-
locked_by: (row.locked_by as string | null) ?? null,
|
|
327
|
-
lease_expires_at:
|
|
328
|
-
row.lease_expires_at === undefined || row.lease_expires_at === null
|
|
329
|
-
? null
|
|
330
|
-
: Number(row.lease_expires_at),
|
|
331
|
-
delivered_to: String(row.delivered_to ?? "[]"),
|
|
332
|
-
parent_run_id:
|
|
333
|
-
row.parent_run_id === undefined || row.parent_run_id === null
|
|
334
|
-
? null
|
|
335
|
-
: String(row.parent_run_id),
|
|
336
|
-
});
|
|
552
|
+
state.messages.push(msgRowFromFields(row));
|
|
337
553
|
return { changes: 1 };
|
|
338
554
|
}
|
|
339
555
|
|
|
340
|
-
|
|
341
|
-
/^INSERT\s+INTO\s+oke_signal_writes\s*\(key,\s*value\)\s*VALUES\s*\(\?,\s*\?\)\s*$/i.exec(
|
|
342
|
-
text,
|
|
343
|
-
);
|
|
344
|
-
if (insertWrite) {
|
|
556
|
+
if (isInsertWrite(text)) {
|
|
345
557
|
state.writes.set(String(params[0]), JSON.parse(String(params[1])));
|
|
346
558
|
return { changes: 1 };
|
|
347
559
|
}
|
|
348
560
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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;
|
|
352
576
|
if (upd) {
|
|
353
577
|
const id = String(params[params.length - 1]);
|
|
354
578
|
const row = state.messages.find((m) => m.id === id);
|
|
@@ -372,7 +596,7 @@ export function createPostgresSignalFake(options?: {
|
|
|
372
596
|
return { changes: 1 };
|
|
373
597
|
}
|
|
374
598
|
|
|
375
|
-
if (
|
|
599
|
+
if (isAlterAddColumn(text)) {
|
|
376
600
|
return { changes: 0 };
|
|
377
601
|
}
|
|
378
602
|
|
|
@@ -439,7 +663,8 @@ async function ensureSchema(sql: PostgresSignalSql): Promise<void> {
|
|
|
439
663
|
locked_by TEXT,
|
|
440
664
|
lease_expires_at BIGINT,
|
|
441
665
|
delivered_to TEXT,
|
|
442
|
-
parent_run_id TEXT
|
|
666
|
+
parent_run_id TEXT,
|
|
667
|
+
db_at BIGINT
|
|
443
668
|
)`);
|
|
444
669
|
// Existing tables created before leases / keys: add columns in place.
|
|
445
670
|
try {
|
|
@@ -470,6 +695,21 @@ async function ensureSchema(sql: PostgresSignalSql): Promise<void> {
|
|
|
470
695
|
} catch {
|
|
471
696
|
/* fake / older engines without IF NOT EXISTS — ignore */
|
|
472
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
|
+
}
|
|
473
713
|
}
|
|
474
714
|
|
|
475
715
|
function rowToMessage(row: Record<string, unknown>): SignalMessage {
|
|
@@ -496,15 +736,31 @@ function rowToMessage(row: Record<string, unknown>): SignalMessage {
|
|
|
496
736
|
};
|
|
497
737
|
}
|
|
498
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
|
+
|
|
499
751
|
/**
|
|
500
752
|
* Open a postgres-backed signal bus.
|
|
501
753
|
*
|
|
502
|
-
* @param options - Declarations / sql fake / clock
|
|
754
|
+
* @param options - Declarations / sql fake / clock / lag window
|
|
503
755
|
*/
|
|
504
|
-
export async function openPostgresSignal(options:
|
|
756
|
+
export async function openPostgresSignal(options: PostgresSignalOpenOptions): Promise<SignalBus> {
|
|
505
757
|
const now = options.now ?? (() => Date.now());
|
|
506
758
|
const signals = options.signals;
|
|
507
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;
|
|
508
764
|
const sql =
|
|
509
765
|
(options.sql as PostgresSignalSql | undefined) ??
|
|
510
766
|
createPostgresSignalFake({
|
|
@@ -517,6 +773,37 @@ export async function openPostgresSignal(options: SignalOpenOptions): Promise<Si
|
|
|
517
773
|
}
|
|
518
774
|
await ensureSchema(sql);
|
|
519
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
|
+
|
|
520
807
|
const consumers: Array<{
|
|
521
808
|
signal: string;
|
|
522
809
|
subscriberId: string;
|
|
@@ -565,9 +852,18 @@ export async function openPostgresSignal(options: SignalOpenOptions): Promise<Si
|
|
|
565
852
|
return n;
|
|
566
853
|
}
|
|
567
854
|
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
855
|
+
if (typeof sql.listen === "function") {
|
|
856
|
+
unlisten = await sql.listen(CHANNEL, () => {
|
|
857
|
+
void drainQuiet();
|
|
858
|
+
});
|
|
859
|
+
} else if ((options.pollMs ?? 0) > 0) {
|
|
860
|
+
const timer = setInterval(() => {
|
|
861
|
+
void drainQuiet();
|
|
862
|
+
}, options.pollMs);
|
|
863
|
+
unlisten = () => {
|
|
864
|
+
clearInterval(timer);
|
|
865
|
+
};
|
|
866
|
+
}
|
|
571
867
|
|
|
572
868
|
function requireDecl(name: string): SignalDecl {
|
|
573
869
|
const decl = signals.get(name);
|
|
@@ -605,25 +901,22 @@ export async function openPostgresSignal(options: SignalOpenOptions): Promise<Si
|
|
|
605
901
|
typeof options?.parentRunId === "string" && options.parentRunId.length > 0
|
|
606
902
|
? options.parentRunId
|
|
607
903
|
: null;
|
|
608
|
-
await tx.exec(
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
parentRunId,
|
|
625
|
-
],
|
|
626
|
-
);
|
|
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
|
+
]);
|
|
627
920
|
}
|
|
628
921
|
|
|
629
922
|
async function begin(): Promise<SignalTransaction> {
|
|
@@ -663,7 +956,9 @@ export async function openPostgresSignal(options: SignalOpenOptions): Promise<Si
|
|
|
663
956
|
await insertEmit(tx, e.signal, e.payload, e.options);
|
|
664
957
|
}
|
|
665
958
|
});
|
|
666
|
-
|
|
959
|
+
if (typeof sql.notify === "function") {
|
|
960
|
+
await sql.notify(CHANNEL, "commit");
|
|
961
|
+
}
|
|
667
962
|
},
|
|
668
963
|
async rollback() {
|
|
669
964
|
if (finished) throw new Error("transaction finished");
|
|
@@ -827,107 +1122,88 @@ export async function openPostgresSignal(options: SignalOpenOptions): Promise<Si
|
|
|
827
1122
|
for (const consumer of consumers) {
|
|
828
1123
|
const decl = signals.get(consumer.signal);
|
|
829
1124
|
if (decl?.delivery !== "once") continue;
|
|
830
|
-
const
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
const attempts = Number(
|
|
1125
|
+
const row = await sql.begin(async (tx) => {
|
|
1126
|
+
const claimed = await tx.query(CLAIM_ONCE_SQL, [consumer.signal, t, t, t]);
|
|
1127
|
+
const hit = claimed[0];
|
|
1128
|
+
if (!hit) return undefined;
|
|
1129
|
+
const attempts = Number(hit.attempts) + 1;
|
|
835
1130
|
const leaseExpiresAt = t + leaseMs;
|
|
836
|
-
await
|
|
1131
|
+
await tx.exec(
|
|
837
1132
|
`UPDATE oke_signal_messages SET status = 'inflight', locked_by = ?, attempts = ?, lease_expires_at = ? WHERE id = ?`,
|
|
838
|
-
[consumer.subscriberId, attempts, leaseExpiresAt,
|
|
1133
|
+
[consumer.subscriberId, attempts, leaseExpiresAt, hit.id],
|
|
839
1134
|
);
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
1135
|
+
hit.locked_by = consumer.subscriberId;
|
|
1136
|
+
hit.attempts = attempts;
|
|
1137
|
+
hit.status = "inflight";
|
|
1138
|
+
hit.lease_expires_at = leaseExpiresAt;
|
|
1139
|
+
return hit;
|
|
1140
|
+
});
|
|
1141
|
+
if (row) {
|
|
1142
|
+
progress = true;
|
|
844
1143
|
await deliverOnce(row, consumer);
|
|
845
1144
|
}
|
|
846
1145
|
}
|
|
847
1146
|
return progress;
|
|
848
1147
|
}
|
|
849
1148
|
|
|
850
|
-
async function
|
|
1149
|
+
async function drainFanoutPass(): Promise<boolean> {
|
|
851
1150
|
let progress = false;
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
const
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
consumer.signal,
|
|
864
|
-
]);
|
|
865
|
-
const current = fresh.find((r) => r.id === row.id);
|
|
866
|
-
if (!current || current.status === "delivered") continue;
|
|
867
|
-
const deliveredTo = new Set(JSON.parse(String(current.delivered_to ?? "[]")) as string[]);
|
|
868
|
-
if (deliveredTo.has(consumer.subscriberId)) continue;
|
|
869
|
-
// Claim this subscriber slot before invoking the handler.
|
|
870
|
-
deliveredTo.add(consumer.subscriberId);
|
|
871
|
-
const subs = consumers.filter((c) => c.signal === consumer.signal);
|
|
872
|
-
const status = subs.every((s) => deliveredTo.has(s.subscriberId)) ? "delivered" : "pending";
|
|
873
|
-
await sql.exec(
|
|
874
|
-
`UPDATE oke_signal_messages SET delivered_to = ?, status = ?, attempts = ? WHERE id = ?`,
|
|
875
|
-
[JSON.stringify([...deliveredTo]), status, Number(current.attempts) + 1, row.id],
|
|
876
|
-
);
|
|
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);
|
|
877
1162
|
progress = true;
|
|
878
|
-
const msg = rowToMessage(
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
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
|
+
}
|
|
890
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);
|
|
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();
|
|
891
1197
|
}
|
|
892
1198
|
}
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
async function drainLivePass(): Promise<boolean> {
|
|
897
|
-
let progress = false;
|
|
898
|
-
const pending = await sql.query(
|
|
899
|
-
`SELECT * FROM oke_signal_messages WHERE status IN ('pending', 'inflight')`,
|
|
900
|
-
);
|
|
901
|
-
for (const row of pending) {
|
|
902
|
-
if (row.delivery !== "live") continue;
|
|
903
|
-
progress = true;
|
|
904
|
-
const handlers = liveHandlers.get(String(row.signal));
|
|
905
|
-
const payload = JSON.parse(String(row.payload));
|
|
906
|
-
if (handlers) {
|
|
907
|
-
const event: LiveEvent = { id: String(row.id), payload };
|
|
908
|
-
for (const h of handlers) await h(event);
|
|
909
|
-
}
|
|
910
|
-
const sig = String(row.signal);
|
|
911
|
-
let list = recentLive.get(sig);
|
|
912
|
-
if (!list) {
|
|
913
|
-
list = [];
|
|
914
|
-
recentLive.set(sig, list);
|
|
915
|
-
}
|
|
916
|
-
list.push(payload);
|
|
917
|
-
while (list.length > 50) list.shift();
|
|
918
|
-
noteDelivered();
|
|
919
|
-
await sql.exec(
|
|
920
|
-
`UPDATE oke_signal_messages SET status = 'delivered', locked_by = NULL, lease_expires_at = NULL WHERE id = ?`,
|
|
921
|
-
[row.id],
|
|
922
|
-
);
|
|
1199
|
+
const pruneBefore = snap.dbNow - lagMs;
|
|
1200
|
+
for (const [id, seenAt] of fanoutSeen) {
|
|
1201
|
+
if (seenAt < pruneBefore) fanoutSeen.delete(id);
|
|
923
1202
|
}
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
if (pruned.has(sig)) continue;
|
|
929
|
-
pruned.add(sig);
|
|
930
|
-
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);
|
|
931
1207
|
}
|
|
932
1208
|
return progress;
|
|
933
1209
|
}
|
|
@@ -940,9 +1216,8 @@ export async function openPostgresSignal(options: SignalOpenOptions): Promise<Si
|
|
|
940
1216
|
draining = (async () => {
|
|
941
1217
|
for (let i = 0; i < 1000; i++) {
|
|
942
1218
|
const a = await drainOncePass();
|
|
943
|
-
const b = await
|
|
944
|
-
|
|
945
|
-
if (!a && !b && !c) break;
|
|
1219
|
+
const b = await drainFanoutPass();
|
|
1220
|
+
if (!a && !b) break;
|
|
946
1221
|
}
|
|
947
1222
|
})();
|
|
948
1223
|
try {
|
|
@@ -992,22 +1267,20 @@ export async function openPostgresSignal(options: SignalOpenOptions): Promise<Si
|
|
|
992
1267
|
}
|
|
993
1268
|
}
|
|
994
1269
|
const subs = consumers.filter((c) => c.signal === name);
|
|
995
|
-
const subscribers =
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
}),
|
|
1010
|
-
);
|
|
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
|
+
});
|
|
1011
1284
|
return {
|
|
1012
1285
|
signal: name,
|
|
1013
1286
|
delivery: decl.delivery,
|