@happyvertical/smrt-core 0.44.0 → 0.45.0
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/AGENTS.md +8 -8
- package/agents/change-feed.md +3 -2
- package/agents/generators.md +34 -0
- package/agents/revision-guard.md +66 -0
- package/dist/browser.js +2 -1
- package/dist/cascade.d.ts +5 -9
- package/dist/cascade.d.ts.map +1 -1
- package/dist/cascade.js +65 -30
- package/dist/cascade.js.map +1 -1
- package/dist/change-feed.d.ts +60 -1
- package/dist/change-feed.d.ts.map +1 -1
- package/dist/change-feed.js +464 -27
- package/dist/change-feed.js.map +1 -1
- package/dist/change-signals.d.ts.map +1 -1
- package/dist/change-signals.js +13 -11
- package/dist/change-signals.js.map +1 -1
- package/dist/embedded-write-queue.d.ts +8 -0
- package/dist/embedded-write-queue.d.ts.map +1 -1
- package/dist/embedded-write-queue.js +12 -2
- package/dist/embedded-write-queue.js.map +1 -1
- package/dist/generators/cli.d.ts.map +1 -1
- package/dist/generators/cli.js +10 -18
- package/dist/generators/cli.js.map +1 -1
- package/dist/generators/custom-action.d.ts +216 -0
- package/dist/generators/custom-action.d.ts.map +1 -1
- package/dist/generators/custom-action.js +256 -1
- package/dist/generators/custom-action.js.map +1 -1
- package/dist/generators/index.d.ts +2 -1
- package/dist/generators/index.d.ts.map +1 -1
- package/dist/generators/index.js +3 -2
- package/dist/generators/mcp.d.ts.map +1 -1
- package/dist/generators/mcp.js +14 -39
- package/dist/generators/mcp.js.map +1 -1
- package/dist/generators/preflight-route.d.ts +151 -0
- package/dist/generators/preflight-route.d.ts.map +1 -0
- package/dist/generators/preflight-route.js +194 -0
- package/dist/generators/preflight-route.js.map +1 -0
- package/dist/generators/rest.d.ts +12 -0
- package/dist/generators/rest.d.ts.map +1 -1
- package/dist/generators/rest.js +14 -15
- package/dist/generators/rest.js.map +1 -1
- package/dist/generators/tool-schema.d.ts.map +1 -1
- package/dist/generators/tool-schema.js +2 -8
- package/dist/generators/tool-schema.js.map +1 -1
- package/dist/generators.js +3 -2
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -4
- package/dist/knowledge.d.ts.map +1 -1
- package/dist/knowledge.js +283 -35
- package/dist/knowledge.js.map +1 -1
- package/dist/manifest/static-manifest.d.ts.map +1 -1
- package/dist/manifest/static-manifest.js +6 -2
- package/dist/manifest/static-manifest.js.map +1 -1
- package/dist/manifest/store.js +1 -1
- package/dist/manifest/store.js.map +1 -1
- package/dist/manifest.json +8 -2
- package/dist/object.d.ts +31 -1
- package/dist/object.d.ts.map +1 -1
- package/dist/object.js +57 -9
- package/dist/object.js.map +1 -1
- package/dist/registry/framework-base-classes.d.ts +10 -0
- package/dist/registry/framework-base-classes.d.ts.map +1 -0
- package/dist/registry/framework-base-classes.js +92 -0
- package/dist/registry/framework-base-classes.js.map +1 -0
- package/dist/registry/schema-builder.d.ts.map +1 -1
- package/dist/registry/schema-builder.js +2 -0
- package/dist/registry/schema-builder.js.map +1 -1
- package/dist/registry/types.d.ts +27 -6
- package/dist/registry/types.d.ts.map +1 -1
- package/dist/registry.d.ts +1 -0
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +2 -1
- package/dist/registry.js.map +1 -1
- package/dist/revision-guard.d.ts +84 -0
- package/dist/revision-guard.d.ts.map +1 -0
- package/dist/revision-guard.js +120 -0
- package/dist/revision-guard.js.map +1 -0
- package/dist/scanner/manifest-generator.d.ts +46 -16
- package/dist/scanner/manifest-generator.d.ts.map +1 -1
- package/dist/scanner/manifest-generator.js +198 -45
- package/dist/scanner/manifest-generator.js.map +1 -1
- package/dist/smrt-knowledge.json +18 -9
- package/dist/system/bootstrap.d.ts.map +1 -1
- package/dist/system/bootstrap.js +2 -1
- package/dist/system/bootstrap.js.map +1 -1
- package/dist/system/schema.d.ts +75 -2
- package/dist/system/schema.d.ts.map +1 -1
- package/dist/system/schema.js +326 -4
- package/dist/system/schema.js.map +1 -1
- package/dist/vite-plugin/index.d.ts.map +1 -1
- package/dist/vite-plugin/index.js +48 -23
- package/dist/vite-plugin/index.js.map +1 -1
- package/dist/vite-plugin/sveltekit-generator.d.ts +33 -7
- package/dist/vite-plugin/sveltekit-generator.d.ts.map +1 -1
- package/dist/vite-plugin/sveltekit-generator.js +88 -17
- package/dist/vite-plugin/sveltekit-generator.js.map +1 -1
- package/dist/vite-plugin/sync-apply-route.d.ts.map +1 -1
- package/dist/vite-plugin/sync-apply-route.js +2 -0
- package/dist/vite-plugin/sync-apply-route.js.map +1 -1
- package/dist/vite-plugin/web-collections.d.ts.map +1 -1
- package/dist/vite-plugin/web-collections.js +28 -5
- package/dist/vite-plugin/web-collections.js.map +1 -1
- package/package.json +4 -4
package/dist/change-feed.js
CHANGED
|
@@ -5,7 +5,7 @@ import { resolveDispatchTenantScope } from "./dispatch/tenant-resolver.js";
|
|
|
5
5
|
import { isEmbeddedDatabase, withEmbeddedWriteQueue } from "./embedded-write-queue.js";
|
|
6
6
|
import { GlobalInterceptors } from "./interceptors.js";
|
|
7
7
|
import { detectEngine } from "./schema/ddl/index.js";
|
|
8
|
-
import { CREATE_SMRT_CHANGES_TABLE, ENSURE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION, ENSURE_POSTGRES_CHANGE_FEED_SCHEMA, FRAMEWORK_OPERATIONAL_TABLES, POSTGRES_CHANGE_FEED_APPEND_FUNCTION_IDENTITY, POSTGRES_CHANGE_FEED_APPEND_FUNCTION_NAME, REPLACE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION, RETIRED_SYSTEM_TABLES } from "./system/schema.js";
|
|
8
|
+
import { CREATE_SMRT_CHANGES_TABLE, ENSURE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION, ENSURE_POSTGRES_CHANGE_FEED_SCHEMA, FRAMEWORK_OPERATIONAL_TABLES, POSTGRES_CHANGE_FEED_APPEND_FUNCTION_IDENTITY, POSTGRES_CHANGE_FEED_APPEND_FUNCTION_NAME, POSTGRES_CHANGE_FEED_DRAIN_FUNCTION_IDENTITY, POSTGRES_CHANGE_FEED_DRAIN_FUNCTION_NAME, POSTGRES_CHANGE_FEED_HELPER_MARKER, POSTGRES_CHANGE_FEED_PENDING_TABLE, REPLACE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION, RETIRED_SYSTEM_TABLES } from "./system/schema.js";
|
|
9
9
|
import { SYSTEM_TABLE_NAMES } from "./schema/system-table-shapes.js";
|
|
10
10
|
import { createLogger } from "@happyvertical/logger";
|
|
11
11
|
//#region src/change-feed.ts
|
|
@@ -49,6 +49,46 @@ import { createLogger } from "@happyvertical/logger";
|
|
|
49
49
|
* auto-increment mechanism exists in the system-table schema path either;
|
|
50
50
|
* see `system/schema.ts`.)
|
|
51
51
|
*
|
|
52
|
+
* ## Staged appends inside caller transactions (PostgreSQL, #2649)
|
|
53
|
+
*
|
|
54
|
+
* `MAX+1` allocation costs a *wait*: two appends that pick the same value
|
|
55
|
+
* conflict on the primary key, and the loser waits for the winner's
|
|
56
|
+
* transaction to end. Under autocommit that is one statement. Inside a
|
|
57
|
+
* caller-managed transaction it is the whole transaction — and a long write
|
|
58
|
+
* transaction that appends and then keeps taking row locks forms a genuine
|
|
59
|
+
* lock cycle with any writer that took those row locks first and then
|
|
60
|
+
* appended. PostgreSQL detects it (`40P01`) and aborts one side, so an
|
|
61
|
+
* ordinary concurrent request could abort a legitimate long write.
|
|
62
|
+
*
|
|
63
|
+
* PostgreSQL appends issued inside a caller transaction are therefore
|
|
64
|
+
* **staged**: `_smrt_append_change` inserts into `_smrt_changes_pending`,
|
|
65
|
+
* whose identity key conflicts with nothing, so the append never waits on
|
|
66
|
+
* another transaction and the cycle cannot form. The staged row is still
|
|
67
|
+
* fate-shared with the caller (a rollback removes it). {@link drainChangeFeed}
|
|
68
|
+
* — run by {@link getChangesSince} before it reads, and (throttled) by the
|
|
69
|
+
* append path itself — moves *committed* staged rows into `_smrt_changes`
|
|
70
|
+
* under a try-only advisory lock, numbering them `MAX(seq) + row_number()` in
|
|
71
|
+
* staged order. Draining is driven from JavaScript rather than inside the
|
|
72
|
+
* append helper so that every sequenced entry also gets its live `_events`
|
|
73
|
+
* signal: an entry sequenced invisibly server-side would be skipped for good
|
|
74
|
+
* by a subscriber whose `Last-Event-ID` came from a later, higher sequence.
|
|
75
|
+
* Signals for drained entries are published only once the drain is proven
|
|
76
|
+
* committed, so a rollback can never advertise a sequence another appender
|
|
77
|
+
* then reuses.
|
|
78
|
+
*
|
|
79
|
+
* The cursor guarantee is unchanged, because sequences are still allocated by
|
|
80
|
+
* exactly one `MAX+1` writer at a time and only ever for already-committed
|
|
81
|
+
* work: committed sequences stay contiguous, and no entry can appear at or
|
|
82
|
+
* below a horizon a reader already observed. What a staged append gives up is
|
|
83
|
+
* *promptness*, not durability or order: its entry becomes visible one drain
|
|
84
|
+
* after its transaction commits, and its position in the log is its drain
|
|
85
|
+
* order rather than its statement order. An autocommit append drains before
|
|
86
|
+
* allocating whenever its throttle allows, so staged work usually keeps its
|
|
87
|
+
* place ahead of later writes.
|
|
88
|
+
*
|
|
89
|
+
* SQLite and DuckDB keep the direct `MAX+1` insert unchanged — SQLite
|
|
90
|
+
* serializes writers outright, so the defect is unreachable there.
|
|
91
|
+
*
|
|
52
92
|
* Contention note: appends serialize on the head of the log. Each append is
|
|
53
93
|
* one small INSERT (issued from the write path *after* the user's row was
|
|
54
94
|
* written), so the serialization window is one statement; conflicts resolve
|
|
@@ -68,7 +108,7 @@ import { createLogger } from "@happyvertical/logger";
|
|
|
68
108
|
* JavaScript only throws/logs after PostgreSQL has restored the caller's
|
|
69
109
|
* transaction, so a swallowed append failure cannot surface later as 25P02.
|
|
70
110
|
* The append still joins a caller-managed transaction on the same handle and
|
|
71
|
-
* shares its fate (a rollback removes the
|
|
111
|
+
* shares its fate (a rollback removes the staged row with the data row).
|
|
72
112
|
*
|
|
73
113
|
* ## Known gaps (documented in the PRD)
|
|
74
114
|
*
|
|
@@ -120,6 +160,7 @@ var CHANGE_FEED_TABLE = "_smrt_changes";
|
|
|
120
160
|
*/
|
|
121
161
|
var CHANGE_FEED_EXCLUDED_TABLES = /* @__PURE__ */ new Set([
|
|
122
162
|
...SYSTEM_TABLE_NAMES,
|
|
163
|
+
POSTGRES_CHANGE_FEED_PENDING_TABLE,
|
|
123
164
|
...FRAMEWORK_OPERATIONAL_TABLES,
|
|
124
165
|
...RETIRED_SYSTEM_TABLES
|
|
125
166
|
]);
|
|
@@ -144,6 +185,83 @@ var MAX_CHANGES_LIMIT = 5e3;
|
|
|
144
185
|
* blocking transaction commits, so a small bound is ample.
|
|
145
186
|
*/
|
|
146
187
|
var MAX_APPEND_ATTEMPTS = 20;
|
|
188
|
+
/**
|
|
189
|
+
* Maximum bounded drain batches one {@link drainChangeFeed} call sequences.
|
|
190
|
+
* A cap rather than "until empty" so a pathological writer cannot make one
|
|
191
|
+
* reader drain forever; the remainder is picked up by the next drain.
|
|
192
|
+
*/
|
|
193
|
+
var MAX_DRAIN_PASSES = 50;
|
|
194
|
+
/** Cap on signals held for a handle that keeps draining without committing. */
|
|
195
|
+
var MAX_DEFERRED_SIGNALS = 5e3;
|
|
196
|
+
/**
|
|
197
|
+
* Per-handle low-water mark of sequences a drain allocated but has not proven
|
|
198
|
+
* committed (#2649).
|
|
199
|
+
*
|
|
200
|
+
* The hold-back has to outlive the drain call that created it. A caller
|
|
201
|
+
* transaction can drain in one statement and read in the next — the second
|
|
202
|
+
* read's own drain allocates nothing (the server-side helper refuses once the
|
|
203
|
+
* transaction has an id), yet the transaction still sees the first drain's
|
|
204
|
+
* uncommitted rows and would serve them. Keyed on the handle object, which is
|
|
205
|
+
* the transaction's identity for its whole life, so every later read on the
|
|
206
|
+
* same transaction keeps holding back from the same mark.
|
|
207
|
+
*
|
|
208
|
+
* The mark is recorded unconditionally and *used* only while a transaction id
|
|
209
|
+
* is still assigned at read time. Under autocommit the drain committed as its
|
|
210
|
+
* own transaction, so the next read clears the mark and serves the rows.
|
|
211
|
+
*/
|
|
212
|
+
var uncommittedDrainMarks = /* @__PURE__ */ new WeakMap();
|
|
213
|
+
/**
|
|
214
|
+
* How often the append path drains on its own behalf, per database.
|
|
215
|
+
*
|
|
216
|
+
* The append helper deliberately does not drain server-side: sequences it
|
|
217
|
+
* assigned there would never reach JavaScript, so no `_events` signal would be
|
|
218
|
+
* published for them while this append's own signal carries a higher sequence
|
|
219
|
+
* — and an EventSource resuming from that `Last-Event-ID` would skip them for
|
|
220
|
+
* good. Draining from here instead keeps every sequenced entry signalled, in
|
|
221
|
+
* ascending order, and keeps a deployment whose writers never read the feed
|
|
222
|
+
* from accumulating staged entries indefinitely. Throttled because it is a
|
|
223
|
+
* liveness convenience, not a correctness requirement: cursor readers drain
|
|
224
|
+
* for themselves, and `getTableVersion()` already counts staged entries.
|
|
225
|
+
*/
|
|
226
|
+
var APPEND_DRAIN_INTERVAL_MS = 250;
|
|
227
|
+
/** dbKey → last time the append path drained for it. */
|
|
228
|
+
var lastAppendDrain = /* @__PURE__ */ new Map();
|
|
229
|
+
/** dbKeys that staged an append since this process last drained them. */
|
|
230
|
+
var stagedSinceLastDrain = /* @__PURE__ */ new Set();
|
|
231
|
+
function noteStagedAppend(db) {
|
|
232
|
+
try {
|
|
233
|
+
stagedSinceLastDrain.add(resolveDbCacheKey(db));
|
|
234
|
+
} catch {}
|
|
235
|
+
}
|
|
236
|
+
async function drainBeforeAppend(db) {
|
|
237
|
+
if (getEngine(db) !== "postgres") return [];
|
|
238
|
+
let dbKey;
|
|
239
|
+
try {
|
|
240
|
+
dbKey = resolveDbCacheKey(db);
|
|
241
|
+
} catch {
|
|
242
|
+
return [];
|
|
243
|
+
}
|
|
244
|
+
const now = Date.now();
|
|
245
|
+
const last = lastAppendDrain.get(dbKey);
|
|
246
|
+
if (!stagedSinceLastDrain.has(dbKey) && last !== void 0 && now - last < APPEND_DRAIN_INTERVAL_MS) return [];
|
|
247
|
+
stagedSinceLastDrain.delete(dbKey);
|
|
248
|
+
lastAppendDrain.set(dbKey, now);
|
|
249
|
+
try {
|
|
250
|
+
return (await drainChangeFeedDetailed(db, {
|
|
251
|
+
settle: false,
|
|
252
|
+
maxPasses: 1
|
|
253
|
+
})).unsettledSignals;
|
|
254
|
+
} catch (error) {
|
|
255
|
+
warnDrainFailureOnce(db, error);
|
|
256
|
+
return [];
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
function recordUncommittedDrainMark(db, firstAllocatedSeq) {
|
|
260
|
+
if (firstAllocatedSeq === null) return;
|
|
261
|
+
const key = db;
|
|
262
|
+
const existing = uncommittedDrainMarks.get(key);
|
|
263
|
+
if (existing === void 0 || firstAllocatedSeq < existing) uncommittedDrainMarks.set(key, firstAllocatedSeq);
|
|
264
|
+
}
|
|
147
265
|
var VALID_OPERATIONS = /* @__PURE__ */ new Set([
|
|
148
266
|
"create",
|
|
149
267
|
"update",
|
|
@@ -199,14 +317,37 @@ function isUniqueViolation(error) {
|
|
|
199
317
|
* framework already have the table via the system-table bootstrap.
|
|
200
318
|
*/
|
|
201
319
|
var ensuredHandles = /* @__PURE__ */ new WeakSet();
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
320
|
+
/**
|
|
321
|
+
* SQL fragment that reports a helper as present ONLY when its body is current.
|
|
322
|
+
*
|
|
323
|
+
* `to_regprocedure()` alone answers "a function of this name and signature
|
|
324
|
+
* exists", which is not the same question: an install can hold a helper whose
|
|
325
|
+
* body predates a fix, and an existence probe would call it current and never
|
|
326
|
+
* replace it. Matching {@link POSTGRES_CHANGE_FEED_HELPER_MARKER} against
|
|
327
|
+
* `pg_proc.prosrc` makes the probe version-aware, so bumping the marker is all
|
|
328
|
+
* a future helper change needs to reach existing databases.
|
|
329
|
+
*/
|
|
330
|
+
function currentHelperProbe(identity) {
|
|
331
|
+
return `(
|
|
332
|
+
SELECT p.oid
|
|
333
|
+
FROM pg_proc AS p
|
|
334
|
+
WHERE p.oid = to_regprocedure('${identity}')
|
|
335
|
+
AND p.prosrc LIKE '%${POSTGRES_CHANGE_FEED_HELPER_MARKER}%'
|
|
336
|
+
)`;
|
|
337
|
+
}
|
|
338
|
+
async function postgresChangeFeedHelpersCurrent(db) {
|
|
339
|
+
const rows = getQueryRows(await db.query(`SELECT
|
|
340
|
+
${currentHelperProbe(POSTGRES_CHANGE_FEED_APPEND_FUNCTION_IDENTITY)} AS function_name,
|
|
341
|
+
${currentHelperProbe(POSTGRES_CHANGE_FEED_DRAIN_FUNCTION_IDENTITY)} AS drain_function_name,
|
|
342
|
+
to_regclass('${POSTGRES_CHANGE_FEED_PENDING_TABLE}') AS pending_table_name`));
|
|
343
|
+
return Boolean(rows[0]?.function_name && rows[0]?.drain_function_name && rows[0]?.pending_table_name);
|
|
205
344
|
}
|
|
206
345
|
async function getPostgresChangeFeedSchemaState(db) {
|
|
207
346
|
const rows = getQueryRows(await db.query(`SELECT
|
|
208
347
|
to_regclass('${CHANGE_FEED_TABLE}') AS table_name,
|
|
209
|
-
|
|
348
|
+
${currentHelperProbe(POSTGRES_CHANGE_FEED_APPEND_FUNCTION_IDENTITY)} AS function_name,
|
|
349
|
+
to_regclass('${POSTGRES_CHANGE_FEED_PENDING_TABLE}') AS pending_table_name,
|
|
350
|
+
${currentHelperProbe(POSTGRES_CHANGE_FEED_DRAIN_FUNCTION_IDENTITY)} AS drain_function_name,
|
|
210
351
|
(
|
|
211
352
|
SELECT data_type
|
|
212
353
|
FROM information_schema.columns
|
|
@@ -217,6 +358,8 @@ async function getPostgresChangeFeedSchemaState(db) {
|
|
|
217
358
|
return {
|
|
218
359
|
tableExists: Boolean(rows[0]?.table_name),
|
|
219
360
|
functionExists: Boolean(rows[0]?.function_name),
|
|
361
|
+
pendingTableExists: Boolean(rows[0]?.pending_table_name),
|
|
362
|
+
drainFunctionExists: Boolean(rows[0]?.drain_function_name),
|
|
220
363
|
createdAtType: rows[0]?.created_at_type ? String(rows[0].created_at_type) : null
|
|
221
364
|
};
|
|
222
365
|
}
|
|
@@ -239,18 +382,45 @@ async function ensurePostgresChangeFeedAppendFunction(db, options = {}) {
|
|
|
239
382
|
if (getEngine(db, options.typeHint) !== "postgres") return;
|
|
240
383
|
assertPostgresChangeFeedTimestampCurrent(await getPostgresChangeFeedSchemaState(db));
|
|
241
384
|
if (options.replaceExisting === false) {
|
|
242
|
-
if (await
|
|
385
|
+
if (await postgresChangeFeedHelpersCurrent(db)) return;
|
|
243
386
|
await db.query(ENSURE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION);
|
|
244
387
|
return;
|
|
245
388
|
}
|
|
246
389
|
await db.query(REPLACE_POSTGRES_CHANGE_FEED_APPEND_FUNCTION);
|
|
247
390
|
}
|
|
391
|
+
/**
|
|
392
|
+
* Refresh the PostgreSQL change-feed helpers on an already-initialized
|
|
393
|
+
* database (issue #2649).
|
|
394
|
+
*
|
|
395
|
+
* `bootstrapSystemTables()` installs the helpers only while applying a system
|
|
396
|
+
* schema version, and #2649 changes the helper body without changing the
|
|
397
|
+
* portable system DDL — so a database already stamped with the current version
|
|
398
|
+
* would keep the pre-#2649 append function, and its `40P01` deadlock, until
|
|
399
|
+
* some feed route happened to call {@link ensureChangeFeedTable}. Ordinary
|
|
400
|
+
* model writes never do. Bootstrap therefore calls this before its
|
|
401
|
+
* version fast-return; it is one catalog probe when the helpers are current.
|
|
402
|
+
*
|
|
403
|
+
* Databases whose `_smrt_changes.created_at` is still the legacy
|
|
404
|
+
* timezone-naive column are left alone: they need their audited
|
|
405
|
+
* `migratePostgresSystemTimestamps()` pass first, and failing bootstrap on
|
|
406
|
+
* them here would be a new, unrelated break.
|
|
407
|
+
*
|
|
408
|
+
* @internal
|
|
409
|
+
*/
|
|
410
|
+
async function ensurePostgresChangeFeedHelpers(db, typeHint) {
|
|
411
|
+
if (getEngine(db, typeHint) !== "postgres") return;
|
|
412
|
+
const state = await getPostgresChangeFeedSchemaState(db);
|
|
413
|
+
if (!state.tableExists) return;
|
|
414
|
+
if (state.createdAtType !== "timestamp with time zone") return;
|
|
415
|
+
if (state.functionExists && state.pendingTableExists && state.drainFunctionExists) return;
|
|
416
|
+
await db.query(ENSURE_POSTGRES_CHANGE_FEED_SCHEMA);
|
|
417
|
+
}
|
|
248
418
|
async function ensureChangeFeedTable(db) {
|
|
249
419
|
if (ensuredHandles.has(db)) return;
|
|
250
420
|
if (getEngine(db) === "postgres") {
|
|
251
421
|
const state = await getPostgresChangeFeedSchemaState(db);
|
|
252
422
|
assertPostgresChangeFeedTimestampCurrent(state);
|
|
253
|
-
if (state.tableExists && state.functionExists && state.createdAtType === "timestamp with time zone") {
|
|
423
|
+
if (state.tableExists && state.functionExists && state.pendingTableExists && state.drainFunctionExists && state.createdAtType === "timestamp with time zone") {
|
|
254
424
|
ensuredHandles.add(db);
|
|
255
425
|
return;
|
|
256
426
|
}
|
|
@@ -273,6 +443,14 @@ async function ensureChangeFeedTable(db) {
|
|
|
273
443
|
* conflicts or on any non-conflict database error; the framework's
|
|
274
444
|
* interceptor catches and logs instead of failing the user's write.
|
|
275
445
|
*
|
|
446
|
+
* **Returns `null` for a staged append** (PostgreSQL, #2649): an append issued
|
|
447
|
+
* inside a caller-managed transaction is written to `_smrt_changes_pending`
|
|
448
|
+
* and receives its sequence from the next {@link drainChangeFeed} after that
|
|
449
|
+
* transaction commits, so no sequence exists to return yet. The entry is
|
|
450
|
+
* durable and ordered — it simply is not numbered at this instant. Callers
|
|
451
|
+
* that need the sequence (an SSE event id, say) must treat `null` as "not
|
|
452
|
+
* available yet", never as a failure: a failure still throws.
|
|
453
|
+
*
|
|
276
454
|
* **PostgreSQL transaction safety (#2026).** The INSERT runs inside the
|
|
277
455
|
* framework-owned `_smrt_append_change` PL/pgSQL function. Its exception
|
|
278
456
|
* handler is a PostgreSQL subtransaction: a failed attempt is rolled back
|
|
@@ -297,6 +475,27 @@ async function appendChange(db, input) {
|
|
|
297
475
|
input.tenantId ?? null,
|
|
298
476
|
(/* @__PURE__ */ new Date()).toISOString()
|
|
299
477
|
];
|
|
478
|
+
const drainedSignals = await drainBeforeAppend(db);
|
|
479
|
+
/**
|
|
480
|
+
* Settle the pre-append drain's signals from what this append revealed.
|
|
481
|
+
*
|
|
482
|
+
* A drain that allocated anything assigned a transaction id, so if this
|
|
483
|
+
* append then took the DIRECT path — `pg_current_xact_id_if_assigned()` was
|
|
484
|
+
* still null at its entry — that id is already gone, which can only mean the
|
|
485
|
+
* drain committed as its own autocommit transaction. Publishing here is
|
|
486
|
+
* therefore proven safe AND correctly ordered: these sequences are below the
|
|
487
|
+
* one this append is about to take, and the interceptor publishes that one
|
|
488
|
+
* only after this call returns. A deferred append proves nothing, so its
|
|
489
|
+
* signals go to the queue.
|
|
490
|
+
*/
|
|
491
|
+
const settleDrainedSignals = (appended) => {
|
|
492
|
+
if (drainedSignals.length === 0) return;
|
|
493
|
+
if (appended === null) {
|
|
494
|
+
queueDeferredSignals(db, drainedSignals);
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
publishSignals(db, drainedSignals);
|
|
498
|
+
};
|
|
300
499
|
for (let attempt = 1; attempt <= MAX_APPEND_ATTEMPTS; attempt++) try {
|
|
301
500
|
const row = getQueryRows(await withEmbeddedWriteQueue(db, isEmbeddedDatabase(db), () => db.query(sql, ...params)))[0];
|
|
302
501
|
if (!row) throw new Error("Change feed append returned no result row");
|
|
@@ -305,13 +504,223 @@ async function appendChange(db, input) {
|
|
|
305
504
|
error.code = String(row.error_code);
|
|
306
505
|
throw error;
|
|
307
506
|
}
|
|
308
|
-
|
|
507
|
+
if (engine === "postgres" && row.allocated_seq == null) {
|
|
508
|
+
noteStagedAppend(db);
|
|
509
|
+
settleDrainedSignals(null);
|
|
510
|
+
return null;
|
|
511
|
+
}
|
|
512
|
+
const seq = toSeqNumber(engine === "postgres" ? row.allocated_seq : row.seq);
|
|
513
|
+
settleDrainedSignals(seq);
|
|
514
|
+
if (engine === "postgres") recordUncommittedDrainMark(db, seq);
|
|
515
|
+
return seq;
|
|
309
516
|
} catch (error) {
|
|
310
|
-
if (!isUniqueViolation(error) || attempt === MAX_APPEND_ATTEMPTS)
|
|
517
|
+
if (!isUniqueViolation(error) || attempt === MAX_APPEND_ATTEMPTS) {
|
|
518
|
+
queueDeferredSignals(db, drainedSignals);
|
|
519
|
+
throw error;
|
|
520
|
+
}
|
|
311
521
|
}
|
|
312
522
|
throw new Error("appendChange exhausted retries without allocating a seq");
|
|
313
523
|
}
|
|
314
524
|
/**
|
|
525
|
+
* Sequence change-feed entries staged inside caller-managed transactions
|
|
526
|
+
* (issue #2649). PostgreSQL only; a no-op on every other engine.
|
|
527
|
+
*
|
|
528
|
+
* A `save()`/`delete()`/{@link appendChange} issued inside a caller
|
|
529
|
+
* transaction cannot allocate `MAX(seq) + 1` there: the allocation waits for
|
|
530
|
+
* any competing appender's transaction to end, and a long write transaction
|
|
531
|
+
* that goes on to take row locks the waiter holds closes a real lock cycle
|
|
532
|
+
* (`40P01`). Such appends are staged instead, and this call moves every
|
|
533
|
+
* **committed** staged entry into `_smrt_changes` with contiguous sequences,
|
|
534
|
+
* in staged order, under a try-only advisory lock.
|
|
535
|
+
*
|
|
536
|
+
* It runs by itself on the paths that matter — every autocommit append drains
|
|
537
|
+
* server-side before allocating its own sequence, and {@link getChangesSince}
|
|
538
|
+
* drains before it reads — so applications do not normally need to call it. A
|
|
539
|
+
* drain issued from inside a transaction that has already written is skipped
|
|
540
|
+
* server-side: allocating there would hold sequences uncommitted for the rest
|
|
541
|
+
* of that transaction, which is the wait this fix removes.
|
|
542
|
+
* Call it explicitly from a scheduled job when a deployment writes *only*
|
|
543
|
+
* through transactions and reads the feed from a connection that cannot write
|
|
544
|
+
* (a read replica or a read-only role), because neither self-draining path is
|
|
545
|
+
* then available.
|
|
546
|
+
*
|
|
547
|
+
* Best-effort by the feed's failure policy: a drain that cannot run leaves the
|
|
548
|
+
* staged entries in place for the next attempt and never throws into the
|
|
549
|
+
* caller's write path. Nothing is lost — staged entries are durable — they are
|
|
550
|
+
* simply not yet visible to cursor readers.
|
|
551
|
+
*
|
|
552
|
+
* @returns How many staged entries were sequenced.
|
|
553
|
+
*/
|
|
554
|
+
async function drainChangeFeed(db) {
|
|
555
|
+
return (await drainChangeFeedDetailed(db)).drained;
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* {@link drainChangeFeed} plus the lowest sequence this call allocated.
|
|
559
|
+
*
|
|
560
|
+
* A drain runs on the caller's handle, so when that handle is inside a
|
|
561
|
+
* caller-managed transaction the rows it just wrote are visible **to this
|
|
562
|
+
* transaction only** and disappear if it rolls back. A reader that served them
|
|
563
|
+
* would report entries that never committed and advance its cursor past
|
|
564
|
+
* sequences a concurrent autocommit appender goes on to claim — a permanently
|
|
565
|
+
* skipped change. {@link getChangesSince} therefore refuses to serve at or
|
|
566
|
+
* above `firstAllocatedSeq`: everything below it was committed by somebody
|
|
567
|
+
* else before this drain started, and the drain's own rows are served by the
|
|
568
|
+
* next poll, once they are committed for everyone.
|
|
569
|
+
*
|
|
570
|
+
* @internal
|
|
571
|
+
*/
|
|
572
|
+
async function drainChangeFeedDetailed(db, options = {}) {
|
|
573
|
+
if (getEngine(db) !== "postgres") return {
|
|
574
|
+
drained: 0,
|
|
575
|
+
firstAllocatedSeq: null,
|
|
576
|
+
unsettledSignals: []
|
|
577
|
+
};
|
|
578
|
+
const settle = options.settle !== false;
|
|
579
|
+
if (settle) await flushDeferredSignals(db);
|
|
580
|
+
let firstAllocatedSeq = null;
|
|
581
|
+
let drained = 0;
|
|
582
|
+
const unsettledSignals = [];
|
|
583
|
+
const maxPasses = Math.max(1, options.maxPasses ?? MAX_DRAIN_PASSES);
|
|
584
|
+
for (let pass = 0; pass < maxPasses; pass++) {
|
|
585
|
+
const rows = getQueryRows(await db.query(`SELECT * FROM ${POSTGRES_CHANGE_FEED_DRAIN_FUNCTION_NAME}()`));
|
|
586
|
+
const failure = rows.find((row) => row.error_code != null);
|
|
587
|
+
if (failure) {
|
|
588
|
+
const error = new Error(String(failure.error_message || "PostgreSQL change-feed drain failed"));
|
|
589
|
+
error.code = String(failure.error_code);
|
|
590
|
+
if (isUniqueViolation(error)) continue;
|
|
591
|
+
throw error;
|
|
592
|
+
}
|
|
593
|
+
const sequenced = rows.filter((row) => row.drained_seq != null);
|
|
594
|
+
drained += sequenced.length;
|
|
595
|
+
for (const row of sequenced) {
|
|
596
|
+
const seq = toSeqNumber(row.drained_seq);
|
|
597
|
+
if (firstAllocatedSeq === null || seq < firstAllocatedSeq) firstAllocatedSeq = seq;
|
|
598
|
+
}
|
|
599
|
+
const signals = sequenced.map((row) => ({
|
|
600
|
+
table: String(row.drained_table ?? ""),
|
|
601
|
+
operation: String(row.drained_operation ?? "update"),
|
|
602
|
+
rowId: row.drained_row_id == null ? null : String(row.drained_row_id),
|
|
603
|
+
tenantId: row.drained_tenant_id == null ? null : String(row.drained_tenant_id),
|
|
604
|
+
seq: toSeqNumber(row.drained_seq)
|
|
605
|
+
}));
|
|
606
|
+
if (signals.length > 0) if (!settle) unsettledSignals.push(...signals);
|
|
607
|
+
else if (await drainCommitted(db)) publishSignals(db, signals);
|
|
608
|
+
else queueDeferredSignals(db, signals);
|
|
609
|
+
if (sequenced.length === 0) break;
|
|
610
|
+
}
|
|
611
|
+
recordUncommittedDrainMark(db, firstAllocatedSeq);
|
|
612
|
+
return {
|
|
613
|
+
drained,
|
|
614
|
+
firstAllocatedSeq,
|
|
615
|
+
unsettledSignals
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
/** Signals a drain produced but could not yet prove committed. */
|
|
619
|
+
var deferredSignals = /* @__PURE__ */ new Map();
|
|
620
|
+
function publishSignals(db, signals) {
|
|
621
|
+
for (const signal of signals) try {
|
|
622
|
+
publishChangeSignal(db, signal);
|
|
623
|
+
} catch (error) {
|
|
624
|
+
warnSignalPublishFailureOnce(db, signal.table, error);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
function queueDeferredSignals(db, signals) {
|
|
628
|
+
try {
|
|
629
|
+
const dbKey = resolveDbCacheKey(db);
|
|
630
|
+
const queued = deferredSignals.get(dbKey);
|
|
631
|
+
if (queued) {
|
|
632
|
+
queued.push(...signals);
|
|
633
|
+
if (queued.length > MAX_DEFERRED_SIGNALS) queued.splice(0, queued.length - MAX_DEFERRED_SIGNALS);
|
|
634
|
+
} else deferredSignals.set(dbKey, [...signals]);
|
|
635
|
+
} catch {}
|
|
636
|
+
}
|
|
637
|
+
/**
|
|
638
|
+
* Publish queued signals whose sequences are now committed.
|
|
639
|
+
*
|
|
640
|
+
* A queued entry's transaction may have rolled back, which returns its row to
|
|
641
|
+
* the staging table and frees its sequence for somebody else. Each queued
|
|
642
|
+
* signal is therefore verified against the committed log before it is
|
|
643
|
+
* published: the sequence must still carry the same table and row.
|
|
644
|
+
*
|
|
645
|
+
* An entry that does not verify is **kept, not dropped**. The queue is keyed
|
|
646
|
+
* per database, and every connection to that database shares it — so a second
|
|
647
|
+
* connection can reach this while the transaction that queued the entry is
|
|
648
|
+
* still open. Its own commit probe says "not in a transaction", but it cannot
|
|
649
|
+
* see the other connection's uncommitted rows, and discarding on that basis
|
|
650
|
+
* would strand a signal nobody can ever republish once that transaction
|
|
651
|
+
* commits. Unverified entries therefore wait for a later flush, bounded only
|
|
652
|
+
* by {@link MAX_DEFERRED_SIGNALS}, which evicts oldest-first — an entry whose
|
|
653
|
+
* transaction really did roll back is re-sequenced and re-signalled by a later
|
|
654
|
+
* drain regardless.
|
|
655
|
+
*/
|
|
656
|
+
async function flushDeferredSignals(db) {
|
|
657
|
+
let dbKey;
|
|
658
|
+
try {
|
|
659
|
+
dbKey = resolveDbCacheKey(db);
|
|
660
|
+
} catch {
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
const queued = deferredSignals.get(dbKey);
|
|
664
|
+
if (!queued || queued.length === 0) return;
|
|
665
|
+
deferredSignals.delete(dbKey);
|
|
666
|
+
const candidates = queued;
|
|
667
|
+
let unpublished = candidates;
|
|
668
|
+
try {
|
|
669
|
+
if (!await drainCommitted(db)) return;
|
|
670
|
+
unpublished = await publishVerifiedSignals(db, candidates);
|
|
671
|
+
} finally {
|
|
672
|
+
requeueDeferredSignals(dbKey, unpublished);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* Publish the queued signals whose sequences still carry the row they were
|
|
677
|
+
* drained for, and return the ones that could not be verified.
|
|
678
|
+
*/
|
|
679
|
+
async function publishVerifiedSignals(db, candidates) {
|
|
680
|
+
const p = placeholders(db);
|
|
681
|
+
const rows = getQueryRows(await db.query(`SELECT seq, table_name, row_id FROM ${CHANGE_FEED_TABLE} WHERE seq IN (${candidates.map((_, index) => p(index + 1)).join(", ")})`, ...candidates.map((signal) => signal.seq)));
|
|
682
|
+
const identity = (table, rowId) => `${table}\u0000${rowId ?? ""}`;
|
|
683
|
+
const committed = new Map(rows.map((row) => [toSeqNumber(row.seq), identity(String(row.table_name ?? ""), row.row_id == null ? null : String(row.row_id))]));
|
|
684
|
+
const verified = candidates.filter((signal) => committed.get(signal.seq) === identity(signal.table, signal.rowId));
|
|
685
|
+
if (verified.length === 0) return candidates;
|
|
686
|
+
const publishedSeqs = new Set(verified.map((signal) => signal.seq));
|
|
687
|
+
publishSignals(db, verified);
|
|
688
|
+
return candidates.filter((signal) => !publishedSeqs.has(signal.seq));
|
|
689
|
+
}
|
|
690
|
+
/** Put unpublished signals back, ahead of anything queued meanwhile. */
|
|
691
|
+
function requeueDeferredSignals(dbKey, signals) {
|
|
692
|
+
if (signals.length === 0) return;
|
|
693
|
+
const queuedSince = deferredSignals.get(dbKey) ?? [];
|
|
694
|
+
const merged = [...signals, ...queuedSince];
|
|
695
|
+
deferredSignals.set(dbKey, merged.length > MAX_DEFERRED_SIGNALS ? merged.slice(merged.length - MAX_DEFERRED_SIGNALS) : merged);
|
|
696
|
+
}
|
|
697
|
+
/**
|
|
698
|
+
* Whether the drain that just ran has committed.
|
|
699
|
+
*
|
|
700
|
+
* Under autocommit the drain was its own transaction and ended with its
|
|
701
|
+
* statement, so no transaction id is assigned by the time this separate
|
|
702
|
+
* statement runs. Inside a caller transaction the id the drain assigned is
|
|
703
|
+
* still live and its rows are still uncommitted.
|
|
704
|
+
*/
|
|
705
|
+
async function drainCommitted(db) {
|
|
706
|
+
const committed = getQueryRows(await db.query("SELECT pg_current_xact_id_if_assigned() IS NULL AS committed"))[0]?.committed;
|
|
707
|
+
return committed === true || committed === "t";
|
|
708
|
+
}
|
|
709
|
+
/**
|
|
710
|
+
* Drain without ever failing the caller — the read-path and prune policy.
|
|
711
|
+
*
|
|
712
|
+
* Any sequences it allocates are recorded on the handle's uncommitted-drain
|
|
713
|
+
* mark; see {@link uncommittedDrainMarks} for why a reader must not serve at
|
|
714
|
+
* or above it while the caller's transaction is still open.
|
|
715
|
+
*/
|
|
716
|
+
async function drainChangeFeedBestEffort(db) {
|
|
717
|
+
try {
|
|
718
|
+
await drainChangeFeedDetailed(db);
|
|
719
|
+
} catch (error) {
|
|
720
|
+
warnDrainFailureOnce(db, error);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
/**
|
|
315
724
|
* Manual bump escape hatch for out-of-band writers.
|
|
316
725
|
*
|
|
317
726
|
* Framework mutation paths feed the log automatically, but raw SQL issued
|
|
@@ -370,11 +779,17 @@ async function getChangesSince(db, options) {
|
|
|
370
779
|
const { since } = options;
|
|
371
780
|
if (!Number.isFinite(since) || since < 0) throw new Error(`getChangesSince requires a non-negative numeric cursor, got '${String(since)}'`);
|
|
372
781
|
const limit = Math.min(Math.max(Math.floor(options.limit ?? 500), 1), MAX_CHANGES_LIMIT);
|
|
782
|
+
await drainChangeFeedBestEffort(db);
|
|
373
783
|
const p = placeholders(db);
|
|
374
|
-
const boundsRows = getQueryRows(await db.query(`SELECT MIN(seq) AS floor, MAX(seq) AS horizon FROM ${CHANGE_FEED_TABLE}`));
|
|
784
|
+
const boundsRows = getQueryRows(await db.query(getEngine(db) === "postgres" ? `SELECT MIN(seq) AS floor, MAX(seq) AS horizon, pg_current_xact_id_if_assigned() IS NOT NULL AS in_transaction FROM ${CHANGE_FEED_TABLE}` : `SELECT MIN(seq) AS floor, MAX(seq) AS horizon FROM ${CHANGE_FEED_TABLE}`));
|
|
375
785
|
const floor = toSeqNumber(boundsRows[0]?.floor);
|
|
376
786
|
const horizon = toSeqNumber(boundsRows[0]?.horizon);
|
|
377
|
-
|
|
787
|
+
const inCallerTransaction = boundsRows[0]?.in_transaction === true || boundsRows[0]?.in_transaction === "t";
|
|
788
|
+
const handleKey = db;
|
|
789
|
+
if (!inCallerTransaction) uncommittedDrainMarks.delete(handleKey);
|
|
790
|
+
const drainMark = inCallerTransaction ? uncommittedDrainMarks.get(handleKey) : void 0;
|
|
791
|
+
const servedHorizon = drainMark === void 0 ? horizon : Math.min(horizon, drainMark - 1);
|
|
792
|
+
if (servedHorizon === 0) return since === 0 ? {
|
|
378
793
|
changes: [],
|
|
379
794
|
cursor: 0
|
|
380
795
|
} : {
|
|
@@ -383,19 +798,19 @@ async function getChangesSince(db, options) {
|
|
|
383
798
|
resyncRequired: true,
|
|
384
799
|
resyncCursor: 0
|
|
385
800
|
};
|
|
386
|
-
if (since >
|
|
801
|
+
if (since > servedHorizon) return {
|
|
387
802
|
changes: [],
|
|
388
803
|
cursor: since,
|
|
389
804
|
resyncRequired: true,
|
|
390
|
-
resyncCursor:
|
|
805
|
+
resyncCursor: servedHorizon
|
|
391
806
|
};
|
|
392
807
|
if (since < floor - 1) return {
|
|
393
808
|
changes: [],
|
|
394
809
|
cursor: since,
|
|
395
810
|
resyncRequired: true,
|
|
396
|
-
resyncCursor:
|
|
811
|
+
resyncCursor: servedHorizon
|
|
397
812
|
};
|
|
398
|
-
if (
|
|
813
|
+
if (servedHorizon <= since) return {
|
|
399
814
|
changes: [],
|
|
400
815
|
cursor: since
|
|
401
816
|
};
|
|
@@ -406,7 +821,7 @@ async function getChangesSince(db, options) {
|
|
|
406
821
|
conditions.push(`seq > ${next()}`);
|
|
407
822
|
params.push(since);
|
|
408
823
|
conditions.push(`seq <= ${next()}`);
|
|
409
|
-
params.push(
|
|
824
|
+
params.push(servedHorizon);
|
|
410
825
|
const tables = options.tables?.filter((table) => table.trim().length > 0);
|
|
411
826
|
if (tables && tables.length > 0) {
|
|
412
827
|
conditions.push(`table_name IN (${tables.map(() => next()).join(", ")})`);
|
|
@@ -422,7 +837,7 @@ async function getChangesSince(db, options) {
|
|
|
422
837
|
const changes = getQueryRows(await db.query(sql, ...params)).map(rowToEntry);
|
|
423
838
|
return {
|
|
424
839
|
changes,
|
|
425
|
-
cursor: changes.length === limit ? changes[changes.length - 1].seq :
|
|
840
|
+
cursor: changes.length === limit ? changes[changes.length - 1].seq : servedHorizon
|
|
426
841
|
};
|
|
427
842
|
}
|
|
428
843
|
/**
|
|
@@ -493,9 +908,17 @@ async function getTableVersion(db, table) {
|
|
|
493
908
|
if (!name) throw new Error("getTableVersion requires a non-empty table name");
|
|
494
909
|
await ensureChangeFeedTable(db);
|
|
495
910
|
const p = placeholders(db);
|
|
496
|
-
const
|
|
497
|
-
|
|
498
|
-
|
|
911
|
+
const row = getQueryRows(await db.query(getEngine(db) === "postgres" ? `SELECT
|
|
912
|
+
(SELECT MAX(seq) FROM ${CHANGE_FEED_TABLE} WHERE table_name = ${p(1)}) AS version,
|
|
913
|
+
(SELECT MAX(seq) FROM ${CHANGE_FEED_TABLE}) AS horizon,
|
|
914
|
+
(SELECT COUNT(*) FROM ${POSTGRES_CHANGE_FEED_PENDING_TABLE} WHERE table_name = ${p(1)}) AS staged` : `SELECT
|
|
915
|
+
(SELECT MAX(seq) FROM ${CHANGE_FEED_TABLE} WHERE table_name = ${p(1)}) AS version,
|
|
916
|
+
(SELECT MAX(seq) FROM ${CHANGE_FEED_TABLE}) AS horizon,
|
|
917
|
+
0 AS staged`, name))[0];
|
|
918
|
+
const staged = toSeqNumber(row?.staged);
|
|
919
|
+
const tableVersion = row?.version;
|
|
920
|
+
if (tableVersion != null) return toSeqNumber(tableVersion) + staged;
|
|
921
|
+
return toSeqNumber(row?.horizon) + staged;
|
|
499
922
|
}
|
|
500
923
|
function toSeqNumber(value) {
|
|
501
924
|
return toSafeInteger(value ?? 0, "Change-feed sequence");
|
|
@@ -543,6 +966,7 @@ async function pruneChangeFeed(db, retention) {
|
|
|
543
966
|
if (maxAgeMs != null && (!Number.isFinite(maxAgeMs) || maxAgeMs < 0)) throw new Error(`pruneChangeFeed maxAgeMs must be >= 0, got ${maxAgeMs}`);
|
|
544
967
|
if (maxRows != null && (!Number.isFinite(maxRows) || maxRows < 0)) throw new Error(`pruneChangeFeed maxRows must be >= 0, got ${maxRows}`);
|
|
545
968
|
const p = placeholders(db);
|
|
969
|
+
await drainChangeFeedBestEffort(db);
|
|
546
970
|
const horizon = toSeqNumber(getQueryRows(await db.query(`SELECT MAX(seq) AS horizon FROM ${CHANGE_FEED_TABLE}`))[0]?.horizon);
|
|
547
971
|
if (horizon === 0) return { pruned: 0 };
|
|
548
972
|
let pruned = 0;
|
|
@@ -556,11 +980,9 @@ async function pruneChangeFeed(db, retention) {
|
|
|
556
980
|
}
|
|
557
981
|
if (maxAgeMs != null) {
|
|
558
982
|
const cutoff = new Date(Date.now() - maxAgeMs).toISOString();
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
prunedThrough
|
|
563
|
-
], dryRun);
|
|
983
|
+
const firstRetained = toSeqNumber(getQueryRows(await db.query(`SELECT MIN(seq) AS first_retained FROM ${CHANGE_FEED_TABLE} WHERE created_at >= ${p(1)}`, cutoff))[0]?.first_retained);
|
|
984
|
+
const ageThrough = Math.min(firstRetained > 0 ? firstRetained - 1 : horizon - 1, horizon - 1);
|
|
985
|
+
if (ageThrough > prunedThrough) pruned += await deleteCounted(db, `seq <= ${p(1)} AND seq > ${p(2)}`, [ageThrough, prunedThrough], dryRun);
|
|
564
986
|
}
|
|
565
987
|
return { pruned };
|
|
566
988
|
}
|
|
@@ -574,6 +996,8 @@ var CHANGE_FEED_WAS_PERSISTED_KEY = "_smrtChangeFeedWasPersisted";
|
|
|
574
996
|
var warnedAppendFailures = /* @__PURE__ */ new Set();
|
|
575
997
|
/** Databases we already warned about after a failed signal publish (#1763). */
|
|
576
998
|
var warnedSignalPublishFailures = /* @__PURE__ */ new Set();
|
|
999
|
+
/** Databases we already warned about after a failed feed drain (#2649). */
|
|
1000
|
+
var warnedDrainFailures = /* @__PURE__ */ new Set();
|
|
577
1001
|
/**
|
|
578
1002
|
* Register the change-feed writer with {@link GlobalInterceptors}.
|
|
579
1003
|
*
|
|
@@ -641,6 +1065,7 @@ async function appendForInstance(instance, operation) {
|
|
|
641
1065
|
operation,
|
|
642
1066
|
tenantId: rowTenantId
|
|
643
1067
|
});
|
|
1068
|
+
if (seq == null) return;
|
|
644
1069
|
try {
|
|
645
1070
|
publishChangeSignal(db, {
|
|
646
1071
|
table,
|
|
@@ -668,6 +1093,14 @@ function warnAppendFailureOnce(db, table, error) {
|
|
|
668
1093
|
logger.warn(`Change feed: failed to append a change entry for '${table}'. The write itself succeeded; the feed is missing this change (further failures for this database are suppressed). Consumers recover on full resync.`, { error: error instanceof Error ? error.message : String(error) });
|
|
669
1094
|
} catch {}
|
|
670
1095
|
}
|
|
1096
|
+
function warnDrainFailureOnce(db, error) {
|
|
1097
|
+
try {
|
|
1098
|
+
const dbKey = resolveDbCacheKey(db);
|
|
1099
|
+
if (warnedDrainFailures.has(dbKey)) return;
|
|
1100
|
+
warnedDrainFailures.add(dbKey);
|
|
1101
|
+
logger.warn("Change feed: failed to sequence entries staged inside caller transactions. Those entries are durable but stay invisible to cursor readers until a drain succeeds (further failures for this database are suppressed). A read-only handle cannot drain — schedule drainChangeFeed() on a writable connection.", { error: error instanceof Error ? error.message : String(error) });
|
|
1102
|
+
} catch {}
|
|
1103
|
+
}
|
|
671
1104
|
function warnSignalPublishFailureOnce(db, table, error) {
|
|
672
1105
|
try {
|
|
673
1106
|
const dbKey = resolveDbCacheKey(db);
|
|
@@ -682,8 +1115,12 @@ function warnSignalPublishFailureOnce(db, table, error) {
|
|
|
682
1115
|
function resetChangeFeedWarnings() {
|
|
683
1116
|
warnedAppendFailures.clear();
|
|
684
1117
|
warnedSignalPublishFailures.clear();
|
|
1118
|
+
warnedDrainFailures.clear();
|
|
1119
|
+
lastAppendDrain.clear();
|
|
1120
|
+
stagedSinceLastDrain.clear();
|
|
1121
|
+
deferredSignals.clear();
|
|
685
1122
|
}
|
|
686
1123
|
//#endregion
|
|
687
|
-
export { CHANGE_FEED_EXCLUDED_TABLES, CHANGE_FEED_INTERCEPTOR_NAME, CHANGE_FEED_TABLE, CHANGE_FEED_WAS_PERSISTED_KEY, DEFAULT_CHANGES_LIMIT, MAX_CHANGES_LIMIT, appendChange, bumpChangeFeed, ensureChangeFeedTable, ensurePostgresChangeFeedAppendFunction, getChangesSince, getTableVersion, getTenantScopedChangesSince, isChangeFeedObservableTable, pruneChangeFeed, recordInstanceChange, registerChangeFeedWriter, resetChangeFeedWarnings, unregisterChangeFeedWriter };
|
|
1124
|
+
export { CHANGE_FEED_EXCLUDED_TABLES, CHANGE_FEED_INTERCEPTOR_NAME, CHANGE_FEED_TABLE, CHANGE_FEED_WAS_PERSISTED_KEY, DEFAULT_CHANGES_LIMIT, MAX_CHANGES_LIMIT, appendChange, bumpChangeFeed, drainChangeFeed, ensureChangeFeedTable, ensurePostgresChangeFeedAppendFunction, ensurePostgresChangeFeedHelpers, getChangesSince, getTableVersion, getTenantScopedChangesSince, isChangeFeedObservableTable, pruneChangeFeed, recordInstanceChange, registerChangeFeedWriter, resetChangeFeedWarnings, unregisterChangeFeedWriter };
|
|
688
1125
|
|
|
689
1126
|
//# sourceMappingURL=change-feed.js.map
|