@quereus/isolation 4.3.1 → 4.4.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/README.md +25 -5
- package/dist/src/filter-info.d.ts +34 -0
- package/dist/src/filter-info.d.ts.map +1 -0
- package/dist/src/filter-info.js +51 -0
- package/dist/src/filter-info.js.map +1 -0
- package/dist/src/flush.d.ts +36 -0
- package/dist/src/flush.d.ts.map +1 -0
- package/dist/src/flush.js +110 -0
- package/dist/src/flush.js.map +1 -0
- package/dist/src/isolated-table.d.ts +251 -56
- package/dist/src/isolated-table.d.ts.map +1 -1
- package/dist/src/isolated-table.js +658 -320
- package/dist/src/isolated-table.js.map +1 -1
- package/dist/src/isolation-module.d.ts +394 -32
- package/dist/src/isolation-module.d.ts.map +1 -1
- package/dist/src/isolation-module.js +821 -136
- package/dist/src/isolation-module.js.map +1 -1
- package/dist/src/overlay-rows.d.ts +49 -0
- package/dist/src/overlay-rows.d.ts.map +1 -0
- package/dist/src/overlay-rows.js +85 -0
- package/dist/src/overlay-rows.js.map +1 -0
- package/package.json +4 -3
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
import { MemoryTableModule, PhysicalType, QuereusError, StatusCode, tryFoldLiteral, columnDefToSchema } from '@quereus/quereus';
|
|
1
|
+
import { MemoryTableModule, PhysicalType, QuereusError, StatusCode, tryFoldLiteral, columnDefToSchema, isConstraintViolation } from '@quereus/quereus';
|
|
2
2
|
import { IsolatedTable } from './isolated-table.js';
|
|
3
|
+
import { applyOverlayToUnderlying } from './flush.js';
|
|
4
|
+
import { makeFullScanFilterInfo } from './filter-info.js';
|
|
5
|
+
import { iterateEffectiveRows, makePkKeySerializer } from './overlay-rows.js';
|
|
3
6
|
let overlayIdCounter = 0;
|
|
4
7
|
/**
|
|
5
8
|
* Generates a unique overlay ID for each overlay table instance.
|
|
@@ -54,7 +57,10 @@ export function clampToReentrantReads(mode) {
|
|
|
54
57
|
*
|
|
55
58
|
* This provides ACID semantics including:
|
|
56
59
|
* - Read-your-own-writes within a transaction
|
|
57
|
-
* -
|
|
60
|
+
* - Read-committed reads of shared state (the underlying table is live and shared
|
|
61
|
+
* across connections — this is NOT snapshot isolation; another connection's commit
|
|
62
|
+
* can become visible mid-transaction, and there is no write-write conflict
|
|
63
|
+
* detection). A stable snapshot, if needed, is the underlying module's job.
|
|
58
64
|
* - Savepoint support via overlay module's transaction support
|
|
59
65
|
*/
|
|
60
66
|
export class IsolationModule {
|
|
@@ -155,7 +161,7 @@ export class IsolationModule {
|
|
|
155
161
|
const underlyingTable = await underlyingCreateBacking.call(this.underlying, db, tableSchema);
|
|
156
162
|
const state = { underlyingTable };
|
|
157
163
|
this.setUnderlyingState(tableSchema.schemaName, tableSchema.name, state);
|
|
158
|
-
return new IsolatedTable(db, this, underlyingTable);
|
|
164
|
+
return new IsolatedTable(db, this, tableSchema.schemaName, tableSchema.name, underlyingTable);
|
|
159
165
|
};
|
|
160
166
|
}
|
|
161
167
|
// The attach seams swap the underlying storage flavor in place (ordinary ⇄
|
|
@@ -168,6 +174,14 @@ export class IsolationModule {
|
|
|
168
174
|
// from the underlying. Evict only on success: a thrown attach leaves the
|
|
169
175
|
// prior flavor (and its still-valid cache) intact, and the failure-cleanup
|
|
170
176
|
// path is `discardBackingForAttach`, which evicts in its own right.
|
|
177
|
+
//
|
|
178
|
+
// NOTE: these three seams evict `underlyingTables` without touching
|
|
179
|
+
// `connectionOverlays`, unlike `destroy()`. That is safe only because writes to a
|
|
180
|
+
// materialized-view backing table are privileged and bypass the overlay, so no
|
|
181
|
+
// overlay is ever staged against a table that crosses a seam. If a seam ever runs
|
|
182
|
+
// on a table an open transaction has staged writes for, `commitConnectionOverlays`
|
|
183
|
+
// will raise its INTERNAL invariant error — give the seams the same overlay sweep
|
|
184
|
+
// `destroy()` performs.
|
|
171
185
|
const underlyingEnsure = this.underlying.ensureBackingForAttach;
|
|
172
186
|
if (underlyingEnsure) {
|
|
173
187
|
this.ensureBackingForAttach = async (db, schemaName, tableName, backingSchema) => {
|
|
@@ -267,11 +281,40 @@ export class IsolationModule {
|
|
|
267
281
|
this.connectionOverlays.set(key, state);
|
|
268
282
|
}
|
|
269
283
|
/**
|
|
270
|
-
*
|
|
271
|
-
*
|
|
284
|
+
* Frees the in-memory staging (overlay) table backing `state` by calling the overlay
|
|
285
|
+
* module's `destroy`, so its manager entry (and the rows it holds) is removed from the
|
|
286
|
+
* overlay module's table registry rather than leaking there for the life of the
|
|
287
|
+
* `Database`. This is the single sink every overlay-discard path funnels through —
|
|
288
|
+
* without it, `MemoryTableModule.tables` accumulates one dead `_overlay_<table>_<id>`
|
|
289
|
+
* entry per writing transaction (and one more per rebuild), unbounded.
|
|
290
|
+
*
|
|
291
|
+
* `MemoryTableManager.destroy` rolls back the overlay's own pending layer and clears its
|
|
292
|
+
* connections; a later db-side teardown of the (now-detached) `MemoryVirtualTableConnection`
|
|
293
|
+
* is tolerated by `MemoryTableManager.disconnect` (`!connection` → no-op), so destroying
|
|
294
|
+
* here mid-commit/rollback does not throw when the connection is torn down afterwards.
|
|
295
|
+
*
|
|
296
|
+
* Defensive on a missing schema: real overlays always carry one (`createOverlaySchema`),
|
|
297
|
+
* so a schemaless state can only be a malformed/test-fabricated one — skip rather than throw.
|
|
298
|
+
*/
|
|
299
|
+
async releaseOverlayTable(state) {
|
|
300
|
+
const overlaySchema = state.overlayTable.tableSchema;
|
|
301
|
+
if (!overlaySchema)
|
|
302
|
+
return;
|
|
303
|
+
await this.overlayModule.destroy(state.db, undefined, overlaySchema.vtabModuleName, overlaySchema.schemaName, overlaySchema.name);
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Removes the overlay state for a specific connection and table, first releasing its
|
|
307
|
+
* staging table so it does not leak (see {@link releaseOverlayTable}). Async because the
|
|
308
|
+
* release drives the overlay module's `destroy`; all callers (`clearOverlay`, `alterSchema`)
|
|
309
|
+
* are already async and `await` it. Called on the rollback / alter-schema / rollback-to-
|
|
310
|
+
* pre-overlay-savepoint discard paths.
|
|
272
311
|
*/
|
|
273
|
-
clearConnectionOverlay(db, schemaName, tableName) {
|
|
312
|
+
async clearConnectionOverlay(db, schemaName, tableName) {
|
|
274
313
|
const key = this.makeConnectionOverlayKey(db, schemaName, tableName);
|
|
314
|
+
const state = this.connectionOverlays.get(key);
|
|
315
|
+
if (!state)
|
|
316
|
+
return;
|
|
317
|
+
await this.releaseOverlayTable(state);
|
|
275
318
|
this.connectionOverlays.delete(key);
|
|
276
319
|
}
|
|
277
320
|
/**
|
|
@@ -293,6 +336,155 @@ export class IsolationModule {
|
|
|
293
336
|
const key = this.makeConnectionOverlayKey(db, schemaName, tableName);
|
|
294
337
|
this.preOverlaySavepoints.delete(key);
|
|
295
338
|
}
|
|
339
|
+
/**
|
|
340
|
+
* Returns every key of a connection-scoped map (`<dbId>:<schema>.<table>`, the
|
|
341
|
+
* shape of {@link connectionOverlays} / {@link preOverlaySavepoints} /
|
|
342
|
+
* {@link connectionInFlight}) that belongs to `schemaName.tableName`, across ALL
|
|
343
|
+
* db ids. Those maps embed the db id as a prefix, so a per-table sweep is a suffix
|
|
344
|
+
* match on `:<schema>.<table>`. Keys are stored lowercased, so the suffix is too.
|
|
345
|
+
*
|
|
346
|
+
* The keys are materialized into an array rather than yielded, so callers may
|
|
347
|
+
* delete or re-key entries while walking the result.
|
|
348
|
+
*/
|
|
349
|
+
connectionScopedKeys(map, schemaName, tableName) {
|
|
350
|
+
const suffix = `:${schemaName}.${tableName}`.toLowerCase();
|
|
351
|
+
const keys = [];
|
|
352
|
+
for (const key of map.keys()) {
|
|
353
|
+
if (key.endsWith(suffix))
|
|
354
|
+
keys.push(key);
|
|
355
|
+
}
|
|
356
|
+
return keys;
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Commits every overlay this db-transaction staged as ONE coordinated two-phase
|
|
360
|
+
* flush, instead of each table flushing+committing its own underlying
|
|
361
|
+
* independently. The per-table approach tears a multi-table commit: table A's
|
|
362
|
+
* underlying `commit()` durably lands (and, for a shared-coordinator
|
|
363
|
+
* `quereus-store`, flushes *every* pending table) before table B has even
|
|
364
|
+
* applied, so a failure in B leaves A committed. See the fix ticket and
|
|
365
|
+
* `quereus-store/README` § "Atomic multi-store commit".
|
|
366
|
+
*
|
|
367
|
+
* Phase 1 (apply): for every staged overlay, begin its underlying table and
|
|
368
|
+
* apply the overlay's rows WITHOUT committing (see {@link applyOverlayToUnderlying}).
|
|
369
|
+
* For a `quereus-store` underlying, every table's writes accumulate in the
|
|
370
|
+
* module's single shared coordinator (the first `begin()` opens it; the rest are
|
|
371
|
+
* idempotent no-ops).
|
|
372
|
+
*
|
|
373
|
+
* Phase 2 (commit): once ALL overlays have applied, commit the affected
|
|
374
|
+
* underlying tables. For `quereus-store` the first `commit()` flushes every
|
|
375
|
+
* table's ops in one atomic coordinator commit — a single `AtomicBatch.write()`
|
|
376
|
+
* on a provider that exposes `beginAtomicBatch` — and the rest no-op. For an
|
|
377
|
+
* underlying with per-table transaction domains (the memory vtab), each table
|
|
378
|
+
* commits independently.
|
|
379
|
+
*
|
|
380
|
+
* On any Phase-1 error, roll back every underlying begun so far and rethrow;
|
|
381
|
+
* nothing was committed, so the transaction aborts atomically. Because all the
|
|
382
|
+
* fallible data work (constraint re-checks, injected/IO write errors) happens in
|
|
383
|
+
* Phase 1 before any commit, a data-driven abort is always clean. Full
|
|
384
|
+
* crash-atomicity across the commit phase itself is contingent on the underlying
|
|
385
|
+
* exposing a shared atomic commit domain (see docs/design-isolation-layer.md
|
|
386
|
+
* § "Commit Failure Recovery").
|
|
387
|
+
*
|
|
388
|
+
* A poisoned overlay aborts the whole commit before any apply — mirroring the
|
|
389
|
+
* per-connection `assertOverlayUsable` check, now with the added benefit that no earlier
|
|
390
|
+
* table is left committed. The overlay is left intact so the ensuing rollback discards
|
|
391
|
+
* it. Two DDLs poison: a cross-connection ALTER (rows left in the pre-alter layout) and
|
|
392
|
+
* a cross-connection DROP TABLE (the table is gone; see {@link destroy}).
|
|
393
|
+
*
|
|
394
|
+
* Driven once per db-transaction: the first `IsolatedConnection.commit()` in the
|
|
395
|
+
* database's commit loop runs this whole flush and clears every overlay, so the
|
|
396
|
+
* remaining connections find no overlay for their table and this is a no-op — no
|
|
397
|
+
* explicit "already flushed" latch is needed, the cleared-overlay state guards
|
|
398
|
+
* itself.
|
|
399
|
+
*
|
|
400
|
+
* **Invariant: every staged overlay resolves to an underlying table here, or is
|
|
401
|
+
* poisoned.** The table-lifecycle hooks are what keep that true — {@link destroy}
|
|
402
|
+
* discards or poisons the overlays of a dropped table across every connection, and
|
|
403
|
+
* {@link renameTable} re-connects the underlying under the new name whenever it re-keys
|
|
404
|
+
* an overlay onto it. The poison check above is the enforcement point: it runs BEFORE
|
|
405
|
+
* the `underlyingTables` lookup, so a dropped table's surviving foreign overlay raises
|
|
406
|
+
* its poison message rather than the orphan error below. A miss that is neither resolved
|
|
407
|
+
* nor poisoned is therefore a layer-invariant violation, not a routine condition, and is
|
|
408
|
+
* raised as `StatusCode.INTERNAL`: the alternative — dropping the staged rows and letting
|
|
409
|
+
* the commit report success — is silent data loss. Only a CLEAN overlay
|
|
410
|
+
* (`hasChanges === false`) may miss harmlessly; it staged nothing, so it is discarded.
|
|
411
|
+
*/
|
|
412
|
+
async commitConnectionOverlays(db) {
|
|
413
|
+
const prefix = `${this.getDbId(db)}:`;
|
|
414
|
+
const entries = [];
|
|
415
|
+
/** Clean overlays with no underlying — never applied, but must still be cleared. */
|
|
416
|
+
const orphanedCleanKeys = [];
|
|
417
|
+
for (const [key, state] of this.connectionOverlays.entries()) {
|
|
418
|
+
if (!key.startsWith(prefix))
|
|
419
|
+
continue;
|
|
420
|
+
// A poisoned overlay can neither be flushed nor merged (its rows are in the
|
|
421
|
+
// pre-alter column layout). Abort the whole commit before applying anything;
|
|
422
|
+
// the overlay is left intact so the ensuing rollback discards it (and its
|
|
423
|
+
// poison). A poisoned overlay always has hasChanges === true.
|
|
424
|
+
if (state.poison) {
|
|
425
|
+
throw new QuereusError(state.poison.message, StatusCode.CONSTRAINT);
|
|
426
|
+
}
|
|
427
|
+
// The overlay key is `<dbId>:<schema>.<table>`; the suffix after the dbId is
|
|
428
|
+
// exactly the `underlyingTables` key (both lowercased).
|
|
429
|
+
const underlyingKey = key.slice(prefix.length);
|
|
430
|
+
const underlyingState = this.underlyingTables.get(underlyingKey);
|
|
431
|
+
if (!underlyingState) {
|
|
432
|
+
if (state.hasChanges) {
|
|
433
|
+
throw new QuereusError(`Isolation layer: staged overlay '${key}' has no underlying table '${underlyingKey}' to flush. `
|
|
434
|
+
+ `A table-lifecycle hook (destroy / renameTable) failed to keep the overlay and underlying maps in step.`, StatusCode.INTERNAL);
|
|
435
|
+
}
|
|
436
|
+
// Staged nothing, so nothing is lost. It never reaches `entries`, so the
|
|
437
|
+
// clear-loop below would not see it — collect it explicitly or it leaks.
|
|
438
|
+
orphanedCleanKeys.push(key);
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
entries.push({ key, state, underlyingTable: underlyingState.underlyingTable });
|
|
442
|
+
}
|
|
443
|
+
// Phase 1: apply every staged overlay to its underlying WITHOUT committing.
|
|
444
|
+
const applied = [];
|
|
445
|
+
try {
|
|
446
|
+
for (const { state, underlyingTable } of entries) {
|
|
447
|
+
if (!state.hasChanges)
|
|
448
|
+
continue;
|
|
449
|
+
// Track BEFORE applying: applyOverlayToUnderlying begins the underlying up
|
|
450
|
+
// front, so a mid-apply throw still needs this table in the rollback set.
|
|
451
|
+
applied.push(underlyingTable);
|
|
452
|
+
await applyOverlayToUnderlying(underlyingTable, state.overlayTable, this.tombstoneColumn);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
catch (error) {
|
|
456
|
+
// Nothing committed yet — roll back every underlying we began so no table is
|
|
457
|
+
// left half-applied, then propagate (the transaction aborts atomically). For a
|
|
458
|
+
// shared-coordinator store the first rollback discards all pending ops and the
|
|
459
|
+
// rest no-op. allSettled mirrors the engine's own rollback-during-abort posture
|
|
460
|
+
// in database-transaction.ts (rollback failures must not mask the original error).
|
|
461
|
+
await Promise.allSettled(applied.map(underlyingTable => underlyingTable.rollback?.()));
|
|
462
|
+
throw error;
|
|
463
|
+
}
|
|
464
|
+
// Phase 2: commit the affected underlyings. For a shared-coordinator store the
|
|
465
|
+
// first commit flushes all tables in one atomic batch and the rest no-op; for
|
|
466
|
+
// per-table domains (memory) each commits independently.
|
|
467
|
+
for (const underlyingTable of applied) {
|
|
468
|
+
await underlyingTable.commit?.();
|
|
469
|
+
}
|
|
470
|
+
// Clear every overlay for this db — the transaction's staged state is now
|
|
471
|
+
// durable (or was empty). Every key cleared here was either applied above
|
|
472
|
+
// (`hasChanges`) or staged nothing; a staged overlay that could not be applied
|
|
473
|
+
// threw INTERNAL before Phase 1 and never reaches this point. Subsequent
|
|
474
|
+
// IsolatedConnection.commit()s in the loop find no overlay and no-op. Pre-overlay
|
|
475
|
+
// savepoint sets are cleared per table by each connection's onConnectionCommit
|
|
476
|
+
// (which also covers a table that has savepoints but never got an overlay).
|
|
477
|
+
for (const { key, state } of entries) {
|
|
478
|
+
await this.releaseOverlayTable(state);
|
|
479
|
+
this.connectionOverlays.delete(key);
|
|
480
|
+
}
|
|
481
|
+
for (const key of orphanedCleanKeys) {
|
|
482
|
+
const state = this.connectionOverlays.get(key);
|
|
483
|
+
if (state)
|
|
484
|
+
await this.releaseOverlayTable(state);
|
|
485
|
+
this.connectionOverlays.delete(key);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
296
488
|
/**
|
|
297
489
|
* Coalesces concurrent covering-connection builds for one (db, table) onto a
|
|
298
490
|
* single in-flight promise, keyed identically to {@link connectionOverlays}
|
|
@@ -350,6 +542,13 @@ export class IsolationModule {
|
|
|
350
542
|
}
|
|
351
543
|
/**
|
|
352
544
|
* Returns capabilities combining underlying module with isolation guarantees.
|
|
545
|
+
*
|
|
546
|
+
* `ddlTransactionality` is forwarded verbatim through the spread — the wrapper
|
|
547
|
+
* NEVER upgrades it. The overlay stages DML outside the underlying module, so an
|
|
548
|
+
* underlying DDL-commit flushes only module-side ops, leaving overlay writes
|
|
549
|
+
* behind (the `bug-store-savepoint-ddl-drop-lost-insert` asymmetry). Forwarding
|
|
550
|
+
* the underlying's (pessimistic) value is the honest choice; only `isolation` /
|
|
551
|
+
* `savepoints` are augmented, since the wrapper genuinely adds those.
|
|
353
552
|
*/
|
|
354
553
|
getCapabilities() {
|
|
355
554
|
const underlyingCaps = this.underlying.getCapabilities?.() ?? {};
|
|
@@ -446,8 +645,10 @@ export class IsolationModule {
|
|
|
446
645
|
// 2. Store underlying state (overlay is per-connection, created lazily)
|
|
447
646
|
const state = { underlyingTable };
|
|
448
647
|
this.setUnderlyingState(tableSchema.schemaName, tableSchema.name, state);
|
|
449
|
-
// 3. Return wrapped table (overlay will be created lazily on first write)
|
|
450
|
-
|
|
648
|
+
// 3. Return wrapped table (overlay will be created lazily on first write).
|
|
649
|
+
// Keyed off the schema's own (schemaName, name) — the pair `underlyingTables` uses —
|
|
650
|
+
// never off the underlying table's self-reported names (see IsolatedTable's ctor doc).
|
|
651
|
+
return new IsolatedTable(db, this, tableSchema.schemaName, tableSchema.name, underlyingTable);
|
|
451
652
|
}
|
|
452
653
|
/**
|
|
453
654
|
* Connects to an existing isolated table.
|
|
@@ -471,21 +672,180 @@ export class IsolationModule {
|
|
|
471
672
|
// the per-connection overlay so reads reflect only persisted underlying state.
|
|
472
673
|
const readCommitted = options?._readCommitted === true;
|
|
473
674
|
// Return a fresh IsolatedTable instance that will look up its overlay
|
|
474
|
-
// from connection-scoped storage (shared with other instances in same transaction)
|
|
475
|
-
|
|
675
|
+
// from connection-scoped storage (shared with other instances in same transaction).
|
|
676
|
+
// Pass the connect-time (schemaName, tableName) — the pair `underlyingTables` is keyed
|
|
677
|
+
// by — never the underlying's self-reported names (see IsolatedTable's ctor doc).
|
|
678
|
+
return new IsolatedTable(db, this, schemaName, tableName, state.underlyingTable, readCommitted);
|
|
476
679
|
}
|
|
477
680
|
/**
|
|
478
|
-
* Destroys the underlying table.
|
|
681
|
+
* Destroys the underlying table, then resolves every connection's staged state for it.
|
|
682
|
+
*
|
|
683
|
+
* DROP TABLE is not transaction-scoped: the table is gone for *every* connection the
|
|
684
|
+
* moment this returns, so no overlay staging writes against it can ever be flushed.
|
|
685
|
+
* What differs is who gets told. Per overlay key matching the dropped table (both maps
|
|
686
|
+
* are keyed `<dbId>:<schema>.<table>`, so the sweep spans all db ids):
|
|
687
|
+
*
|
|
688
|
+
* - **The dropping connection's own overlay** is discarded silently. It issued the DROP;
|
|
689
|
+
* there is nobody to notify.
|
|
690
|
+
* - **A foreign overlay with staged rows** (`hasChanges`) is **poisoned**, not swept.
|
|
691
|
+
* Sweeping it let that connection commit against an empty overlay set and report
|
|
692
|
+
* success after its rows were thrown away — silent cross-connection data loss. Poison
|
|
693
|
+
* makes its next read/write/commit throw `CONSTRAINT` (see
|
|
694
|
+
* {@link IsolatedTable.assertOverlayUsable} and the poison check at the head of
|
|
695
|
+
* {@link commitConnectionOverlays}, which precedes the `underlyingTables` lookup and so
|
|
696
|
+
* raises the poison message rather than the orphan INTERNAL error). An already-poisoned
|
|
697
|
+
* overlay keeps its original message — the first cause is the one worth reporting.
|
|
698
|
+
* - **A foreign overlay with no staged rows** is discarded: it staged nothing, so nothing
|
|
699
|
+
* is lost.
|
|
700
|
+
*
|
|
701
|
+
* `preOverlaySavepoints` is swept for every matching key whose overlay did NOT survive.
|
|
702
|
+
* A surviving poisoned overlay keeps its set: `ensureOverlay` padding still consults it,
|
|
703
|
+
* and the owning connection's `onConnectionRollback` reaps it when its failed commit
|
|
704
|
+
* rolls back. Without the sweep, an abandoned set outlived the table for the lifetime of
|
|
705
|
+
* the `Database` (nothing else is keyed to reap it once the table is gone).
|
|
706
|
+
*
|
|
707
|
+
* Nothing is discarded or poisoned until the underlying destroy SUCCEEDS. A throwing
|
|
708
|
+
* `underlying.destroy` means the table still exists, so every connection's staged
|
|
709
|
+
* writes are still flushable and every map entry must survive untouched — the same
|
|
710
|
+
* reason {@link renameTable} delegates before mutating its maps.
|
|
711
|
+
*
|
|
712
|
+
* NOTE: poison rides on the `ConnectionOverlayState`, not on its rows, so a foreign
|
|
713
|
+
* connection that later unwinds every staged row past the drop (rollback to a savepoint
|
|
714
|
+
* taken after the overlay existed) still fails its commit. Deliberately over-strict —
|
|
715
|
+
* the table is gone either way. If a caller ever needs the clean-unwind case to commit,
|
|
716
|
+
* re-evaluate the poison on `onConnectionRollbackToSavepoint` rather than special-casing
|
|
717
|
+
* here.
|
|
718
|
+
*
|
|
719
|
+
* NOTE: a connection whose own overlay was already poisoned (by another connection's
|
|
720
|
+
* ALTER) escapes that poison for this table by dropping it — the own-overlay branch
|
|
721
|
+
* deletes the state, poison and all. Correct as written: the rows it discards belong to
|
|
722
|
+
* a table this connection just asked to remove. If poison ever carries a cause that
|
|
723
|
+
* outlives the table, gate the own-overlay delete on it.
|
|
724
|
+
*
|
|
725
|
+
* NOTE: this mutates `connectionOverlays` while a foreign connection may be mid-scan in
|
|
726
|
+
* `IsolatedTable.query`'s merged branch — that scan will keep merging against an overlay
|
|
727
|
+
* whose underlying is now destroyed. The module clamps to `'reentrant-reads'`, so no
|
|
728
|
+
* in-tree host reaches it. If a host ever runs a DROP concurrently with a foreign scan,
|
|
729
|
+
* the merged iterator needs a per-scan snapshot of the overlay + underlying pair.
|
|
479
730
|
*/
|
|
480
731
|
async destroy(db, pAux, moduleName, schemaName, tableName) {
|
|
481
|
-
this.removeUnderlyingState(schemaName, tableName);
|
|
482
732
|
await this.underlying.destroy(db, pAux, moduleName, schemaName, tableName);
|
|
733
|
+
this.removeUnderlyingState(schemaName, tableName);
|
|
734
|
+
const ownKey = this.makeConnectionOverlayKey(db, schemaName, tableName);
|
|
735
|
+
const survivingKeys = new Set();
|
|
736
|
+
for (const key of this.connectionScopedKeys(this.connectionOverlays, schemaName, tableName)) {
|
|
737
|
+
const state = this.connectionOverlays.get(key);
|
|
738
|
+
if (key !== ownKey && state.hasChanges) {
|
|
739
|
+
if (!state.poison) {
|
|
740
|
+
state.poison = { message: this.buildDropPoisonMessage(schemaName, tableName) };
|
|
741
|
+
}
|
|
742
|
+
survivingKeys.add(key);
|
|
743
|
+
continue;
|
|
744
|
+
}
|
|
745
|
+
// Own overlay, or a foreign CLEAN one — abandoned here, so free its staging table.
|
|
746
|
+
// A surviving poisoned foreign overlay is intentionally NOT released: it stays
|
|
747
|
+
// installed and is freed later when its owning connection rolls back (which routes
|
|
748
|
+
// through clearConnectionOverlay → releaseOverlayTable).
|
|
749
|
+
await this.releaseOverlayTable(state);
|
|
750
|
+
this.connectionOverlays.delete(key);
|
|
751
|
+
}
|
|
752
|
+
for (const key of this.connectionScopedKeys(this.preOverlaySavepoints, schemaName, tableName)) {
|
|
753
|
+
if (!survivingKeys.has(key))
|
|
754
|
+
this.preOverlaySavepoints.delete(key);
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
/**
|
|
758
|
+
* Inserts one staged row into a freshly built overlay, raising on anything but success.
|
|
759
|
+
*
|
|
760
|
+
* `MemoryTable.update` RETURNS a `constraint` status rather than throwing it. Every overlay
|
|
761
|
+
* rebuild loop ignored that return, so a row the new schema forbids was dropped on the floor
|
|
762
|
+
* and the transaction committed without it — silent data loss, reachable today by
|
|
763
|
+
* `alter table … add constraint … unique` over pending duplicate rows. Convert it into a
|
|
764
|
+
* throw; {@link adoptRebuiltOverlay} decides whether that means INTERNAL (the issuer, whose
|
|
765
|
+
* rows the DDL's own validation pass already accepted) or poison (a foreign connection,
|
|
766
|
+
* which must roll back).
|
|
767
|
+
*/
|
|
768
|
+
async insertIntoRebuiltOverlay(newOverlayTable, values, tableName) {
|
|
769
|
+
// No `onConflict` is passed, so the overlay's memory module cannot answer `ignore`;
|
|
770
|
+
// `UpdateResult` is then exactly `ok | constraint`.
|
|
771
|
+
const result = await newOverlayTable.update({ operation: 'insert', values, preCoerced: true });
|
|
772
|
+
if (isConstraintViolation(result)) {
|
|
773
|
+
throw new QuereusError(`Overlay rebuild on '${tableName}' hit a ${result.constraint} constraint: ${result.message ?? 'no message'}`, StatusCode.CONSTRAINT);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
/**
|
|
777
|
+
* Runs one overlay's rebuild and installs the result, routing a CONSTRAINT failure by who
|
|
778
|
+
* owns the overlay:
|
|
779
|
+
*
|
|
780
|
+
* - **The DDL-issuing connection.** Unreachable: the row source handed to the underlying
|
|
781
|
+
* (see {@link issuerEffectiveRows}) judged a superset of exactly these rows and accepted
|
|
782
|
+
* them, so a rejection here means validation and migration have drifted. Raise INTERNAL —
|
|
783
|
+
* loudly, because the alternative is the silent row loss this guard exists to end.
|
|
784
|
+
* - **A foreign connection.** Reachable and legitimate: its staged rows may violate a
|
|
785
|
+
* constraint another connection just declared. Poison that overlay and leave it
|
|
786
|
+
* unmigrated, so its owner errors on its next read/write/commit and rolls back. The
|
|
787
|
+
* issuer's DDL proceeds. Mirrors the tier-3 NOT NULL handling in {@link alterTable}.
|
|
788
|
+
*
|
|
789
|
+
* A rebuild that throws leaves the OLD overlay installed either way — the new table is
|
|
790
|
+
* simply discarded.
|
|
791
|
+
*/
|
|
792
|
+
async adoptRebuiltOverlay(key, oldState, isIssuer, schemaName, tableName, ddlDescription, rebuild) {
|
|
793
|
+
let rebuilt;
|
|
794
|
+
try {
|
|
795
|
+
rebuilt = await rebuild();
|
|
796
|
+
}
|
|
797
|
+
catch (e) {
|
|
798
|
+
if (!(e instanceof QuereusError) || e.code !== StatusCode.CONSTRAINT)
|
|
799
|
+
throw e;
|
|
800
|
+
if (isIssuer) {
|
|
801
|
+
throw new QuereusError(`Isolation layer: rebuilding the issuing connection's overlay for '${schemaName}.${tableName}' after `
|
|
802
|
+
+ `${ddlDescription} raised: ${e.message}. That DDL's validation pass already judged a superset of these `
|
|
803
|
+
+ `rows and accepted them, so validation and migration have drifted.`, StatusCode.INTERNAL, e);
|
|
804
|
+
}
|
|
805
|
+
// Poisoned: the OLD overlay stays installed under `key` (unmigrated, unreleased) so
|
|
806
|
+
// its owner errors and rolls back — do NOT release it here.
|
|
807
|
+
oldState.poison = { message: this.buildRebuildPoisonMessage(schemaName, tableName, ddlDescription, e.message) };
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
// Rebuild succeeded: the NEW overlay replaces the OLD one under `key`, so the OLD
|
|
811
|
+
// staging table is abandoned — free it (leak sink). The builders already freed a
|
|
812
|
+
// HALF-built new overlay on their own throw path, so nothing is double-released.
|
|
813
|
+
this.connectionOverlays.set(key, rebuilt);
|
|
814
|
+
await this.releaseOverlayTable(oldState);
|
|
815
|
+
}
|
|
816
|
+
/**
|
|
817
|
+
* Builds the poison message stamped onto a foreign overlay whose staged rows cannot be
|
|
818
|
+
* migrated under a constraint another connection's DDL just declared (see
|
|
819
|
+
* {@link adoptRebuiltOverlay}). Companion to {@link buildAlterPoisonMessage}, which covers
|
|
820
|
+
* the `addColumn` NOT NULL backfill rejected before the underlying is touched; this one
|
|
821
|
+
* covers a UNIQUE (or any other) violation raised by the rebuild itself.
|
|
822
|
+
*/
|
|
823
|
+
buildRebuildPoisonMessage(schemaName, tableName, ddlDescription, cause) {
|
|
824
|
+
return `Another connection's ${ddlDescription} on '${schemaName}.${tableName}' declared a constraint this connection's `
|
|
825
|
+
+ `uncommitted rows violate (${cause}); roll back this transaction.`;
|
|
826
|
+
}
|
|
827
|
+
/**
|
|
828
|
+
* Builds the poison message stamped onto a foreign overlay whose table was dropped out
|
|
829
|
+
* from under it (see {@link destroy}). Names the schema.table so the owning connection's
|
|
830
|
+
* eventual read/write/commit error is self-explanatory. Companion to
|
|
831
|
+
* {@link buildAlterPoisonMessage}: both poison sources raise the same
|
|
832
|
+
* `StatusCode.CONSTRAINT` and are told apart by their message, not their code.
|
|
833
|
+
*/
|
|
834
|
+
buildDropPoisonMessage(schemaName, tableName) {
|
|
835
|
+
return `Table '${schemaName}.${tableName}' was dropped by another connection while this connection had uncommitted changes staged for it; roll back this transaction.`;
|
|
483
836
|
}
|
|
484
837
|
/**
|
|
485
838
|
* Closes all resources held by the underlying module (if it supports closeAll).
|
|
486
839
|
* Also clears connection overlay state.
|
|
487
840
|
*/
|
|
488
841
|
async closeAll() {
|
|
842
|
+
// Free every overlay's staging table before dropping the map. The default
|
|
843
|
+
// MemoryTableModule overlay is discarded with this wrapper, but a host-injected
|
|
844
|
+
// SHARED config.overlay would otherwise retain one dead entry per open overlay —
|
|
845
|
+
// each state carries its own db so the release targets the right one.
|
|
846
|
+
for (const state of this.connectionOverlays.values()) {
|
|
847
|
+
await this.releaseOverlayTable(state);
|
|
848
|
+
}
|
|
489
849
|
this.connectionOverlays.clear();
|
|
490
850
|
this.preOverlaySavepoints.clear();
|
|
491
851
|
this.underlyingTables.clear();
|
|
@@ -495,24 +855,107 @@ export class IsolationModule {
|
|
|
495
855
|
}
|
|
496
856
|
}
|
|
497
857
|
/**
|
|
498
|
-
*
|
|
858
|
+
* The rows the connection owning `overlayState` can SEE — the underlying's committed rows
|
|
859
|
+
* merged with that overlay's staged writes. Re-callable, as `EffectiveRowSource` requires.
|
|
499
860
|
*
|
|
500
|
-
*
|
|
501
|
-
*
|
|
861
|
+
* This is the seam the whole fix turns on: the underlying module validates row-content DDL
|
|
862
|
+
* (UNIQUE duplicate detection, collation-rekey collisions) against its OWN rows, which under
|
|
863
|
+
* isolation are the committed rows only. The transaction's pending rows live here, in the
|
|
864
|
+
* overlay, where the underlying cannot reach them — so we hand them down.
|
|
502
865
|
*
|
|
503
|
-
*
|
|
504
|
-
*
|
|
505
|
-
*
|
|
866
|
+
* NOTE: each call re-materializes the overlay and re-scans the underlying. `alter column …
|
|
867
|
+
* set collate` calls once per UNIQUE constraint covering the altered column, so a table with
|
|
868
|
+
* many such constraints pays that many scans. If it ever shows up as slow, materialize the
|
|
869
|
+
* overlay's PK map once per DDL and share it across the calls.
|
|
506
870
|
*/
|
|
507
|
-
|
|
871
|
+
effectiveRowsFor(db, underlyingTable, overlayState) {
|
|
872
|
+
const schema = underlyingTable.tableSchema;
|
|
873
|
+
if (!schema) {
|
|
874
|
+
throw new QuereusError('Isolation layer: underlying table has no schema', StatusCode.INTERNAL);
|
|
875
|
+
}
|
|
876
|
+
const pkIndices = schema.primaryKeyDefinition.map(pkDef => pkDef.index);
|
|
877
|
+
const pkKeyOf = makePkKeySerializer(db, schema);
|
|
878
|
+
const overlayTable = overlayState.overlayTable;
|
|
879
|
+
return () => iterateEffectiveRows(underlyingTable, overlayTable, this.tombstoneColumn, pkIndices, pkKeyOf);
|
|
880
|
+
}
|
|
881
|
+
/**
|
|
882
|
+
* The row source to hand a row-validating DDL on behalf of the connection issuing it, or
|
|
883
|
+
* undefined when that connection has nothing staged and the underlying's own rows already
|
|
884
|
+
* ARE its effective rows.
|
|
885
|
+
*
|
|
886
|
+
* **Only the issuing connection's overlay feeds validation.** A foreign connection's
|
|
887
|
+
* overlay may hold rows that collide with the new constraint; that is its problem when it
|
|
888
|
+
* commits, exactly as an ordinary concurrent duplicate insert would be. A poisoned issuer
|
|
889
|
+
* overlay is likewise skipped — its rows are structurally stale, and the connection can
|
|
890
|
+
* only recover by rolling back.
|
|
891
|
+
*/
|
|
892
|
+
issuerEffectiveRows(db, schemaName, tableName, underlyingTable) {
|
|
893
|
+
const overlayState = this.getConnectionOverlay(db, schemaName, tableName);
|
|
894
|
+
if (!overlayState || overlayState.poison || !overlayState.hasChanges)
|
|
895
|
+
return undefined;
|
|
896
|
+
return this.effectiveRowsFor(db, underlyingTable, overlayState);
|
|
897
|
+
}
|
|
898
|
+
/**
|
|
899
|
+
* Creates an index on the underlying table, then rebuilds every per-connection overlay so
|
|
900
|
+
* the new index (and, for a UNIQUE index, the constraint derived from it) is enforced for
|
|
901
|
+
* the rest of each open transaction.
|
|
902
|
+
*
|
|
903
|
+
* Two things the underlying cannot do for itself:
|
|
904
|
+
*
|
|
905
|
+
* 1. **Judge the right rows.** The issuing connection's pending rows are in its overlay,
|
|
906
|
+
* invisible to the underlying, so a duplicate it staged would slip past the build and a
|
|
907
|
+
* duplicate it deleted would spuriously reject it. {@link issuerEffectiveRows} supplies
|
|
908
|
+
* the merged view; the underlying builds its physical structure from its own committed
|
|
909
|
+
* rows, which is sound because every reader resolves an index entry back to its live row.
|
|
910
|
+
* 2. **Enforce the new constraint.** An overlay built before the index knows nothing of it,
|
|
911
|
+
* and `IsolatedTable.findMergedUniqueConflict` only scans the underlying — so a pending
|
|
912
|
+
* row colliding with another pending row is nobody's job until the overlay itself carries
|
|
913
|
+
* the index. Hence the rebuild, which is also what gives a merged secondary-index scan
|
|
914
|
+
* later in the transaction an overlay that can serve it.
|
|
915
|
+
*
|
|
916
|
+
* We use the stored table instance's createIndex() rather than the module-level method so
|
|
917
|
+
* that the MemoryTable's local tableSchema property stays in sync. That property is what
|
|
918
|
+
* ensureOverlay() reads when building the overlay schema.
|
|
919
|
+
*/
|
|
920
|
+
async createIndex(db, schemaName, tableName, indexSchema, rows) {
|
|
508
921
|
const state = this.getUnderlyingState(schemaName, tableName);
|
|
922
|
+
// An outer wrapper's row source, if any, already names the effective rows; otherwise
|
|
923
|
+
// build our own from the issuing connection's overlay.
|
|
924
|
+
const rowSource = rows ?? (state ? this.issuerEffectiveRows(db, schemaName, tableName, state.underlyingTable) : undefined);
|
|
509
925
|
if (state?.underlyingTable.createIndex) {
|
|
510
926
|
// Instance-level createIndex keeps MemoryTable.tableSchema fresh
|
|
511
|
-
await state.underlyingTable.createIndex(indexSchema);
|
|
927
|
+
await state.underlyingTable.createIndex(indexSchema, rowSource);
|
|
512
928
|
}
|
|
513
929
|
else if (this.underlying.createIndex) {
|
|
514
|
-
await this.underlying.createIndex(db, schemaName, tableName, indexSchema);
|
|
930
|
+
await this.underlying.createIndex(db, schemaName, tableName, indexSchema, rowSource);
|
|
931
|
+
}
|
|
932
|
+
else {
|
|
933
|
+
return; // underlying does not support indexes; nothing was created, nothing to rebuild
|
|
515
934
|
}
|
|
935
|
+
if (!state)
|
|
936
|
+
return;
|
|
937
|
+
const updatedSchema = this.assertIndexPresent(state.underlyingTable, schemaName, tableName, indexSchema.name);
|
|
938
|
+
await this.rebuildOverlaysForIndexChange(db, schemaName, tableName, updatedSchema, `create index '${indexSchema.name}'`);
|
|
939
|
+
}
|
|
940
|
+
/**
|
|
941
|
+
* Reads back the underlying table instance's post-`createIndex` schema, asserting it now
|
|
942
|
+
* carries the new index.
|
|
943
|
+
*
|
|
944
|
+
* Both bundled underlyings refresh the instance's cached `tableSchema` (memory through
|
|
945
|
+
* `MemoryTable.createIndex`, the store through `StoreTable.updateSchema`), and the overlay
|
|
946
|
+
* rebuild below copies its index/constraint set from it. A third-party underlying that
|
|
947
|
+
* refreshed only its module-level schema would silently rebuild overlays under the PRE-index
|
|
948
|
+
* schema, re-opening the very hole this method exists to close — so assert rather than assume.
|
|
949
|
+
*/
|
|
950
|
+
assertIndexPresent(underlyingTable, schemaName, tableName, indexName) {
|
|
951
|
+
const updatedSchema = underlyingTable.tableSchema;
|
|
952
|
+
const present = updatedSchema?.indexes?.some(idx => idx.name.toLowerCase() === indexName.toLowerCase());
|
|
953
|
+
if (!updatedSchema || !present) {
|
|
954
|
+
throw new QuereusError(`Isolation layer: underlying table '${schemaName}.${tableName}' did not refresh its cached tableSchema after `
|
|
955
|
+
+ `creating index '${indexName}'. The per-connection overlays cannot adopt an index the underlying does not `
|
|
956
|
+
+ `report; the underlying module must refresh VirtualTable.tableSchema in createIndex.`, StatusCode.INTERNAL);
|
|
957
|
+
}
|
|
958
|
+
return updatedSchema;
|
|
516
959
|
}
|
|
517
960
|
/**
|
|
518
961
|
* Drops an index on the underlying table.
|
|
@@ -549,35 +992,54 @@ export class IsolationModule {
|
|
|
549
992
|
const updatedSchema = state?.underlyingTable.tableSchema;
|
|
550
993
|
if (!updatedSchema)
|
|
551
994
|
return;
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
995
|
+
await this.rebuildOverlaysForIndexChange(db, schemaName, tableName, updatedSchema, `drop index '${indexName}'`);
|
|
996
|
+
}
|
|
997
|
+
/**
|
|
998
|
+
* Rebuilds every non-poisoned per-connection overlay of one table under a schema whose
|
|
999
|
+
* index / constraint set just changed (CREATE INDEX or DROP INDEX). Shared because the two
|
|
1000
|
+
* directions differ only in which structures the new schema carries — the column layout is
|
|
1001
|
+
* identical either way, so staged rows copy verbatim.
|
|
1002
|
+
*
|
|
1003
|
+
* A poisoned overlay (from a cross-connection ALTER, or a DROP TABLE) holds rows in the
|
|
1004
|
+
* pre-alter column layout, narrower/wider than `updatedSchema`. Rebuilding it would copy
|
|
1005
|
+
* layout-mismatched rows AND drop the poison flag (the new state carries none), silently
|
|
1006
|
+
* un-poisoning a connection that must still roll back. Leave it as-is.
|
|
1007
|
+
*/
|
|
1008
|
+
async rebuildOverlaysForIndexChange(db, schemaName, tableName, updatedSchema, ddlDescription) {
|
|
1009
|
+
const ownKey = this.makeConnectionOverlayKey(db, schemaName, tableName);
|
|
1010
|
+
for (const key of this.connectionScopedKeys(this.connectionOverlays, schemaName, tableName)) {
|
|
1011
|
+
const overlayState = this.connectionOverlays.get(key);
|
|
1012
|
+
if (overlayState.poison)
|
|
1013
|
+
continue;
|
|
1014
|
+
await this.adoptRebuiltOverlay(key, overlayState, key === ownKey, schemaName, tableName, ddlDescription, () => this.rebuildOverlayForIndexChange(db, overlayState, updatedSchema));
|
|
564
1015
|
}
|
|
565
1016
|
}
|
|
566
1017
|
/**
|
|
567
|
-
* Rebuilds an overlay table under
|
|
568
|
-
*
|
|
569
|
-
*
|
|
1018
|
+
* Rebuilds an overlay table under a post-CREATE/DROP-INDEX schema, preserving staged rows
|
|
1019
|
+
* (including tombstones). Column layout is unchanged by either, so rows copy verbatim — but
|
|
1020
|
+
* the new overlay's index/constraint set is not, so an insert here can legitimately raise
|
|
1021
|
+
* UNIQUE (a foreign connection staged two rows the new index forbids).
|
|
570
1022
|
*/
|
|
571
|
-
async
|
|
1023
|
+
async rebuildOverlayForIndexChange(db, oldState, updatedSchema) {
|
|
572
1024
|
const oldOverlay = oldState.overlayTable;
|
|
573
1025
|
const newOverlaySchema = this.createOverlaySchema(updatedSchema);
|
|
574
1026
|
const newOverlayTable = await this.overlayModule.create(db, newOverlaySchema);
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
1027
|
+
const newState = { overlayTable: newOverlayTable, hasChanges: oldState.hasChanges, db };
|
|
1028
|
+
try {
|
|
1029
|
+
if (oldState.hasChanges && oldOverlay.query) {
|
|
1030
|
+
for await (const oldRow of oldOverlay.query(makeFullScanFilterInfo())) {
|
|
1031
|
+
await this.insertIntoRebuiltOverlay(newOverlayTable, oldRow, updatedSchema.name);
|
|
1032
|
+
}
|
|
578
1033
|
}
|
|
579
1034
|
}
|
|
580
|
-
|
|
1035
|
+
catch (e) {
|
|
1036
|
+
// A mid-copy throw (e.g. a UNIQUE the new index forbids) abandons this freshly
|
|
1037
|
+
// built overlay: adoptRebuiltOverlay keeps the OLD overlay installed on its throw
|
|
1038
|
+
// path and never sees this handle, so free it here or it leaks.
|
|
1039
|
+
await this.releaseOverlayTable(newState);
|
|
1040
|
+
throw e;
|
|
1041
|
+
}
|
|
1042
|
+
return newState;
|
|
581
1043
|
}
|
|
582
1044
|
/**
|
|
583
1045
|
* Delegates ALTER TABLE to the underlying module and migrates any per-connection
|
|
@@ -600,8 +1062,13 @@ export class IsolationModule {
|
|
|
600
1062
|
* ALTER either fails clean or fully applies — base/catalog can no longer diverge.
|
|
601
1063
|
* This mirrors the engine's pre-mutation `validateNotNullBackfill` in
|
|
602
1064
|
* `runtime/emit/alter-table.ts`.
|
|
1065
|
+
*
|
|
1066
|
+
* **Row-content validation.** The row-validating arms (`add constraint … unique`,
|
|
1067
|
+
* `alter column … set collate`) judge the ISSUING connection's effective rows, not the
|
|
1068
|
+
* underlying's committed ones — see {@link issuerEffectiveRows}. The underlying runs that
|
|
1069
|
+
* check before it mutates anything, so the atomic-abort guarantee above still holds.
|
|
603
1070
|
*/
|
|
604
|
-
async alterTable(db, schemaName, tableName, change) {
|
|
1071
|
+
async alterTable(db, schemaName, tableName, change, rows) {
|
|
605
1072
|
if (!this.underlying.alterTable) {
|
|
606
1073
|
throw new QuereusError(`Underlying module does not support ALTER TABLE for '${schemaName}.${tableName}'`, StatusCode.UNSUPPORTED);
|
|
607
1074
|
}
|
|
@@ -612,13 +1079,11 @@ export class IsolationModule {
|
|
|
612
1079
|
// left for its owning connection to error on, while the issuer's ALTER proceeds.
|
|
613
1080
|
// Already-poisoned overlays (own or foreign) are skipped entirely: they hold rows
|
|
614
1081
|
// from before an earlier ALTER, stay poisoned, and must not be re-read/migrated.
|
|
615
|
-
const suffix = `:${schemaName}.${tableName}`.toLowerCase();
|
|
616
1082
|
const ownKey = this.makeConnectionOverlayKey(db, schemaName, tableName);
|
|
617
1083
|
let ownEntry;
|
|
618
1084
|
const foreign = [];
|
|
619
|
-
for (const
|
|
620
|
-
|
|
621
|
-
continue;
|
|
1085
|
+
for (const key of this.connectionScopedKeys(this.connectionOverlays, schemaName, tableName)) {
|
|
1086
|
+
const state = this.connectionOverlays.get(key);
|
|
622
1087
|
// An already-poisoned overlay (from an earlier ALTER) holds pre-alter rows and must
|
|
623
1088
|
// never be re-read or migrated — checked BEFORE the ownKey split so the poisoned
|
|
624
1089
|
// connection's OWN later ALTER cannot route its overlay through migration, which
|
|
@@ -649,38 +1114,53 @@ export class IsolationModule {
|
|
|
649
1114
|
// schema needed — so it is valid here, before the underlying is mutated, and the
|
|
650
1115
|
// same context drives the post-mutation migration.
|
|
651
1116
|
const addColumnCtx = this.deriveAddColumnBackfill(change, db, tableName);
|
|
1117
|
+
// Build the setNotNull backfill context (undefined unless this is a NOT NULL tightening
|
|
1118
|
+
// with overlays to migrate). The now-NOT-NULL column's index and folded DEFAULT are read
|
|
1119
|
+
// from a to-be-migrated overlay's PRE-alter schema — the same layout every migrated overlay
|
|
1120
|
+
// shares, and the same source `dropColumnIdx` uses above.
|
|
1121
|
+
const setNotNullCtx = this.deriveSetNotNullBackfill(change, toMigrate, tableName);
|
|
652
1122
|
// Tier 2: validate the ISSUER's own overlay BEFORE mutating the shared underlying.
|
|
653
1123
|
// Any throw here (CONSTRAINT backfill or INTERNAL tombstone guard) propagates while
|
|
654
1124
|
// underlying + catalog + every overlay are still untouched — the companion ticket's
|
|
655
1125
|
// atomic-abort guarantee, preserved unchanged for the issuer.
|
|
656
1126
|
if (ownEntry) {
|
|
657
|
-
await this.validateOverlayMigration(ownEntry[1], addColumnCtx);
|
|
1127
|
+
await this.validateOverlayMigration(ownEntry[1], addColumnCtx, setNotNullCtx);
|
|
658
1128
|
}
|
|
659
|
-
const
|
|
1129
|
+
const underlyingState = this.getUnderlyingState(schemaName, tableName);
|
|
1130
|
+
// Hand the underlying the issuer's effective rows so its own row-content checks
|
|
1131
|
+
// (`add constraint … unique`, `alter column … set collate`) see the transaction's
|
|
1132
|
+
// pending rows and skip the ones it has deleted. An outer wrapper's source wins if
|
|
1133
|
+
// one was supplied.
|
|
1134
|
+
const rowSource = rows ?? (underlyingState
|
|
1135
|
+
? this.issuerEffectiveRows(db, schemaName, tableName, underlyingState.underlyingTable)
|
|
1136
|
+
: undefined);
|
|
1137
|
+
const updated = await this.underlying.alterTable(db, schemaName, tableName, change, rowSource);
|
|
660
1138
|
// The cached underlying VirtualTable's `tableSchema` is a construction-time
|
|
661
1139
|
// snapshot (e.g. MemoryTable.tableSchema); module-level alterTable rotates the
|
|
662
1140
|
// underlying manager's schema but not this instance's field. Refresh it so a
|
|
663
1141
|
// freshly-connected IsolatedTable's merged-view UNIQUE check (which reads
|
|
664
1142
|
// this.tableSchema.uniqueConstraints / per-column collation) sees the post-alter
|
|
665
1143
|
// constraint set. Mirrors the implicit instance refresh dropIndex already gets.
|
|
666
|
-
const underlyingState = this.getUnderlyingState(schemaName, tableName);
|
|
667
1144
|
if (underlyingState)
|
|
668
1145
|
underlyingState.underlyingTable.tableSchema = updated;
|
|
1146
|
+
const ddlDescription = `alter table (${change.type})`;
|
|
669
1147
|
// Migrate the issuer's own overlay (already validated above). Its NOT NULL /
|
|
670
|
-
// tombstone throw sites are unreachable after pre-validation
|
|
1148
|
+
// tombstone throw sites are unreachable after pre-validation; so is a UNIQUE
|
|
1149
|
+
// rejection, which `rowSource` already judged — {@link adoptRebuiltOverlay} raises
|
|
1150
|
+
// INTERNAL if one fires anyway rather than dropping the row.
|
|
671
1151
|
if (ownEntry) {
|
|
672
|
-
|
|
673
|
-
this.connectionOverlays.set(ownEntry[0], newState);
|
|
1152
|
+
await this.adoptRebuiltOverlay(ownEntry[0], ownEntry[1], true, schemaName, tableName, ddlDescription, () => this.migrateOverlayForAlter(db, ownEntry[1], updated, change, dropColumnIdx, addColumnCtx, setNotNullCtx));
|
|
674
1153
|
}
|
|
675
1154
|
// Tier 3: per FOREIGN overlay, validate then migrate — but a per-row NOT NULL
|
|
676
1155
|
// (CONSTRAINT) failure poisons that one overlay instead of aborting the issuer's
|
|
677
|
-
// ALTER
|
|
678
|
-
//
|
|
679
|
-
//
|
|
680
|
-
//
|
|
1156
|
+
// ALTER, as does a UNIQUE the migration itself raises (its staged rows may violate a
|
|
1157
|
+
// constraint the issuer just declared). An INTERNAL failure (e.g. missing tombstone
|
|
1158
|
+
// column) is a layer-invariant violation, not a data condition, so it rethrows loud
|
|
1159
|
+
// for everyone. Both phases run per overlay, so one bad foreign overlay poisons only
|
|
1160
|
+
// itself; healthy peers still migrate.
|
|
681
1161
|
for (const [key, oldState] of foreign) {
|
|
682
1162
|
try {
|
|
683
|
-
await this.validateOverlayMigration(oldState, addColumnCtx);
|
|
1163
|
+
await this.validateOverlayMigration(oldState, addColumnCtx, setNotNullCtx);
|
|
684
1164
|
}
|
|
685
1165
|
catch (e) {
|
|
686
1166
|
if (e instanceof QuereusError && e.code === StatusCode.CONSTRAINT) {
|
|
@@ -689,8 +1169,7 @@ export class IsolationModule {
|
|
|
689
1169
|
}
|
|
690
1170
|
throw e;
|
|
691
1171
|
}
|
|
692
|
-
|
|
693
|
-
this.connectionOverlays.set(key, newState);
|
|
1172
|
+
await this.adoptRebuiltOverlay(key, oldState, false, schemaName, tableName, ddlDescription, () => this.migrateOverlayForAlter(db, oldState, updated, change, dropColumnIdx, addColumnCtx, setNotNullCtx));
|
|
694
1173
|
}
|
|
695
1174
|
return updated;
|
|
696
1175
|
}
|
|
@@ -698,13 +1177,19 @@ export class IsolationModule {
|
|
|
698
1177
|
* Builds the poison message stamped onto a foreign overlay whose backfill could not
|
|
699
1178
|
* satisfy a cross-connection ALTER (see {@link alterTable} tier 3). Names the
|
|
700
1179
|
* schema.table and the offending column so the owning connection's eventual
|
|
701
|
-
* read/write/commit error is self-explanatory. Poison
|
|
702
|
-
*
|
|
703
|
-
* types never reach here but
|
|
1180
|
+
* read/write/commit error is self-explanatory. Poison arises on the addColumn NOT NULL
|
|
1181
|
+
* path (a new NOT-NULL column with no usable default) and the `set not null` tightening
|
|
1182
|
+
* path (a staged NULL with no usable default); other change types never reach here but
|
|
1183
|
+
* are handled defensively.
|
|
704
1184
|
*/
|
|
705
1185
|
buildAlterPoisonMessage(schemaName, tableName, change) {
|
|
706
|
-
|
|
707
|
-
|
|
1186
|
+
if (change.type === 'addColumn') {
|
|
1187
|
+
return `ALTER on '${schemaName}.${tableName}' added column '${change.columnDef.name}' (NOT NULL) that this connection's uncommitted row cannot satisfy; roll back this transaction.`;
|
|
1188
|
+
}
|
|
1189
|
+
if (change.type === 'alterColumn') {
|
|
1190
|
+
return `ALTER on '${schemaName}.${tableName}' tightened column '${change.columnName}' to NOT NULL, which this connection's uncommitted row violates; roll back this transaction.`;
|
|
1191
|
+
}
|
|
1192
|
+
return `ALTER on '${schemaName}.${tableName}' cannot migrate this connection's uncommitted rows; roll back this transaction.`;
|
|
708
1193
|
}
|
|
709
1194
|
/**
|
|
710
1195
|
* Renames a table through the isolation layer.
|
|
@@ -717,69 +1202,152 @@ export class IsolationModule {
|
|
|
717
1202
|
* Done in this order so a failure in the underlying rename leaves our
|
|
718
1203
|
* internal maps untouched (the engine will not update the schema catalog
|
|
719
1204
|
* if this method throws).
|
|
1205
|
+
*
|
|
1206
|
+
* **Why the underlying is re-connected, not re-keyed.** A rename mid-transaction
|
|
1207
|
+
* moves any staged overlay onto the new name, and `commitConnectionOverlays`
|
|
1208
|
+
* resolves an overlay's underlying by that name. Simply re-keying
|
|
1209
|
+
* `underlyingTables` old→new would be cheaper, but the cached `VirtualTable` may
|
|
1210
|
+
* be dead: `StoreModule.renameTable` closes and re-opens the store, so the stale
|
|
1211
|
+
* handle yields "store is closed". So we evict it and, when an overlay was
|
|
1212
|
+
* carried onto the new name, immediately connect a fresh underlying under that
|
|
1213
|
+
* name — otherwise the transaction commits against a table that is in neither
|
|
1214
|
+
* map, and the overlay's rows vanish (see {@link commitConnectionOverlays}'s
|
|
1215
|
+
* invariant). With no overlay carried over there is nothing to flush, so the
|
|
1216
|
+
* eviction alone is enough and the next `connect()` re-resolves lazily.
|
|
720
1217
|
*/
|
|
721
1218
|
async renameTable(db, schemaName, oldName, newName) {
|
|
1219
|
+
// Read the catalog entry BEFORE anything mutates: `runtime/emit/alter-table.ts`
|
|
1220
|
+
// calls this hook ahead of the catalog swap, so the table is still registered
|
|
1221
|
+
// under `oldName` here. It carries the vtab module name / args that
|
|
1222
|
+
// `reconnectUnderlyingAfterRename` needs — the hook's own signature has neither.
|
|
1223
|
+
const preRenameSchema = db.schemaManager.getTable(schemaName, oldName);
|
|
722
1224
|
if (this.underlying.renameTable) {
|
|
723
1225
|
await this.underlying.renameTable(db, schemaName, oldName, newName);
|
|
724
1226
|
}
|
|
725
1227
|
// Drop our cached underlying VirtualTable for the old name. It may have
|
|
726
1228
|
// been disconnected by the underlying module (e.g. StoreModule closes
|
|
727
1229
|
// and re-opens stores during rename), so reusing it would yield "store
|
|
728
|
-
// is closed" errors.
|
|
729
|
-
// fresh underlying table from the underlying module.
|
|
1230
|
+
// is closed" errors.
|
|
730
1231
|
this.removeUnderlyingState(schemaName, oldName);
|
|
731
|
-
// Re-key per-connection overlay
|
|
732
|
-
//
|
|
733
|
-
//
|
|
734
|
-
this.rekeyConnectionScopedMap(this.connectionOverlays, schemaName, oldName, newName);
|
|
735
|
-
|
|
1232
|
+
// Re-key per-connection overlay state, preserving the connection-id prefix so
|
|
1233
|
+
// overlays created earlier in an open transaction remain visible under the new
|
|
1234
|
+
// name — the commit flush resolves an overlay's underlying by current name.
|
|
1235
|
+
const movedOverlays = this.rekeyConnectionScopedMap(this.connectionOverlays, schemaName, oldName, newName);
|
|
1236
|
+
// `preOverlaySavepoints` is deliberately NOT re-keyed. The set's own maintainers —
|
|
1237
|
+
// the savepoint/commit/rollback callbacks on the already-registered
|
|
1238
|
+
// IsolatedConnection — resolve the name the IsolatedTable was constructed with,
|
|
1239
|
+
// which stays `oldName` for the life of the transaction. Moving the set to
|
|
1240
|
+
// `newName` would strand it: the old-name instance clears a key that no longer
|
|
1241
|
+
// exists and the moved set survives into the next transaction, where a matching
|
|
1242
|
+
// `rollback to savepoint` depth would wrongly discard the whole overlay.
|
|
1243
|
+
// Nothing needs carrying over: the first statement after the rename connects a
|
|
1244
|
+
// fresh IsolatedTable under `newName`, whose ensureConnection() registers a new
|
|
1245
|
+
// IsolatedConnection, and `Database.registerConnection` replays the active
|
|
1246
|
+
// savepoint stack onto it. If no overlay was carried across, that replay rebuilds
|
|
1247
|
+
// the depth set under the new name from scratch; if one was, the replay adds
|
|
1248
|
+
// nothing (the depths no longer pre-date an overlay) and the overlay's own
|
|
1249
|
+
// registered connection already holds a snapshot per active depth, taken when
|
|
1250
|
+
// `ensureOverlay` pre-registered it.
|
|
1251
|
+
if (movedOverlays > 0) {
|
|
1252
|
+
await this.reconnectUnderlyingAfterRename(db, schemaName, newName, preRenameSchema);
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
/**
|
|
1256
|
+
* Forward the engine's post-propagation rename finalize to the underlying (see
|
|
1257
|
+
* {@link renameTable} for the two-phase split). The underlying (e.g. `StoreModule`)
|
|
1258
|
+
* uses it to drop the old name's catalog entry only after the cross-table rewrites
|
|
1259
|
+
* `propagateTableRename` enqueued are durable. `IsolationModule` owns no persistent
|
|
1260
|
+
* catalog of its own and already evicted every old-name state in `renameTable`, so
|
|
1261
|
+
* it simply delegates.
|
|
1262
|
+
*/
|
|
1263
|
+
async finalizeRename(db, schemaName, oldName, newName) {
|
|
1264
|
+
await this.underlying.finalizeRename?.(db, schemaName, oldName, newName);
|
|
1265
|
+
}
|
|
1266
|
+
/**
|
|
1267
|
+
* Connects a fresh underlying table under the post-rename name and records it in
|
|
1268
|
+
* `underlyingTables`, restoring the "every staged overlay resolves to an underlying"
|
|
1269
|
+
* invariant that {@link renameTable}'s eviction would otherwise break.
|
|
1270
|
+
*
|
|
1271
|
+
* `preRenameSchema` is the catalog's pre-rename `TableSchema`; it is cloned under the
|
|
1272
|
+
* new name so the underlying module sees the same column layout, PK, and vtab args it
|
|
1273
|
+
* was created with.
|
|
1274
|
+
*
|
|
1275
|
+
* NOTE: `pAux` is passed as `undefined`: the aux data the engine hands
|
|
1276
|
+
* `IsolationModule.connect()` belongs to *this* wrapper's registration, not the
|
|
1277
|
+
* underlying's, and both bundled underlyings (`MemoryTableModule`, `StoreModule`)
|
|
1278
|
+
* ignore the parameter — the same assumption `connect()` already relies on when it
|
|
1279
|
+
* forwards its own caller's `pAux` straight through. If a third-party underlying ever
|
|
1280
|
+
* reads `pAux` in `connect()`, `IsolationModule` must capture the underlying's own aux
|
|
1281
|
+
* data at registration and hand it back here.
|
|
1282
|
+
*/
|
|
1283
|
+
async reconnectUnderlyingAfterRename(db, schemaName, newName, preRenameSchema) {
|
|
1284
|
+
if (!preRenameSchema) {
|
|
1285
|
+
throw new QuereusError(`Isolation layer: cannot re-resolve underlying table for renamed '${schemaName}.${newName}' — `
|
|
1286
|
+
+ `no catalog entry for the pre-rename name, and a staged overlay depends on it.`, StatusCode.INTERNAL);
|
|
1287
|
+
}
|
|
1288
|
+
const renamedSchema = { ...preRenameSchema, name: newName };
|
|
1289
|
+
const underlyingTable = await this.underlying.connect(db, undefined, preRenameSchema.vtabModuleName, schemaName, newName, preRenameSchema.vtabArgs ?? {}, renamedSchema);
|
|
1290
|
+
this.setUnderlyingState(schemaName, newName, { underlyingTable });
|
|
736
1291
|
}
|
|
737
1292
|
/**
|
|
738
1293
|
* Re-keys all entries of a connection-scoped map (`<dbId>:<schema>.<table>`)
|
|
739
1294
|
* from oldName to newName, leaving entries for other tables untouched.
|
|
1295
|
+
* Returns how many entries moved.
|
|
740
1296
|
*/
|
|
741
1297
|
rekeyConnectionScopedMap(map, schemaName, oldName, newName) {
|
|
742
|
-
|
|
1298
|
+
// Length of the LOWERCASED suffix: keys are stored lowercased, and case folding is
|
|
1299
|
+
// not always length-preserving (`'İ'.toLowerCase()` is two code units).
|
|
1300
|
+
const oldSuffixLength = `:${schemaName}.${oldName}`.toLowerCase().length;
|
|
743
1301
|
const newSuffix = `:${schemaName}.${newName}`.toLowerCase();
|
|
744
|
-
const
|
|
745
|
-
for (const
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
map.delete(key);
|
|
750
|
-
}
|
|
751
|
-
}
|
|
752
|
-
for (const [newKey, value] of moved) {
|
|
753
|
-
map.set(newKey, value);
|
|
1302
|
+
const oldKeys = this.connectionScopedKeys(map, schemaName, oldName);
|
|
1303
|
+
for (const oldKey of oldKeys) {
|
|
1304
|
+
const value = map.get(oldKey);
|
|
1305
|
+
map.delete(oldKey);
|
|
1306
|
+
map.set(`${oldKey.substring(0, oldKey.length - oldSuffixLength)}${newSuffix}`, value);
|
|
754
1307
|
}
|
|
1308
|
+
return oldKeys.length;
|
|
755
1309
|
}
|
|
756
1310
|
/**
|
|
757
1311
|
* Rebuilds an overlay table under the post-alter schema, translating each
|
|
758
1312
|
* staged row to the new column layout.
|
|
1313
|
+
*
|
|
1314
|
+
* A row the post-alter schema's constraints reject throws CONSTRAINT out of
|
|
1315
|
+
* {@link insertIntoRebuiltOverlay} rather than being silently discarded; the caller
|
|
1316
|
+
* ({@link adoptRebuiltOverlay}) maps that to INTERNAL or poison.
|
|
759
1317
|
*/
|
|
760
|
-
async migrateOverlayForAlter(db, oldState, updatedSchema, change, dropColumnIdx, addColumnCtx) {
|
|
1318
|
+
async migrateOverlayForAlter(db, oldState, updatedSchema, change, dropColumnIdx, addColumnCtx, setNotNullCtx) {
|
|
761
1319
|
const oldOverlay = oldState.overlayTable;
|
|
762
1320
|
const oldOverlaySchema = oldOverlay.tableSchema;
|
|
763
1321
|
const newOverlaySchema = this.createOverlaySchema(updatedSchema);
|
|
764
1322
|
const newOverlayTable = await this.overlayModule.create(db, newOverlaySchema);
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
if (
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
1323
|
+
const newState = { overlayTable: newOverlayTable, hasChanges: oldState.hasChanges, db };
|
|
1324
|
+
try {
|
|
1325
|
+
if (oldState.hasChanges && oldOverlaySchema && oldOverlay.query) {
|
|
1326
|
+
const oldTombstoneIdx = oldOverlaySchema.columnIndexMap.get(this.tombstoneColumn.toLowerCase());
|
|
1327
|
+
if (oldTombstoneIdx === undefined) {
|
|
1328
|
+
throw new QuereusError(`Tombstone column '${this.tombstoneColumn}' missing from overlay schema`, StatusCode.INTERNAL);
|
|
1329
|
+
}
|
|
1330
|
+
// `addColumnCtx` (folded literal default, the per-row evaluator, and the new
|
|
1331
|
+
// column's NOT NULL flag) was precomputed once per ALTER by the caller and already
|
|
1332
|
+
// dry-run validated against these same staged rows; undefined for non-addColumn
|
|
1333
|
+
// change types, which append nothing to staged rows.
|
|
1334
|
+
for await (const oldRow of oldOverlay.query(makeFullScanFilterInfo())) {
|
|
1335
|
+
const addColumnValue = addColumnCtx
|
|
1336
|
+
? await this.computeAddColumnValue(addColumnCtx, oldRow, oldTombstoneIdx)
|
|
1337
|
+
: undefined;
|
|
1338
|
+
const newRow = this.translateOverlayRow(oldRow, oldTombstoneIdx, change, dropColumnIdx, addColumnValue, setNotNullCtx);
|
|
1339
|
+
await this.insertIntoRebuiltOverlay(newOverlayTable, newRow, updatedSchema.name);
|
|
1340
|
+
}
|
|
780
1341
|
}
|
|
781
1342
|
}
|
|
782
|
-
|
|
1343
|
+
catch (e) {
|
|
1344
|
+
// Any throw after create abandons this freshly built overlay (adoptRebuiltOverlay
|
|
1345
|
+
// keeps the OLD one installed on its throw path and never holds this handle), so
|
|
1346
|
+
// free it here or it leaks.
|
|
1347
|
+
await this.releaseOverlayTable(newState);
|
|
1348
|
+
throw e;
|
|
1349
|
+
}
|
|
1350
|
+
return newState;
|
|
783
1351
|
}
|
|
784
1352
|
/**
|
|
785
1353
|
* Precomputes the per-ALTER constants an `addColumn` overlay backfill needs:
|
|
@@ -808,7 +1376,7 @@ export class IsolationModule {
|
|
|
808
1376
|
// ADD COLUMN sites. This site only reads `.notNull`/`.name` off the result (the
|
|
809
1377
|
// underlying materializes the real column), so it does not affect collation here — but
|
|
810
1378
|
// keeping the call signature identical avoids drift and is correct for any future reader.
|
|
811
|
-
const newColumn = columnDefToSchema(change.columnDef, defaultNotNull, db.options.getStringOption('default_collation'));
|
|
1379
|
+
const newColumn = columnDefToSchema(change.columnDef, defaultNotNull, db.options.getStringOption('default_collation'), (n) => db.isCollationRegistered(n));
|
|
812
1380
|
return {
|
|
813
1381
|
foldedDefault,
|
|
814
1382
|
evaluator: change.backfillEvaluator,
|
|
@@ -817,6 +1385,43 @@ export class IsolationModule {
|
|
|
817
1385
|
tableName,
|
|
818
1386
|
};
|
|
819
1387
|
}
|
|
1388
|
+
/**
|
|
1389
|
+
* Precomputes the per-ALTER constants an `alter column … set not null` overlay migration needs:
|
|
1390
|
+
* the now-NOT-NULL column's index and the folded literal DEFAULT (with `hasDefault` gating
|
|
1391
|
+
* backfill vs reject). Returns undefined unless this is a NOT NULL *tightening*
|
|
1392
|
+
* (`setNotNull: true`) with at least one overlay to migrate — a DROP NOT NULL loosens and
|
|
1393
|
+
* needs no staged-row work, and with no overlays there is nothing to backfill or reject.
|
|
1394
|
+
*
|
|
1395
|
+
* `change` for `set not null` carries no default expression, so the DEFAULT is read from the
|
|
1396
|
+
* column's PRE-alter schema (via a to-be-migrated overlay — the same source `dropColumnIdx`
|
|
1397
|
+
* uses, and the same layout every migrated overlay shares). Folded exactly as
|
|
1398
|
+
* {@link deriveAddColumnBackfill} folds its DEFAULT, so backfill and reject decisions here
|
|
1399
|
+
* cannot drift from what the underlying enforces over its committed rows.
|
|
1400
|
+
*/
|
|
1401
|
+
deriveSetNotNullBackfill(change, toMigrate, tableName) {
|
|
1402
|
+
if (change.type !== 'alterColumn' || change.setNotNull !== true)
|
|
1403
|
+
return undefined;
|
|
1404
|
+
if (toMigrate.length === 0)
|
|
1405
|
+
return undefined;
|
|
1406
|
+
const overlaySchema = toMigrate[0][1].overlayTable.tableSchema;
|
|
1407
|
+
if (!overlaySchema)
|
|
1408
|
+
return undefined;
|
|
1409
|
+
const colIndex = overlaySchema.columnIndexMap.get(change.columnName.toLowerCase());
|
|
1410
|
+
if (colIndex === undefined)
|
|
1411
|
+
return undefined;
|
|
1412
|
+
const defaultExpr = overlaySchema.columns[colIndex]?.defaultValue;
|
|
1413
|
+
// tryFoldLiteral returns undefined for a non-foldable expr and null for one that folds to
|
|
1414
|
+
// NULL; both mean "no usable literal default" — the staged NULL must reject, not backfill.
|
|
1415
|
+
const folded = defaultExpr ? tryFoldLiteral(defaultExpr) : undefined;
|
|
1416
|
+
const hasDefault = folded !== undefined && folded !== null;
|
|
1417
|
+
return {
|
|
1418
|
+
colIndex,
|
|
1419
|
+
foldedDefault: hasDefault ? folded : null,
|
|
1420
|
+
hasDefault,
|
|
1421
|
+
colName: change.columnName,
|
|
1422
|
+
tableName,
|
|
1423
|
+
};
|
|
1424
|
+
}
|
|
820
1425
|
/**
|
|
821
1426
|
* Dry-runs an overlay's ALTER migration-fallible work without mutating anything,
|
|
822
1427
|
* so the caller can run it for every affected overlay BEFORE the irreversible
|
|
@@ -831,10 +1436,17 @@ export class IsolationModule {
|
|
|
831
1436
|
* rows short-circuit to `null` (the evaluator never runs), and a NOT-NULL-violating
|
|
832
1437
|
* evaluated row throws CONSTRAINT here, atomically. Computed values are discarded.
|
|
833
1438
|
*
|
|
834
|
-
*
|
|
835
|
-
*
|
|
1439
|
+
* For `set not null` with NO usable DEFAULT (`setNotNullCtx.hasDefault === false`), a staged
|
|
1440
|
+
* non-tombstone NULL at the now-NOT-NULL column throws CONSTRAINT here — for the issuer this
|
|
1441
|
+
* aborts atomically before the underlying mutates; for a foreign overlay the caller maps it to
|
|
1442
|
+
* poison. With a usable DEFAULT the staged NULLs are backfilled by {@link translateOverlayRow},
|
|
1443
|
+
* so nothing is rejected here.
|
|
1444
|
+
*
|
|
1445
|
+
* Non-addColumn / non-tightening changes (`addColumnCtx === undefined` and no reject-mode
|
|
1446
|
+
* `setNotNullCtx`) only run the tombstone guard; their row translation appends/removes nothing
|
|
1447
|
+
* fallible on data grounds.
|
|
836
1448
|
*/
|
|
837
|
-
async validateOverlayMigration(oldState, addColumnCtx) {
|
|
1449
|
+
async validateOverlayMigration(oldState, addColumnCtx, setNotNullCtx) {
|
|
838
1450
|
const oldOverlay = oldState.overlayTable;
|
|
839
1451
|
const oldOverlaySchema = oldOverlay.tableSchema;
|
|
840
1452
|
// Mirror the migrate-loop guard exactly: a clean overlay or one without a queryable
|
|
@@ -845,11 +1457,23 @@ export class IsolationModule {
|
|
|
845
1457
|
if (oldTombstoneIdx === undefined) {
|
|
846
1458
|
throw new QuereusError(`Tombstone column '${this.tombstoneColumn}' missing from overlay schema`, StatusCode.INTERNAL);
|
|
847
1459
|
}
|
|
848
|
-
if (
|
|
1460
|
+
if (addColumnCtx) {
|
|
1461
|
+
for await (const oldRow of oldOverlay.query(makeFullScanFilterInfo())) {
|
|
1462
|
+
// Discard the result — this is validation only. A NOT NULL violation throws here.
|
|
1463
|
+
await this.computeAddColumnValue(addColumnCtx, oldRow, oldTombstoneIdx);
|
|
1464
|
+
}
|
|
849
1465
|
return;
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
1466
|
+
}
|
|
1467
|
+
// SET NOT NULL with no usable DEFAULT: reject a staged NULL the migration could not fill.
|
|
1468
|
+
// (With a DEFAULT there is nothing to reject — translateOverlayRow backfills instead.)
|
|
1469
|
+
if (setNotNullCtx && !setNotNullCtx.hasDefault) {
|
|
1470
|
+
for await (const oldRow of oldOverlay.query(makeFullScanFilterInfo())) {
|
|
1471
|
+
if (oldRow[oldTombstoneIdx] === 1)
|
|
1472
|
+
continue; // tombstone: placeholder NULLs, not a row
|
|
1473
|
+
if (oldRow[setNotNullCtx.colIndex] === null) {
|
|
1474
|
+
throw new QuereusError(`column ${setNotNullCtx.colName} contains NULL values`, StatusCode.CONSTRAINT);
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
853
1477
|
}
|
|
854
1478
|
}
|
|
855
1479
|
/**
|
|
@@ -885,8 +1509,13 @@ export class IsolationModule {
|
|
|
885
1509
|
* `addColumnValue` is the per-row value the caller computed for an `addColumn`
|
|
886
1510
|
* (via {@link computeAddColumnValue}); it is `undefined` for every other change
|
|
887
1511
|
* type. Keeping the (async) backfill in the caller's loop lets this stay synchronous.
|
|
1512
|
+
*
|
|
1513
|
+
* `setNotNullCtx` (present only for a `set not null` tightening WITH a usable DEFAULT) maps a
|
|
1514
|
+
* staged NULL at the now-NOT-NULL column to that DEFAULT — filling the issuer's own pending
|
|
1515
|
+
* rows, which the underlying's committed-row backfill never touches. Reject-mode contexts (no
|
|
1516
|
+
* DEFAULT) never reach here: {@link validateOverlayMigration} aborts/poisons first.
|
|
888
1517
|
*/
|
|
889
|
-
translateOverlayRow(oldRow, oldTombstoneIdx, change, dropColumnIdx, addColumnValue) {
|
|
1518
|
+
translateOverlayRow(oldRow, oldTombstoneIdx, change, dropColumnIdx, addColumnValue, setNotNullCtx) {
|
|
890
1519
|
const tombstoneValue = oldRow[oldTombstoneIdx];
|
|
891
1520
|
const data = Array.from(oldRow.slice(0, oldTombstoneIdx));
|
|
892
1521
|
let newData;
|
|
@@ -901,8 +1530,17 @@ export class IsolationModule {
|
|
|
901
1530
|
? [...data.slice(0, dropColumnIdx), ...data.slice(dropColumnIdx + 1)]
|
|
902
1531
|
: data;
|
|
903
1532
|
break;
|
|
904
|
-
case 'renameColumn':
|
|
905
1533
|
case 'alterColumn':
|
|
1534
|
+
// SET NOT NULL backfill (WITH a usable DEFAULT): fill a staged NULL at the
|
|
1535
|
+
// now-NOT-NULL column. Tombstones carry placeholder NULLs never read, so leave them.
|
|
1536
|
+
// Every other alterColumn attribute (and the no-DEFAULT reject case) leaves data as-is.
|
|
1537
|
+
// NOTE: SET DATA TYPE also alters existing values but is NOT converted here — its
|
|
1538
|
+
// overlay gap is deferred (see SetNotNullBackfillContext doc).
|
|
1539
|
+
newData = (setNotNullCtx && tombstoneValue !== 1)
|
|
1540
|
+
? data.map((v, i) => (i === setNotNullCtx.colIndex && v === null ? setNotNullCtx.foldedDefault : v))
|
|
1541
|
+
: data;
|
|
1542
|
+
break;
|
|
1543
|
+
case 'renameColumn':
|
|
906
1544
|
case 'alterPrimaryKey':
|
|
907
1545
|
case 'addConstraint':
|
|
908
1546
|
case 'dropConstraint':
|
|
@@ -916,34 +1554,25 @@ export class IsolationModule {
|
|
|
916
1554
|
}
|
|
917
1555
|
return [...newData, tombstoneValue];
|
|
918
1556
|
}
|
|
919
|
-
/** Creates a FilterInfo for a full table scan (no constraints). */
|
|
920
|
-
makeFullScanFilterInfo() {
|
|
921
|
-
return {
|
|
922
|
-
idxNum: 0,
|
|
923
|
-
idxStr: null,
|
|
924
|
-
constraints: [],
|
|
925
|
-
args: [],
|
|
926
|
-
indexInfoOutput: {
|
|
927
|
-
nConstraint: 0,
|
|
928
|
-
aConstraint: [],
|
|
929
|
-
nOrderBy: 0,
|
|
930
|
-
aOrderBy: [],
|
|
931
|
-
colUsed: 0n,
|
|
932
|
-
aConstraintUsage: [],
|
|
933
|
-
idxNum: 0,
|
|
934
|
-
idxStr: null,
|
|
935
|
-
orderByConsumed: false,
|
|
936
|
-
estimatedCost: 1000000,
|
|
937
|
-
estimatedRows: 1000000n,
|
|
938
|
-
idxFlags: 0,
|
|
939
|
-
},
|
|
940
|
-
};
|
|
941
|
-
}
|
|
942
1557
|
/**
|
|
943
1558
|
* Creates overlay schema from underlying schema.
|
|
944
1559
|
* Adds tombstone column and uses unique name to avoid conflicts.
|
|
945
1560
|
*
|
|
946
|
-
* Called by IsolatedTable when lazily creating its overlay
|
|
1561
|
+
* Called by IsolatedTable when lazily creating its overlay, and by the two overlay-rebuild
|
|
1562
|
+
* paths (`rebuildOverlayForIndexChange`, `migrateOverlayForAlter`).
|
|
1563
|
+
*
|
|
1564
|
+
* Every copied secondary index — and every copied UNIQUE constraint, including the ones
|
|
1565
|
+
* a UNIQUE index derives — is narrowed to a PARTIAL structure over live rows only
|
|
1566
|
+
* (`<tombstone> = 0`), AND-ed onto whatever partial predicate it already carried. A
|
|
1567
|
+
* tombstone is a deletion marker, not a row, so no uniqueness rule may be evaluated over
|
|
1568
|
+
* it: it carries its row's PK and NULL everywhere else, so a UNIQUE structure whose
|
|
1569
|
+
* columns all sit inside the PK would otherwise see two deleted rows as duplicates.
|
|
1570
|
+
* (Non-PK unique columns escaped only because their tombstone key is NULL and SQL treats
|
|
1571
|
+
* NULLs as distinct.) The overlay's PRIMARY KEY uniqueness is NOT narrowed — it must keep
|
|
1572
|
+
* covering tombstones so a re-insert at a tombstoned PK is detected and converted.
|
|
1573
|
+
*
|
|
1574
|
+
* `IsolatedTable.mergedSecondaryIndexQuery` wants exactly the live overlay rows out of
|
|
1575
|
+
* these indexes, so narrowing them is what it already expects.
|
|
947
1576
|
*/
|
|
948
1577
|
createOverlaySchema(baseSchema) {
|
|
949
1578
|
const tombstoneColumn = {
|
|
@@ -964,14 +1593,70 @@ export class IsolationModule {
|
|
|
964
1593
|
newColumnIndexMap.set(this.tombstoneColumn.toLowerCase(), newColumns.length - 1);
|
|
965
1594
|
// Use unique ID to avoid conflicts when multiple overlays exist
|
|
966
1595
|
const overlayId = generateOverlayId();
|
|
1596
|
+
const overlayName = `_overlay_${baseSchema.name}_${overlayId}`;
|
|
1597
|
+
const liveOnly = this.liveRowPredicate();
|
|
1598
|
+
// A partial-index / UNIQUE predicate copied from the base carries a self-qualifier
|
|
1599
|
+
// bound to the base table's name (e.g. `where t.v > 0`). The overlay renames the table
|
|
1600
|
+
// to `overlayName`, so that qualifier now names a DIFFERENT table than the overlay's
|
|
1601
|
+
// MemoryIndex is scoped to — and `compilePredicate` rejects a foreign qualifier at
|
|
1602
|
+
// index-build time (see partial-index-predicate table-qualifier rejection). Rescope the
|
|
1603
|
+
// self-qualifier to the overlay name so it stays a self-reference. A foreign qualifier
|
|
1604
|
+
// cannot occur here: `compilePredicate` already rejected one when the base index/UNIQUE
|
|
1605
|
+
// was created, so every qualifier present is the base name.
|
|
1606
|
+
const rescope = (p) => p ? rescopePredicateQualifier(p, baseSchema.name, overlayName) : undefined;
|
|
967
1607
|
return {
|
|
968
1608
|
...baseSchema,
|
|
969
|
-
name:
|
|
1609
|
+
name: overlayName,
|
|
970
1610
|
columns: newColumns,
|
|
971
1611
|
columnIndexMap: newColumnIndexMap,
|
|
972
|
-
|
|
973
|
-
|
|
1612
|
+
indexes: baseSchema.indexes?.map(idx => ({ ...idx, predicate: andPredicate(rescope(idx.predicate), liveOnly) })),
|
|
1613
|
+
uniqueConstraints: baseSchema.uniqueConstraints?.map(uc => ({ ...uc, predicate: andPredicate(rescope(uc.predicate), liveOnly) })),
|
|
974
1614
|
};
|
|
975
1615
|
}
|
|
1616
|
+
/**
|
|
1617
|
+
* `<tombstoneColumn> = 0` — the partial-structure predicate that scopes an overlay index
|
|
1618
|
+
* or UNIQUE constraint to live rows. Built as an AST rather than parsed from text because
|
|
1619
|
+
* the tombstone column name is host-configurable.
|
|
1620
|
+
*
|
|
1621
|
+
* NOTE: the default overlay is a `MemoryTableModule`, which honors `IndexSchema.predicate`
|
|
1622
|
+
* and `UniqueConstraintSchema.predicate`. A host that injects its own `config.overlay`
|
|
1623
|
+
* module must honor them too, or its overlay will re-enforce uniqueness over tombstones.
|
|
1624
|
+
*/
|
|
1625
|
+
liveRowPredicate() {
|
|
1626
|
+
return {
|
|
1627
|
+
type: 'binary',
|
|
1628
|
+
operator: '=',
|
|
1629
|
+
left: { type: 'column', name: this.tombstoneColumn },
|
|
1630
|
+
right: { type: 'literal', value: 0 },
|
|
1631
|
+
};
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
function andPredicate(base, extra) {
|
|
1635
|
+
return base ? { type: 'binary', operator: 'AND', left: base, right: extra } : extra;
|
|
1636
|
+
}
|
|
1637
|
+
/**
|
|
1638
|
+
* Deep-clone `pred`, rewriting every column reference whose `table` qualifier names
|
|
1639
|
+
* `fromName` (case-insensitive) to `toName`. Depth-blind structural walk — a `column`
|
|
1640
|
+
* node is identified by `type === 'column'`; every other node is cloned verbatim. Used
|
|
1641
|
+
* to re-anchor a base table's partial-predicate self-qualifier onto the renamed overlay
|
|
1642
|
+
* table (see {@link IsolationModule.createOverlaySchema}).
|
|
1643
|
+
*/
|
|
1644
|
+
function rescopePredicateQualifier(pred, fromName, toName) {
|
|
1645
|
+
const fromLower = fromName.toLowerCase();
|
|
1646
|
+
const clone = (v) => {
|
|
1647
|
+
if (v === null || typeof v !== 'object')
|
|
1648
|
+
return v;
|
|
1649
|
+
if (Array.isArray(v))
|
|
1650
|
+
return v.map(clone);
|
|
1651
|
+
const out = {};
|
|
1652
|
+
for (const [k, val] of Object.entries(v)) {
|
|
1653
|
+
out[k] = clone(val);
|
|
1654
|
+
}
|
|
1655
|
+
if (out.type === 'column' && typeof out.table === 'string' && out.table.toLowerCase() === fromLower) {
|
|
1656
|
+
out.table = toName;
|
|
1657
|
+
}
|
|
1658
|
+
return out;
|
|
1659
|
+
};
|
|
1660
|
+
return clone(pred);
|
|
976
1661
|
}
|
|
977
1662
|
//# sourceMappingURL=isolation-module.js.map
|