@quereus/sync 3.3.0 → 4.0.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 +17 -7
- package/dist/src/clock/hlc.d.ts +43 -3
- package/dist/src/clock/hlc.d.ts.map +1 -1
- package/dist/src/clock/hlc.js +60 -10
- package/dist/src/clock/hlc.js.map +1 -1
- package/dist/src/create-sync-module.d.ts +46 -9
- package/dist/src/create-sync-module.d.ts.map +1 -1
- package/dist/src/create-sync-module.js +11 -8
- package/dist/src/create-sync-module.js.map +1 -1
- package/dist/src/index.d.ts +10 -8
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +8 -5
- package/dist/src/index.js.map +1 -1
- package/dist/src/metadata/basis-lifecycle.d.ts +180 -0
- package/dist/src/metadata/basis-lifecycle.d.ts.map +1 -0
- package/dist/src/metadata/basis-lifecycle.js +211 -0
- package/dist/src/metadata/basis-lifecycle.js.map +1 -0
- package/dist/src/metadata/change-log.d.ts +1 -1
- package/dist/src/metadata/change-log.d.ts.map +1 -1
- package/dist/src/metadata/change-log.js +6 -6
- package/dist/src/metadata/change-log.js.map +1 -1
- package/dist/src/metadata/column-version.d.ts +14 -5
- package/dist/src/metadata/column-version.d.ts.map +1 -1
- package/dist/src/metadata/column-version.js +22 -12
- package/dist/src/metadata/column-version.js.map +1 -1
- package/dist/src/metadata/index.d.ts +2 -0
- package/dist/src/metadata/index.d.ts.map +1 -1
- package/dist/src/metadata/index.js +2 -0
- package/dist/src/metadata/index.js.map +1 -1
- package/dist/src/metadata/keys.d.ts +52 -2
- package/dist/src/metadata/keys.d.ts.map +1 -1
- package/dist/src/metadata/keys.js +99 -14
- package/dist/src/metadata/keys.js.map +1 -1
- package/dist/src/metadata/peer-state.d.ts +1 -1
- package/dist/src/metadata/peer-state.js +5 -5
- package/dist/src/metadata/quarantine.d.ts +90 -0
- package/dist/src/metadata/quarantine.d.ts.map +1 -0
- package/dist/src/metadata/quarantine.js +167 -0
- package/dist/src/metadata/quarantine.js.map +1 -0
- package/dist/src/metadata/schema-migration.d.ts +1 -1
- package/dist/src/metadata/schema-migration.js +7 -7
- package/dist/src/metadata/schema-version.d.ts +1 -1
- package/dist/src/metadata/schema-version.js +7 -7
- package/dist/src/metadata/tombstones.d.ts +26 -9
- package/dist/src/metadata/tombstones.d.ts.map +1 -1
- package/dist/src/metadata/tombstones.js +42 -19
- package/dist/src/metadata/tombstones.js.map +1 -1
- package/dist/src/sql/basis-lifecycle-tvf.d.ts +34 -0
- package/dist/src/sql/basis-lifecycle-tvf.d.ts.map +1 -0
- package/dist/src/sql/basis-lifecycle-tvf.js +101 -0
- package/dist/src/sql/basis-lifecycle-tvf.js.map +1 -0
- package/dist/src/sync/admission.d.ts +81 -0
- package/dist/src/sync/admission.d.ts.map +1 -0
- package/dist/src/sync/admission.js +94 -0
- package/dist/src/sync/admission.js.map +1 -0
- package/dist/src/sync/change-applicator.d.ts +65 -2
- package/dist/src/sync/change-applicator.d.ts.map +1 -1
- package/dist/src/sync/change-applicator.js +493 -68
- package/dist/src/sync/change-applicator.js.map +1 -1
- package/dist/src/sync/change-grouping.d.ts +43 -0
- package/dist/src/sync/change-grouping.d.ts.map +1 -0
- package/dist/src/sync/change-grouping.js +103 -0
- package/dist/src/sync/change-grouping.js.map +1 -0
- package/dist/src/sync/events.d.ts +159 -1
- package/dist/src/sync/events.d.ts.map +1 -1
- package/dist/src/sync/events.js +50 -0
- package/dist/src/sync/events.js.map +1 -1
- package/dist/src/sync/manager.d.ts +87 -0
- package/dist/src/sync/manager.d.ts.map +1 -1
- package/dist/src/sync/protocol.d.ts +174 -11
- package/dist/src/sync/protocol.d.ts.map +1 -1
- package/dist/src/sync/protocol.js +4 -1
- package/dist/src/sync/protocol.js.map +1 -1
- package/dist/src/sync/snapshot-stream.d.ts.map +1 -1
- package/dist/src/sync/snapshot-stream.js +89 -19
- package/dist/src/sync/snapshot-stream.js.map +1 -1
- package/dist/src/sync/snapshot.d.ts.map +1 -1
- package/dist/src/sync/snapshot.js +63 -44
- package/dist/src/sync/snapshot.js.map +1 -1
- package/dist/src/sync/store-adapter.d.ts +117 -15
- package/dist/src/sync/store-adapter.d.ts.map +1 -1
- package/dist/src/sync/store-adapter.js +247 -91
- package/dist/src/sync/store-adapter.js.map +1 -1
- package/dist/src/sync/sync-context.d.ts +44 -1
- package/dist/src/sync/sync-context.d.ts.map +1 -1
- package/dist/src/sync/sync-context.js +26 -0
- package/dist/src/sync/sync-context.js.map +1 -1
- package/dist/src/sync/sync-manager-impl.d.ts +247 -13
- package/dist/src/sync/sync-manager-impl.d.ts.map +1 -1
- package/dist/src/sync/sync-manager-impl.js +788 -193
- package/dist/src/sync/sync-manager-impl.js.map +1 -1
- package/package.json +6 -6
|
@@ -4,18 +4,57 @@
|
|
|
4
4
|
* Coordinates CRDT metadata tracking and sync operations.
|
|
5
5
|
* Delegates to focused sub-modules for snapshot, streaming, and change application.
|
|
6
6
|
*/
|
|
7
|
-
import {
|
|
7
|
+
import { QuereusError, StatusCode, SYNC_EVICT_TAG } from '@quereus/quereus';
|
|
8
|
+
import { HLCManager, compareHLC, createHLC, deterministicTxnId, MAX_OPSEQ } from '../clock/hlc.js';
|
|
8
9
|
import { generateSiteId, SITE_ID_KEY, serializeSiteIdentity, deserializeSiteIdentity, siteIdEquals, } from '../clock/site.js';
|
|
9
10
|
import { ColumnVersionStore, deserializeColumnVersion } from '../metadata/column-version.js';
|
|
10
11
|
import { TombstoneStore, deserializeTombstone } from '../metadata/tombstones.js';
|
|
11
12
|
import { PeerStateStore } from '../metadata/peer-state.js';
|
|
12
13
|
import { SchemaMigrationStore, deserializeMigration } from '../metadata/schema-migration.js';
|
|
13
14
|
import { ChangeLogStore } from '../metadata/change-log.js';
|
|
15
|
+
import { QuarantineStore } from '../metadata/quarantine.js';
|
|
16
|
+
import { BasisLifecycleStore, classifyBasisLifecycle, basisLifecycleRecordChanged, splitRelKey, parseEvictPolicyTag, isEvictable, quietSince, } from '../metadata/basis-lifecycle.js';
|
|
14
17
|
import { SYNC_KEY_PREFIX, buildAllColumnVersionsScanBounds, buildAllTombstonesScanBounds, buildAllSchemaMigrationsScanBounds, parseColumnVersionKey, parseTombstoneKey, parseSchemaMigrationKey, } from '../metadata/keys.js';
|
|
15
|
-
import {
|
|
16
|
-
import { applyChanges as applyChangesImpl } from './change-applicator.js';
|
|
18
|
+
import { persistHLCStateBatch, toError } from './sync-context.js';
|
|
19
|
+
import { applyChanges as applyChangesImpl, drainHeldChanges as drainHeldChangesImpl, drainReappearedTables } from './change-applicator.js';
|
|
20
|
+
import { buildTransactionChangeSets } from './change-grouping.js';
|
|
17
21
|
import { getSnapshot as getSnapshotImpl, applySnapshot as applySnapshotImpl } from './snapshot.js';
|
|
18
22
|
import { getSnapshotStream as getSnapshotStreamImpl, applySnapshotStream as applySnapshotStreamImpl, getSnapshotCheckpoint as getSnapshotCheckpointImpl, resumeSnapshotStream as resumeSnapshotStreamImpl, } from './snapshot-stream.js';
|
|
23
|
+
/**
|
|
24
|
+
* Guard a transaction's running `opSeq` against the uint32 bound. `opSeq` is
|
|
25
|
+
* serialized as a big-endian uint32 in the HLC comparison key, so a fact count
|
|
26
|
+
* exceeding {@link MAX_OPSEQ} would silently wrap and corrupt ordering — throw
|
|
27
|
+
* instead. Practically unreachable (4 billion facts in one transaction); the
|
|
28
|
+
* write side telemeters the throw via an error sync-state event.
|
|
29
|
+
*/
|
|
30
|
+
export function assertOpSeqInRange(opSeq) {
|
|
31
|
+
if (opSeq > MAX_OPSEQ) {
|
|
32
|
+
throw new QuereusError(`Transaction exceeds ${MAX_OPSEQ + 1} facts; opSeq exhausted`, StatusCode.ERROR);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Map an engine schema-change event `(objectType, type)` to the sync
|
|
37
|
+
* {@link SchemaMigrationType} it records, or `undefined` when the combination is
|
|
38
|
+
* not tracked for replication (e.g. column-level or view/trigger objects, or an
|
|
39
|
+
* `alter` on an index). A `'table' alter` is recorded as `alter_column` — the
|
|
40
|
+
* coarse "table definition changed" migration the schema-sync layer replays.
|
|
41
|
+
*/
|
|
42
|
+
function mapSchemaMigrationType(objectType, type) {
|
|
43
|
+
if (objectType === 'table') {
|
|
44
|
+
switch (type) {
|
|
45
|
+
case 'create': return 'create_table';
|
|
46
|
+
case 'drop': return 'drop_table';
|
|
47
|
+
case 'alter': return 'alter_column';
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
else if (objectType === 'index') {
|
|
51
|
+
switch (type) {
|
|
52
|
+
case 'create': return 'add_index';
|
|
53
|
+
case 'drop': return 'drop_index';
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
19
58
|
/**
|
|
20
59
|
* Implementation of SyncManager.
|
|
21
60
|
*
|
|
@@ -31,36 +70,61 @@ export class SyncManagerImpl {
|
|
|
31
70
|
peerStates;
|
|
32
71
|
changeLog;
|
|
33
72
|
schemaMigrations;
|
|
73
|
+
quarantine;
|
|
74
|
+
basisLifecycle;
|
|
34
75
|
syncEvents;
|
|
35
76
|
applyToStore;
|
|
36
77
|
getTableSchema;
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
78
|
+
/** Reclaim-by-name callback for the eviction sweep; absent ⇒ sweep is a no-op. */
|
|
79
|
+
dropLocalTable;
|
|
80
|
+
// Last basis hash recorded per basis schema (in-memory, advisory). Drives the
|
|
81
|
+
// basis-drift warning in recordLensDeployment — a warning, not durable state.
|
|
82
|
+
lastBasisHash = new Map();
|
|
83
|
+
// Cumulative unknown-table disposition counters (in-memory, observe-only —
|
|
84
|
+
// mirrors the engine's materialized-view collision stats).
|
|
85
|
+
unknownTableIgnored = 0;
|
|
86
|
+
unknownTableQuarantined = 0;
|
|
87
|
+
unknownTableForwarded = 0;
|
|
88
|
+
// Cumulative forwardable changes re-offered through getChangesSince (the
|
|
89
|
+
// store-and-forward relay's outbound activity). Distinct from
|
|
90
|
+
// unknownTableForwarded, which counts apply-time holds: a held entry is held
|
|
91
|
+
// ONCE but relayed possibly MANY times until GC, so this counter grows with
|
|
92
|
+
// relay activity, not with distinct stragglers. Bumped in collectForwardableChanges.
|
|
93
|
+
unknownTableRelayed = 0;
|
|
94
|
+
unknownTableByTable = new Map();
|
|
95
|
+
constructor(kv, config, hlcManager, syncEvents, applyToStore, getTableSchema, dropLocalTable) {
|
|
41
96
|
this.kv = kv;
|
|
42
97
|
this.config = config;
|
|
43
98
|
this.hlcManager = hlcManager;
|
|
44
99
|
this.syncEvents = syncEvents;
|
|
45
100
|
this.applyToStore = applyToStore;
|
|
46
101
|
this.getTableSchema = getTableSchema;
|
|
102
|
+
this.dropLocalTable = dropLocalTable;
|
|
47
103
|
this.columnVersions = new ColumnVersionStore(kv);
|
|
48
|
-
this.tombstones = new TombstoneStore(kv, config.
|
|
104
|
+
this.tombstones = new TombstoneStore(kv, config.retentionHorizonMs);
|
|
49
105
|
this.peerStates = new PeerStateStore(kv);
|
|
50
106
|
this.changeLog = new ChangeLogStore(kv);
|
|
51
107
|
this.schemaMigrations = new SchemaMigrationStore(kv);
|
|
108
|
+
this.quarantine = new QuarantineStore(kv);
|
|
109
|
+
this.basisLifecycle = new BasisLifecycleStore(kv);
|
|
52
110
|
}
|
|
53
111
|
/**
|
|
54
112
|
* Create a new SyncManager, initializing or loading site identity.
|
|
55
113
|
*
|
|
56
114
|
* @param kv - KV store for sync metadata
|
|
57
|
-
* @param
|
|
115
|
+
* @param transactionSource - Engine transaction-commit source for local change
|
|
116
|
+
* capture (a `Database`, or any `onTransactionCommit` emitter). When provided,
|
|
117
|
+
* sync subscribes to `onTransactionCommit` and records CRDT metadata for each
|
|
118
|
+
* committed local transaction — one HLC per transaction. Pass `undefined` for a
|
|
119
|
+
* relay-only deployment (e.g. a coordinator) that captures no local DML.
|
|
58
120
|
* @param config - Sync configuration
|
|
59
121
|
* @param syncEvents - Sync event emitter for UI integration
|
|
60
122
|
* @param applyToStore - Optional callback for applying remote changes to the store
|
|
61
123
|
* @param getTableSchema - Optional callback for getting table schema by name
|
|
124
|
+
* @param dropLocalTable - Optional reclaim-by-name callback for the basis-table
|
|
125
|
+
* eviction sweep; when absent (e.g. a relay-only coordinator) the sweep is a no-op.
|
|
62
126
|
*/
|
|
63
|
-
static async create(kv,
|
|
127
|
+
static async create(kv, transactionSource, config, syncEvents, applyToStore, getTableSchema, dropLocalTable) {
|
|
64
128
|
// Load or create site identity
|
|
65
129
|
const siteIdKey = new TextEncoder().encode(SITE_ID_KEY);
|
|
66
130
|
let siteId;
|
|
@@ -89,10 +153,15 @@ export class SyncManagerImpl {
|
|
|
89
153
|
};
|
|
90
154
|
}
|
|
91
155
|
const hlcManager = new HLCManager(siteId, hlcState);
|
|
92
|
-
const manager = new SyncManagerImpl(kv, config, hlcManager, syncEvents, applyToStore, getTableSchema);
|
|
93
|
-
//
|
|
94
|
-
|
|
95
|
-
|
|
156
|
+
const manager = new SyncManagerImpl(kv, config, hlcManager, syncEvents, applyToStore, getTableSchema, dropLocalTable);
|
|
157
|
+
// Capture local changes at the engine transaction boundary: one grouped
|
|
158
|
+
// `onTransactionCommit` batch ⇒ one transaction ⇒ one HLC. The store's
|
|
159
|
+
// per-table emitter is below the transaction boundary and cannot group a
|
|
160
|
+
// multi-table transaction (docs/sync.md § Transaction-Based Change Grouping),
|
|
161
|
+
// so we subscribe here, not there. Omitted for relay-only deployments.
|
|
162
|
+
transactionSource?.onTransactionCommit((batch) => {
|
|
163
|
+
void manager.handleTransactionCommit(batch);
|
|
164
|
+
});
|
|
96
165
|
return manager;
|
|
97
166
|
}
|
|
98
167
|
// ============================================================================
|
|
@@ -104,126 +173,451 @@ export class SyncManagerImpl {
|
|
|
104
173
|
getCurrentHLC() {
|
|
105
174
|
return this.hlcManager.now();
|
|
106
175
|
}
|
|
176
|
+
/**
|
|
177
|
+
* Basis-membership oracle for unknown-table detection. `getTableSchema(s,t)
|
|
178
|
+
* === undefined` ⇒ the table is outside the local basis. When no oracle was
|
|
179
|
+
* provided (e.g. a relay-only coordinator), detection is inert: every table
|
|
180
|
+
* reports in-basis and the store adapter's defensive throw governs.
|
|
181
|
+
*/
|
|
182
|
+
isTableInBasis(schema, table) {
|
|
183
|
+
return this.getTableSchema ? this.getTableSchema(schema, table) !== undefined : true;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Current column names for an in-basis table via the `getTableSchema` oracle, or
|
|
187
|
+
* `undefined` when the table is outside the basis (oracle returns nothing) OR no
|
|
188
|
+
* oracle was wired. Backs the drain path's basis gate + schema-drift filter (see
|
|
189
|
+
* {@link SyncContext.getTableColumnNames}). The no-oracle `undefined` makes drain
|
|
190
|
+
* a clean no-op on a relay-only coordinator, exactly as detection is inert there.
|
|
191
|
+
*/
|
|
192
|
+
getTableColumnNames(schema, table) {
|
|
193
|
+
return this.getTableSchema?.(schema, table)?.columns?.map(c => c.name);
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Accumulate unknown-table disposition stats (called by the apply path after a
|
|
197
|
+
* diverted group is successfully admitted).
|
|
198
|
+
*/
|
|
199
|
+
recordUnknownTable(disposition, schema, table, changeCount) {
|
|
200
|
+
if (disposition === 'quarantine') {
|
|
201
|
+
this.unknownTableQuarantined += changeCount;
|
|
202
|
+
}
|
|
203
|
+
else if (disposition === 'store-and-forward') {
|
|
204
|
+
this.unknownTableForwarded += changeCount;
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
this.unknownTableIgnored += changeCount;
|
|
208
|
+
}
|
|
209
|
+
// byTable is the union across all dispositions (the per-disposition counters
|
|
210
|
+
// partition it) — accumulated unconditionally, never against `forwarded` alone.
|
|
211
|
+
const key = `${schema}.${table}`;
|
|
212
|
+
this.unknownTableByTable.set(key, (this.unknownTableByTable.get(key) ?? 0) + changeCount);
|
|
213
|
+
}
|
|
214
|
+
getUnknownTableStats() {
|
|
215
|
+
return {
|
|
216
|
+
ignored: this.unknownTableIgnored,
|
|
217
|
+
quarantined: this.unknownTableQuarantined,
|
|
218
|
+
forwarded: this.unknownTableForwarded,
|
|
219
|
+
relayed: this.unknownTableRelayed,
|
|
220
|
+
byTable: new Map(this.unknownTableByTable),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
107
223
|
// ============================================================================
|
|
108
|
-
//
|
|
224
|
+
// Basis-table lifecycle (legacy-table retirement bookkeeping)
|
|
109
225
|
// ============================================================================
|
|
110
226
|
/**
|
|
111
|
-
*
|
|
112
|
-
*
|
|
227
|
+
* Record one logical schema's lens deployment over its basis, recomputing the
|
|
228
|
+
* durable per-basis-table lifecycle classification (`docs/migration.md`
|
|
229
|
+
* § 2 Converge). See {@link SyncManager.recordLensDeployment}.
|
|
230
|
+
*
|
|
231
|
+
* The snapshot is scoped to ONE logical schema, so this schema's directly-mapped
|
|
232
|
+
* contribution is stored per-schema (`mappedBy`) and the aggregate state ORs all
|
|
233
|
+
* schemas — a basis table stays `directly-mapped` until the last mapper drops it.
|
|
234
|
+
* The classification is a pure function of `(snapshot, basis schema)`, both
|
|
235
|
+
* reachable from `db`: the directly-mapped set is the union of every table
|
|
236
|
+
* snapshot's `relationBacking` keys (plus deferred surrogate-split members), and
|
|
237
|
+
* the basis membership / derivation sources come from enumerating the basis
|
|
238
|
+
* schema's tables.
|
|
113
239
|
*/
|
|
114
|
-
async
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
240
|
+
async recordLensDeployment(db, logicalSchemaName, snapshot) {
|
|
241
|
+
const logical = logicalSchemaName.toLowerCase();
|
|
242
|
+
// This schema's directly-mapped contribution: the union of every table
|
|
243
|
+
// snapshot's basis relations. surrogateMemberKeys mark deferred surrogate-split
|
|
244
|
+
// members that back the table INDIRECTLY — fold them in as referenced so a
|
|
245
|
+
// deferred member is never misclassified as an eviction candidate.
|
|
246
|
+
const directlyMapped = new Set();
|
|
247
|
+
for (const tableSnapshot of snapshot.tables.values()) {
|
|
248
|
+
for (const key of tableSnapshot.relationBacking.keys())
|
|
249
|
+
directlyMapped.add(key);
|
|
250
|
+
if (tableSnapshot.surrogateMemberKeys) {
|
|
251
|
+
for (const key of tableSnapshot.surrogateMemberKeys)
|
|
252
|
+
directlyMapped.add(key);
|
|
124
253
|
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
254
|
+
}
|
|
255
|
+
// Enumerate the basis schema (the common case is `main`, but the basis may be
|
|
256
|
+
// an attached schema — resolve by name). basisMembership = all table keys;
|
|
257
|
+
// derivationSources = union of every maintained table's sourceTables.
|
|
258
|
+
const basisSchema = db.schemaManager.getSchema(snapshot.basisSchemaName);
|
|
259
|
+
const basisMembership = new Set();
|
|
260
|
+
const derivationSources = new Set();
|
|
261
|
+
const displayName = new Map();
|
|
262
|
+
// Snapshot the eviction-policy tag + secondary-index names of each in-basis
|
|
263
|
+
// table NOW: both are gone once the table detaches (its schema + tag vanish),
|
|
264
|
+
// and the eviction sweep needs the index list to reclaim index stores by name.
|
|
265
|
+
const evictPolicyByKey = new Map();
|
|
266
|
+
const indexNamesByKey = new Map();
|
|
267
|
+
if (basisSchema) {
|
|
268
|
+
for (const table of basisSchema.getAllTables()) {
|
|
269
|
+
const key = `${table.schemaName}.${table.name}`.toLowerCase();
|
|
270
|
+
basisMembership.add(key);
|
|
271
|
+
displayName.set(key, { schema: table.schemaName, table: table.name });
|
|
272
|
+
for (const src of table.derivation?.sourceTables ?? []) {
|
|
273
|
+
derivationSources.add(src.toLowerCase());
|
|
142
274
|
}
|
|
275
|
+
const policy = parseEvictPolicyTag(table.tags?.[SYNC_EVICT_TAG] ?? null);
|
|
276
|
+
if (policy !== undefined)
|
|
277
|
+
evictPolicyByKey.set(key, policy);
|
|
278
|
+
const indexNames = (table.indexes ?? []).map(i => i.name);
|
|
279
|
+
if (indexNames.length > 0)
|
|
280
|
+
indexNamesByKey.set(key, indexNames);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
else if (snapshot.basisSchemaName) {
|
|
284
|
+
console.warn(`[Sync] recordLensDeployment: basis schema '${snapshot.basisSchemaName}' not found; basis membership treated as empty`);
|
|
285
|
+
}
|
|
286
|
+
// Basis-drift detection: warn (don't silently reclassify) when the basis hash
|
|
287
|
+
// changed out-of-band vs. the last deploy we recorded for this basis.
|
|
288
|
+
const priorHash = this.lastBasisHash.get(snapshot.basisSchemaName);
|
|
289
|
+
if (priorHash !== undefined && priorHash !== snapshot.basisHash) {
|
|
290
|
+
console.warn(`[Sync] recordLensDeployment: basis '${snapshot.basisSchemaName}' hash drifted out-of-band `
|
|
291
|
+
+ `(recorded ${priorHash || '∅'}, current ${snapshot.basisHash || '∅'})`);
|
|
292
|
+
}
|
|
293
|
+
this.lastBasisHash.set(snapshot.basisSchemaName, snapshot.basisHash);
|
|
294
|
+
const stored = await this.basisLifecycle.getAll();
|
|
295
|
+
// The universe of basis-relation keys to (re)classify: the current basis +
|
|
296
|
+
// its derivation sources + this schema's directly-mapped set + every stored
|
|
297
|
+
// record. directlyMapped is folded in (beyond the ticket's literal
|
|
298
|
+
// membership/derivation/stored universe) so every relation the lens maps is
|
|
299
|
+
// tracked even in the defensive case where the basis schema can't be resolved
|
|
300
|
+
// — a no-op normally, since a mapped relation is a real basis table. Stored
|
|
301
|
+
// keys keep an empty/detach deploy revisiting tables it no longer maps.
|
|
302
|
+
const keys = new Set([...basisMembership, ...derivationSources, ...directlyMapped, ...stored.keys()]);
|
|
303
|
+
const now = Date.now();
|
|
304
|
+
const batch = this.kv.batch();
|
|
305
|
+
let wroteAny = false;
|
|
306
|
+
const pendingEvents = [];
|
|
307
|
+
// Tables transitioning detached → present this deploy: a retired table the lens
|
|
308
|
+
// re-maps back into the basis. Its held out-of-basis changes should replay NOW
|
|
309
|
+
// (after the lifecycle records are durable) rather than waiting on the periodic
|
|
310
|
+
// sweep — the lens-redeploy sibling of the inbound-create_table reappearance path.
|
|
311
|
+
const reappeared = [];
|
|
312
|
+
for (const key of keys) {
|
|
313
|
+
const prior = stored.get(key);
|
|
314
|
+
// OR this schema's contribution into the per-schema mapper set: add when it
|
|
315
|
+
// maps the table now, remove when it no longer does (so the LAST mapper
|
|
316
|
+
// dropping it is what flips the aggregate off directly-mapped).
|
|
317
|
+
const mapped = new Set((prior?.mappedBy ?? []).map(s => s.toLowerCase()));
|
|
318
|
+
if (directlyMapped.has(key))
|
|
319
|
+
mapped.add(logical);
|
|
320
|
+
else
|
|
321
|
+
mapped.delete(logical);
|
|
322
|
+
const mappedBy = [...mapped].sort();
|
|
323
|
+
const derivationSource = derivationSources.has(key);
|
|
324
|
+
const inBasis = basisMembership.has(key);
|
|
325
|
+
const state = classifyBasisLifecycle(mappedBy, derivationSource, inBasis);
|
|
326
|
+
// Stamp mapped-since on entry into directly-mapped (clearing any prior
|
|
327
|
+
// unmapped-since); unmapped-since on exit from it (the retirement hint).
|
|
328
|
+
const wasMapped = prior?.state === 'directly-mapped';
|
|
329
|
+
const isMapped = state === 'directly-mapped';
|
|
330
|
+
let mappedSince = prior?.mappedSince;
|
|
331
|
+
let unmappedSince = prior?.unmappedSince;
|
|
332
|
+
if (isMapped && !wasMapped) {
|
|
333
|
+
mappedSince = now;
|
|
334
|
+
unmappedSince = undefined;
|
|
335
|
+
}
|
|
336
|
+
else if (!isMapped && wasMapped) {
|
|
337
|
+
unmappedSince = now;
|
|
338
|
+
}
|
|
339
|
+
// Stamp detached-at on entry into detached (the eviction quiet-clock
|
|
340
|
+
// fallback when the table was never directly mapped); clear it on re-attach.
|
|
341
|
+
const wasDetached = prior?.state === 'detached';
|
|
342
|
+
const isDetached = state === 'detached';
|
|
343
|
+
let detachedAt = prior?.detachedAt;
|
|
344
|
+
if (isDetached && !wasDetached)
|
|
345
|
+
detachedAt = now;
|
|
346
|
+
else if (!isDetached)
|
|
347
|
+
detachedAt = undefined;
|
|
348
|
+
// Eviction override + index list: while in-basis the tag/index list are
|
|
349
|
+
// authoritative (re-captured each deploy, so a removed tag clears the
|
|
350
|
+
// override); once out of basis they are gone, so carry the prior record's.
|
|
351
|
+
const evictPolicy = inBasis ? evictPolicyByKey.get(key) : prior?.evictPolicy;
|
|
352
|
+
const indexNames = inBasis ? indexNamesByKey.get(key) : prior?.indexNames;
|
|
353
|
+
const display = displayName.get(key)
|
|
354
|
+
?? (prior ? { schema: prior.schema, table: prior.table } : splitRelKey(key));
|
|
355
|
+
const record = {
|
|
356
|
+
schema: display.schema,
|
|
357
|
+
table: display.table,
|
|
358
|
+
state,
|
|
359
|
+
mappedBy,
|
|
360
|
+
derivationSource,
|
|
361
|
+
inBasis,
|
|
362
|
+
...(mappedSince !== undefined ? { mappedSince } : {}),
|
|
363
|
+
...(unmappedSince !== undefined ? { unmappedSince } : {}),
|
|
364
|
+
...(detachedAt !== undefined ? { detachedAt } : {}),
|
|
365
|
+
// The dynamic signal is owned by the change applicator — carry through here.
|
|
366
|
+
...(prior?.lastDirectlyMappedWriteAt !== undefined ? { lastDirectlyMappedWriteAt: prior.lastDirectlyMappedWriteAt } : {}),
|
|
367
|
+
...(evictPolicy !== undefined ? { evictPolicy } : {}),
|
|
368
|
+
...(indexNames && indexNames.length > 0 ? { indexNames } : {}),
|
|
369
|
+
};
|
|
370
|
+
if (!prior || basisLifecycleRecordChanged(prior, record)) {
|
|
371
|
+
this.basisLifecycle.put(batch, record);
|
|
372
|
+
wroteAny = true;
|
|
373
|
+
}
|
|
374
|
+
// Emit only on an ACTUAL state change of an already-tracked table — a
|
|
375
|
+
// brand-new table's first classification and an idempotent re-apply emit
|
|
376
|
+
// nothing (a spurious event would mislead the retirement hint).
|
|
377
|
+
if (prior && prior.state !== state) {
|
|
378
|
+
pendingEvents.push({
|
|
379
|
+
schema: record.schema,
|
|
380
|
+
table: record.table,
|
|
381
|
+
previousState: prior.state,
|
|
382
|
+
newState: state,
|
|
383
|
+
at: now,
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
// Detached → present (any in-basis state): a previously-retired table this
|
|
387
|
+
// deploy re-mapped back into the basis — its held out-of-basis changes should
|
|
388
|
+
// replay now. Use the precise `detached → present` transition rather than the
|
|
389
|
+
// broader "inBasis newly true": held entries only ever exist for previously
|
|
390
|
+
// out-of-basis tables, so the precise check skips spurious scoped scans for
|
|
391
|
+
// brand-new tables (excluded anyway by the `prior` guard). A cheapness
|
|
392
|
+
// optimization, not a correctness gate — drainHeldChanges' oracle gate makes
|
|
393
|
+
// any over-trigger a harmless no-op.
|
|
394
|
+
if (wasDetached && !isDetached) {
|
|
395
|
+
reappeared.push({ schema: record.schema, table: record.table });
|
|
143
396
|
}
|
|
144
|
-
|
|
145
|
-
|
|
397
|
+
}
|
|
398
|
+
if (wroteAny)
|
|
146
399
|
await batch.write();
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
changes: changesToEmit,
|
|
152
|
-
pendingSync: true,
|
|
153
|
-
});
|
|
400
|
+
// Emit AFTER the records are durable, so a listener that reads back
|
|
401
|
+
// getBasisTableLifecycle() sees the committed transition.
|
|
402
|
+
for (const event of pendingEvents) {
|
|
403
|
+
this.syncEvents.emitBasisTableLifecycle(event);
|
|
154
404
|
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
405
|
+
// Reactive low-latency drain (sync-drain-reappear-lens-redeploy): a table this
|
|
406
|
+
// deploy re-mapped detached → present may hold out-of-basis changes waiting on it.
|
|
407
|
+
// Replay them as a SEPARATE post-commit apply unit, after the lifecycle batch above
|
|
408
|
+
// is durable and its events have fired, so the oracle (getTableColumnNames) sees the
|
|
409
|
+
// re-attached table and the held changes LWW-resolve against the present basis —
|
|
410
|
+
// instead of waiting up to one periodic-sweep interval. Symmetric with the
|
|
411
|
+
// inbound-create_table path in applyChanges. Advisory: drainReappearedTables logs +
|
|
412
|
+
// swallows any per-table failure, so a drain throw never aborts the deploy (which is
|
|
413
|
+
// itself advisory bookkeeping — the store-module forwarder wraps it in try/catch).
|
|
414
|
+
//
|
|
415
|
+
// RE-ENTRANCY: in production this runs inside the firing `apply schema` statement,
|
|
416
|
+
// which holds the engine exec mutex (the module `notifyLensDeployment` hook is awaited
|
|
417
|
+
// mid-statement). The drain re-enters the engine via `applyToStore` →
|
|
418
|
+
// `db.ingestExternalRowChanges`, which acquires that same mutex — awaiting it inline
|
|
419
|
+
// would deadlock (the statement can't release the mutex until this returns). So when
|
|
420
|
+
// the engine reports it is mid-statement, defer the drain to fire-and-forget: it queues
|
|
421
|
+
// on the mutex and runs the instant `apply schema` commits and releases it (callers
|
|
422
|
+
// observe the drained rows after the capture settle, exactly like the local-change
|
|
423
|
+
// capture). Outside a live statement (e.g. unit tests over a stub store that never
|
|
424
|
+
// touches the engine mutex), await inline so the drain completes before returning.
|
|
425
|
+
if (reappeared.length > 0) {
|
|
426
|
+
if (db._isExecuting?.()) {
|
|
427
|
+
void drainReappearedTables(this, reappeared);
|
|
428
|
+
}
|
|
429
|
+
else {
|
|
430
|
+
await drainReappearedTables(this, reappeared);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
async getBasisTableLifecycle() {
|
|
435
|
+
return this.basisLifecycle.list();
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* Reclaim the local storage of every detached basis table that has been quiet
|
|
439
|
+
* past its effective retention horizon (`docs/migration.md` § 4 Contract).
|
|
440
|
+
* Host-driven (like {@link pruneTombstones} / {@link pruneQuarantine}) — the
|
|
441
|
+
* host schedules it; the library adds no timer. Returns the number of tables
|
|
442
|
+
* evicted.
|
|
443
|
+
*
|
|
444
|
+
* No-op when no `dropLocalTable` reclaim callback is wired (e.g. a relay-only
|
|
445
|
+
* coordinator with no store). For each evictable record it re-reads the record
|
|
446
|
+
* and re-checks eligibility immediately before dropping — guarding against a
|
|
447
|
+
* concurrent re-deploy that re-mapped the table between the scan and the drop —
|
|
448
|
+
* then drops the storage, emits `onBasisTableEvicted`, and clears the record. A
|
|
449
|
+
* `dropLocalTable` throw leaves the record in place to retry next sweep (the
|
|
450
|
+
* drop is idempotent).
|
|
451
|
+
*/
|
|
452
|
+
async evictExpiredBasisTables(now = Date.now()) {
|
|
453
|
+
if (!this.dropLocalTable)
|
|
454
|
+
return 0;
|
|
455
|
+
const records = await this.basisLifecycle.list();
|
|
456
|
+
let evicted = 0;
|
|
457
|
+
for (const candidate of records) {
|
|
458
|
+
if (!isEvictable(candidate, now, this.config))
|
|
459
|
+
continue;
|
|
460
|
+
// Re-read + re-check immediately before the drop: a concurrent re-deploy
|
|
461
|
+
// may have re-mapped (and so re-attached) the table since the scan.
|
|
462
|
+
const fresh = await this.basisLifecycle.get(candidate.schema, candidate.table);
|
|
463
|
+
if (!fresh || fresh.state !== 'detached' || !isEvictable(fresh, now, this.config))
|
|
464
|
+
continue;
|
|
465
|
+
try {
|
|
466
|
+
await this.dropLocalTable(fresh.schema, fresh.table, fresh.indexNames ?? []);
|
|
467
|
+
}
|
|
468
|
+
catch (error) {
|
|
469
|
+
// Leave the record; retry next sweep. The drop is idempotent, so a
|
|
470
|
+
// partial reclaim is re-attempted rather than stranded.
|
|
471
|
+
console.warn(`[Sync] evictExpiredBasisTables: dropLocalTable failed for '${fresh.schema}.${fresh.table}'; `
|
|
472
|
+
+ `record retained for retry: ${error instanceof Error ? error.message : String(error)}`);
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
// Storage reclaimed — clear the record (a later re-create starts fresh) and
|
|
476
|
+
// emit AFTER the drop succeeded.
|
|
477
|
+
await this.basisLifecycle.delete(fresh.schema, fresh.table);
|
|
478
|
+
this.syncEvents.emitBasisTableEvicted({
|
|
479
|
+
schema: fresh.schema,
|
|
480
|
+
table: fresh.table,
|
|
481
|
+
at: now,
|
|
482
|
+
quietForMs: now - quietSince(fresh),
|
|
160
483
|
});
|
|
484
|
+
evicted++;
|
|
161
485
|
}
|
|
486
|
+
return evicted;
|
|
162
487
|
}
|
|
488
|
+
// ============================================================================
|
|
489
|
+
// Local change capture (engine transaction boundary)
|
|
490
|
+
// ============================================================================
|
|
163
491
|
/**
|
|
164
|
-
*
|
|
165
|
-
*
|
|
492
|
+
* Record CRDT metadata for one committed local transaction.
|
|
493
|
+
*
|
|
494
|
+
* Driven by the engine's `onTransactionCommit` group — the authoritative
|
|
495
|
+
* "one transaction = one HLC" boundary. The clock is ticked exactly **once**;
|
|
496
|
+
* every fact of the transaction shares that base `(wallTime, counter, siteId)`
|
|
497
|
+
* and differs only in `opSeq` (a contiguous, 0-based sub-order). DDL is recorded
|
|
498
|
+
* before DML so migrations sort below the same transaction's data facts
|
|
499
|
+
* (docs/sync.md § DDL Application Order). All metadata for the transaction —
|
|
500
|
+
* schema migrations, column versions, tombstones, change-log entries, and the
|
|
501
|
+
* HLC clock state — lands in a single KV batch.
|
|
166
502
|
*/
|
|
167
|
-
async
|
|
503
|
+
async handleTransactionCommit(batch) {
|
|
168
504
|
try {
|
|
169
|
-
|
|
505
|
+
// Capture only LOCAL facts. An all-remote group is a pure sync-apply echo:
|
|
506
|
+
// its metadata was already recorded by the apply path. A mixed group
|
|
507
|
+
// (local + remote in one transaction — unusual) records only its local
|
|
508
|
+
// facts; opSeq is assigned only to recorded facts so they stay contiguous.
|
|
509
|
+
const localSchema = batch.schemaEvents.filter(e => !e.remote);
|
|
510
|
+
const localData = batch.dataEvents.filter(e => !e.remote);
|
|
511
|
+
if (localSchema.length === 0 && localData.length === 0)
|
|
170
512
|
return;
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
}
|
|
188
|
-
else if (objectType === 'index') {
|
|
189
|
-
switch (type) {
|
|
190
|
-
case 'create':
|
|
191
|
-
migrationType = 'add_index';
|
|
192
|
-
break;
|
|
193
|
-
case 'drop':
|
|
194
|
-
migrationType = 'drop_index';
|
|
195
|
-
break;
|
|
196
|
-
default: return;
|
|
197
|
-
}
|
|
513
|
+
// ONE tick per committed transaction. tick() returns opSeq 0; the closure
|
|
514
|
+
// below stamps each successive fact with the next opSeq off the same base.
|
|
515
|
+
const base = this.hlcManager.tick();
|
|
516
|
+
const transactionId = deterministicTxnId(base);
|
|
517
|
+
let opSeq = 0;
|
|
518
|
+
const nextHlc = () => {
|
|
519
|
+
// Throw (telemetered via the catch below) rather than wrap the uint32.
|
|
520
|
+
assertOpSeqInRange(opSeq);
|
|
521
|
+
return createHLC(base.wallTime, base.counter, base.siteId, opSeq++);
|
|
522
|
+
};
|
|
523
|
+
const kvBatch = this.kv.batch();
|
|
524
|
+
const changes = [];
|
|
525
|
+
// DDL before DML: migrations take the lowest opSeqs.
|
|
526
|
+
const versionCounters = new Map();
|
|
527
|
+
for (const event of localSchema) {
|
|
528
|
+
await this.recordSchemaMigration(kvBatch, event, nextHlc, versionCounters);
|
|
198
529
|
}
|
|
199
|
-
|
|
200
|
-
|
|
530
|
+
for (const event of localData) {
|
|
531
|
+
await this.recordDataEvent(kvBatch, event, nextHlc, changes);
|
|
201
532
|
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
await
|
|
205
|
-
type: migrationType,
|
|
206
|
-
ddl: ddl || '',
|
|
207
|
-
hlc,
|
|
208
|
-
schemaVersion: newVersion,
|
|
209
|
-
});
|
|
210
|
-
// Persist HLC state (DRY: uses shared helper)
|
|
211
|
-
await persistHLCState(this);
|
|
533
|
+
// Persist HLC clock state (wallTime/counter only — opSeq is never persisted).
|
|
534
|
+
persistHLCStateBatch(this, kvBatch);
|
|
535
|
+
await kvBatch.write();
|
|
212
536
|
this.syncEvents.emitLocalChange({
|
|
213
|
-
transactionId
|
|
214
|
-
changes
|
|
537
|
+
transactionId,
|
|
538
|
+
changes,
|
|
215
539
|
pendingSync: true,
|
|
216
540
|
});
|
|
217
541
|
}
|
|
218
542
|
catch (error) {
|
|
219
|
-
console.error('[Sync] Error handling
|
|
543
|
+
console.error('[Sync] Error handling transaction commit:', error);
|
|
220
544
|
this.syncEvents.emitSyncStateChange({
|
|
221
545
|
status: 'error',
|
|
222
546
|
error: toError(error),
|
|
223
547
|
});
|
|
224
548
|
}
|
|
225
549
|
}
|
|
226
|
-
|
|
550
|
+
/**
|
|
551
|
+
* Record one local schema-change event as a migration. Allocates an `opSeq`
|
|
552
|
+
* (via `nextHlc`) only when the event maps to a tracked migration, so
|
|
553
|
+
* unsupported object types consume no sub-order. `versionCounters` carries the
|
|
554
|
+
* running per-table schema version across this transaction's migrations.
|
|
555
|
+
*/
|
|
556
|
+
async recordSchemaMigration(batch, event, nextHlc, versionCounters) {
|
|
557
|
+
const migrationType = mapSchemaMigrationType(event.objectType, event.type);
|
|
558
|
+
if (!migrationType)
|
|
559
|
+
return;
|
|
560
|
+
const { schemaName, objectName, ddl } = event;
|
|
561
|
+
const counterKey = `${schemaName}.${objectName}`;
|
|
562
|
+
let version = versionCounters.get(counterKey);
|
|
563
|
+
if (version === undefined) {
|
|
564
|
+
version = await this.schemaMigrations.getCurrentVersion(schemaName, objectName);
|
|
565
|
+
}
|
|
566
|
+
version += 1;
|
|
567
|
+
versionCounters.set(counterKey, version);
|
|
568
|
+
this.schemaMigrations.recordMigrationBatch(batch, schemaName, objectName, {
|
|
569
|
+
type: migrationType,
|
|
570
|
+
ddl: ddl || '',
|
|
571
|
+
hlc: nextHlc(),
|
|
572
|
+
schemaVersion: version,
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
/**
|
|
576
|
+
* Record one local data-change event: a tombstone (delete) or the changed
|
|
577
|
+
* column versions (insert/update). Each recorded fact consumes one `opSeq`.
|
|
578
|
+
*/
|
|
579
|
+
async recordDataEvent(batch, event, nextHlc, changes) {
|
|
580
|
+
const { schemaName, tableName, type, oldRow, newRow } = event;
|
|
581
|
+
const pk = event.key;
|
|
582
|
+
if (!pk) {
|
|
583
|
+
console.warn(`[Sync] Missing primary key for ${schemaName}.${tableName} ${type} event — change not tracked`);
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
if (type === 'delete') {
|
|
587
|
+
// Dedupe the change-log delete entry the same way columns are deduped: if a
|
|
588
|
+
// prior tombstone exists for this pk (a delete→reinsert→delete key reuse),
|
|
589
|
+
// its stale delete entry must go, so at most one delete entry survives per pk
|
|
590
|
+
// with HLC equal to the current tombstone. This keeps collectChangesSince's
|
|
591
|
+
// boundary detection (keyed on the log HLC) in lockstep with grouping (keyed
|
|
592
|
+
// on the resolved tombstone HLC) — see its LOAD-BEARING INVARIANT.
|
|
593
|
+
const existing = await this.tombstones.getTombstone(schemaName, tableName, pk);
|
|
594
|
+
if (existing) {
|
|
595
|
+
this.changeLog.deleteEntryBatch(batch, existing.hlc, 'delete', schemaName, tableName, pk);
|
|
596
|
+
}
|
|
597
|
+
const hlc = nextHlc();
|
|
598
|
+
// Carry the row's last-known image (the engine `oldRow`) as a best-effort
|
|
599
|
+
// before-image on both the persisted tombstone and the inline change. Copy
|
|
600
|
+
// it so neither aliases the engine's row buffer; absent when the event
|
|
601
|
+
// carried no `oldRow` (relayed/synthesized deletes).
|
|
602
|
+
const priorRow = oldRow ? [...oldRow] : undefined;
|
|
603
|
+
this.tombstones.setTombstoneBatch(batch, schemaName, tableName, pk, hlc, priorRow);
|
|
604
|
+
this.changeLog.recordDeletionBatch(batch, hlc, schemaName, tableName, pk);
|
|
605
|
+
await this.columnVersions.deleteRowVersions(schemaName, tableName, pk);
|
|
606
|
+
const change = {
|
|
607
|
+
type: 'delete',
|
|
608
|
+
schema: schemaName,
|
|
609
|
+
table: tableName,
|
|
610
|
+
pk,
|
|
611
|
+
hlc,
|
|
612
|
+
...(priorRow !== undefined ? { priorRow } : {}),
|
|
613
|
+
};
|
|
614
|
+
changes.push(change);
|
|
615
|
+
}
|
|
616
|
+
else if (newRow) {
|
|
617
|
+
await this.recordColumnVersions(batch, schemaName, tableName, pk, oldRow, newRow, nextHlc, changes);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
async recordColumnVersions(batch, schemaName, tableName, pk, oldRow, newRow, nextHlc, changes) {
|
|
227
621
|
const tableSchema = this.getTableSchema?.(schemaName, tableName);
|
|
228
622
|
const columnNames = tableSchema?.columns?.map(c => c.name);
|
|
229
623
|
if (!tableSchema && this.getTableSchema) {
|
|
@@ -238,7 +632,14 @@ export class SyncManagerImpl {
|
|
|
238
632
|
if (oldVersion) {
|
|
239
633
|
this.changeLog.deleteEntryBatch(batch, oldVersion.hlc, 'column', schemaName, tableName, pk, column);
|
|
240
634
|
}
|
|
241
|
-
const
|
|
635
|
+
const hlc = nextHlc();
|
|
636
|
+
// Carry the overwritten cell version as a best-effort before-image on
|
|
637
|
+
// both the persisted version and the inline change. Spread only when a
|
|
638
|
+
// prior exists — first writes carry neither field (absent, not undefined).
|
|
639
|
+
const prior = oldVersion
|
|
640
|
+
? { priorHlc: oldVersion.hlc, priorValue: oldVersion.value }
|
|
641
|
+
: undefined;
|
|
642
|
+
const version = { hlc, value: newValue, ...prior };
|
|
242
643
|
this.columnVersions.setColumnVersionBatch(batch, schemaName, tableName, pk, column, version);
|
|
243
644
|
this.changeLog.recordColumnChangeBatch(batch, hlc, schemaName, tableName, pk, column);
|
|
244
645
|
const change = {
|
|
@@ -249,103 +650,186 @@ export class SyncManagerImpl {
|
|
|
249
650
|
column,
|
|
250
651
|
value: newValue,
|
|
251
652
|
hlc,
|
|
653
|
+
...prior,
|
|
252
654
|
};
|
|
253
|
-
|
|
655
|
+
changes.push(change);
|
|
254
656
|
}
|
|
255
657
|
}
|
|
256
658
|
}
|
|
257
659
|
// ============================================================================
|
|
258
660
|
// Delta Sync API
|
|
259
661
|
// ============================================================================
|
|
662
|
+
/**
|
|
663
|
+
* Extract changes for a peer as **one {@link ChangeSet} per source
|
|
664
|
+
* transaction** (grouped by HLC identity `(wallTime, counter, siteId)`),
|
|
665
|
+
* bounded at transaction granularity by `config.batchSize`. A transaction is
|
|
666
|
+
* never split across ChangeSets and two transactions are never merged, so a
|
|
667
|
+
* consumer that advances `lastSyncHLC = ChangeSet.hlc` always lands on a real
|
|
668
|
+
* commit boundary (docs/sync.md § Transaction-Based Change Grouping → Read side).
|
|
669
|
+
*/
|
|
260
670
|
async getChangesSince(peerSiteId, sinceHLC) {
|
|
671
|
+
const dataChanges = sinceHLC
|
|
672
|
+
? await this.collectChangesSince(peerSiteId, sinceHLC, this.config.batchSize)
|
|
673
|
+
: await this.collectAllChanges(peerSiteId);
|
|
674
|
+
// Fold forwardable held changes (the `store-and-forward` relay's outbound
|
|
675
|
+
// half) into the SAME ChangeSet[] return — no new transport surface. Each
|
|
676
|
+
// keeps its ORIGINAL hlc + siteId, so buildTransactionChangeSets re-forms the
|
|
677
|
+
// straggler's transaction by deterministicTxnId: a forwarded change rejoins any
|
|
678
|
+
// applied part of the same straggler txn (e.g. a txn that wrote both a live and
|
|
679
|
+
// a now-retired-here table), correct by construction.
|
|
680
|
+
//
|
|
681
|
+
// BOUND + ORDERING (verify, no extra code): collectChangesSince early-exits its
|
|
682
|
+
// change-log scan at a transaction cut C; collectForwardableChanges is FULLY
|
|
683
|
+
// scanned (all `> sinceHLC`). buildTransactionChangeSets re-bounds the UNION at
|
|
684
|
+
// batchSize at a transaction boundary M ≤ C. Everything ≤ M from BOTH sources is
|
|
685
|
+
// present (change-log up to C ⊇ ≤ M; forwardable fully scanned ⊇ ≤ M), so the
|
|
686
|
+
// returned prefix is contiguous, advancing the consumer's watermark to M loses
|
|
687
|
+
// nothing, and forwardable entries with HLC in (M, …] are re-collected next round
|
|
688
|
+
// (still `> sinceHLC`). Groups are HLC-ordered, so a forwarded change interleaves
|
|
689
|
+
// with change-log changes in global HLC order — never out of order.
|
|
690
|
+
const forwardable = await this.collectForwardableChanges(peerSiteId, sinceHLC);
|
|
691
|
+
const changes = forwardable.length > 0 ? [...dataChanges, ...forwardable] : dataChanges;
|
|
692
|
+
const schemaMigrations = await this.collectSchemaMigrations(peerSiteId, sinceHLC);
|
|
693
|
+
return buildTransactionChangeSets(changes, schemaMigrations, this.config.batchSize, (transactionId, changeCount) => {
|
|
694
|
+
// Oversized transactions are returned whole (never split); telemeter
|
|
695
|
+
// rather than silently chunk so the bound breach is observable.
|
|
696
|
+
console.warn(`[Sync] Oversized transaction ${transactionId}: ${changeCount} changes exceed batchSize ${this.config.batchSize}; returned as one ChangeSet`);
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
/**
|
|
700
|
+
* Delta extraction: facts after `sinceHLC`, in change-log (HLC) order, bounded
|
|
701
|
+
* at **scan time** to whole transactions whose cumulative data-change count
|
|
702
|
+
* reaches `batchSize`.
|
|
703
|
+
*
|
|
704
|
+
* The change-log scan is keyed by `(wallTime, counter, siteId, opSeq)`, so a
|
|
705
|
+
* transaction's facts arrive contiguously and transactions arrive in commit
|
|
706
|
+
* order. That lets us stop scanning as soon as enough WHOLE transactions have
|
|
707
|
+
* accumulated, instead of draining the entire iterator into memory and letting
|
|
708
|
+
* {@link buildTransactionChangeSets} truncate afterward (the pre-
|
|
709
|
+
* `sync-getchangessince-bounded-extraction` behavior, where `batchSize` capped
|
|
710
|
+
* the response but not the scan). The bound here mirrors that grouping step
|
|
711
|
+
* exactly — whole transactions accumulate until the cumulative data-change count
|
|
712
|
+
* reaches `batchSize`, never splitting a transaction — so the grouped response
|
|
713
|
+
* is byte-identical; only the scan footprint shrinks.
|
|
714
|
+
*
|
|
715
|
+
* LOAD-BEARING INVARIANT: boundary detection keys off `logEntry.hlc`, but the
|
|
716
|
+
* grouper keys off the *resolved* version's HLC ({@link resolveLogEntry} returns
|
|
717
|
+
* `cv.hlc` / `tombstone.hlc`). These agree only when each non-null-resolving log
|
|
718
|
+
* entry's HLC equals its resolved version's HLC, which holds for BOTH entry types
|
|
719
|
+
* because each is deduped on overwrite — at most one entry survives per key, and
|
|
720
|
+
* its HLC equals the current version's:
|
|
721
|
+
* - COLUMN: an overwrite deletes the prior `(pk, column)` entry (see
|
|
722
|
+
* {@link recordColumnVersions} and `commitChangeMetadata`), so the survivor's
|
|
723
|
+
* HLC is the current `cv.hlc`.
|
|
724
|
+
* - DELETE: a newer tombstone deletes the prior `pk` delete entry (see
|
|
725
|
+
* {@link recordDataEvent} and `commitChangeMetadata`), so a
|
|
726
|
+
* delete→reinsert→delete key reuse no longer leaves a stale entry that
|
|
727
|
+
* re-attributes to a later tombstone HLC; the survivor's HLC is the current
|
|
728
|
+
* `tombstone.hlc`.
|
|
729
|
+
*
|
|
730
|
+
* The overwrite dedup above covers entries written across SEPARATE writes/applies
|
|
731
|
+
* (each later one sees the prior committed version). The apply path additionally
|
|
732
|
+
* collapses in-batch repeats: two versions of one key inside a single
|
|
733
|
+
* `applyChanges` call resolve against the SAME pre-batch prior version, so neither
|
|
734
|
+
* sees the other — `commitChangeMetadata` keeps only the max-HLC winner per key and
|
|
735
|
+
* writes a single entry, preserving the invariant regardless of how many versions
|
|
736
|
+
* of a key were batched.
|
|
737
|
+
*
|
|
738
|
+
* (Schema migrations are still fully scanned by {@link collectSchemaMigrations};
|
|
739
|
+
* the `sm:` range is not HLC-ordered, but migrations are few and the grouping
|
|
740
|
+
* step drops any that sort past the bounded fact watermark.)
|
|
741
|
+
*/
|
|
742
|
+
async collectChangesSince(peerSiteId, sinceHLC, batchSize) {
|
|
261
743
|
const changes = [];
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
schema: logEntry.schema,
|
|
273
|
-
table: logEntry.table,
|
|
274
|
-
pk: logEntry.pk,
|
|
275
|
-
column: logEntry.column,
|
|
276
|
-
value: cv.value,
|
|
277
|
-
hlc: cv.hlc,
|
|
278
|
-
};
|
|
279
|
-
changes.push(columnChange);
|
|
280
|
-
}
|
|
281
|
-
else {
|
|
282
|
-
const tombstone = await this.tombstones.getTombstone(logEntry.schema, logEntry.table, logEntry.pk);
|
|
283
|
-
if (!tombstone)
|
|
284
|
-
continue;
|
|
285
|
-
const deletion = {
|
|
286
|
-
type: 'delete',
|
|
287
|
-
schema: logEntry.schema,
|
|
288
|
-
table: logEntry.table,
|
|
289
|
-
pk: logEntry.pk,
|
|
290
|
-
hlc: tombstone.hlc,
|
|
291
|
-
};
|
|
292
|
-
changes.push(deletion);
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
else {
|
|
297
|
-
await this.collectAllChanges(peerSiteId, changes);
|
|
298
|
-
}
|
|
299
|
-
// Collect schema migrations
|
|
300
|
-
const schemaMigrations = [];
|
|
301
|
-
const smBounds = buildAllSchemaMigrationsScanBounds();
|
|
302
|
-
for await (const entry of this.kv.iterate(smBounds)) {
|
|
303
|
-
const parsed = parseSchemaMigrationKey(entry.key);
|
|
304
|
-
if (!parsed)
|
|
305
|
-
continue;
|
|
306
|
-
const migration = deserializeMigration(entry.value);
|
|
307
|
-
if (sinceHLC && compareHLC(migration.hlc, sinceHLC) <= 0)
|
|
744
|
+
// Data-change count over fully-scanned (whole) transactions. The in-flight
|
|
745
|
+
// transaction's own count is held separately until its boundary is crossed,
|
|
746
|
+
// so it is only folded in once the transaction is known to be complete.
|
|
747
|
+
let completedChangeCount = 0;
|
|
748
|
+
let currentTxnId = null;
|
|
749
|
+
let currentTxnChangeCount = 0;
|
|
750
|
+
for await (const logEntry of this.changeLog.getChangesSince(sinceHLC)) {
|
|
751
|
+
// A transaction is wholly one site's, so skipping the peer's own facts
|
|
752
|
+
// filters whole transactions cleanly (no half-empty ChangeSet).
|
|
753
|
+
if (siteIdEquals(logEntry.hlc.siteId, peerSiteId))
|
|
308
754
|
continue;
|
|
309
|
-
|
|
755
|
+
// deterministicTxnId excludes opSeq, so it is exactly the transaction
|
|
756
|
+
// identity. Facts of one transaction are contiguous in the HLC-ordered
|
|
757
|
+
// scan, so a change of id means the prior transaction is complete and can
|
|
758
|
+
// be folded into the bound.
|
|
759
|
+
const txnId = deterministicTxnId(logEntry.hlc);
|
|
760
|
+
if (currentTxnId !== null && txnId !== currentTxnId) {
|
|
761
|
+
completedChangeCount += currentTxnChangeCount;
|
|
762
|
+
// Enough whole transactions accumulated — stop before touching the next
|
|
763
|
+
// one. buildTransactionChangeSets re-applies the same bound, so feeding
|
|
764
|
+
// it this prefix yields the same ChangeSets the full scan would have.
|
|
765
|
+
if (completedChangeCount >= batchSize)
|
|
766
|
+
break;
|
|
767
|
+
currentTxnChangeCount = 0;
|
|
768
|
+
}
|
|
769
|
+
currentTxnId = txnId;
|
|
770
|
+
const change = await this.resolveLogEntry(logEntry);
|
|
771
|
+
if (!change)
|
|
310
772
|
continue;
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
schema: parsed.schema,
|
|
314
|
-
table: parsed.table,
|
|
315
|
-
ddl: migration.ddl,
|
|
316
|
-
hlc: migration.hlc,
|
|
317
|
-
schemaVersion: migration.schemaVersion,
|
|
318
|
-
});
|
|
319
|
-
}
|
|
320
|
-
if (changes.length === 0 && schemaMigrations.length === 0) {
|
|
321
|
-
return [];
|
|
322
|
-
}
|
|
323
|
-
schemaMigrations.sort((a, b) => compareHLC(a.hlc, b.hlc));
|
|
324
|
-
const result = [];
|
|
325
|
-
for (let i = 0; i < changes.length; i += this.config.batchSize) {
|
|
326
|
-
const batch = changes.slice(i, i + this.config.batchSize);
|
|
327
|
-
const maxHLC = batch.reduce((max, c) => compareHLC(c.hlc, max) > 0 ? c.hlc : max, batch[0].hlc);
|
|
328
|
-
result.push({
|
|
329
|
-
siteId: this.getSiteId(),
|
|
330
|
-
transactionId: crypto.randomUUID(),
|
|
331
|
-
hlc: maxHLC,
|
|
332
|
-
changes: batch,
|
|
333
|
-
schemaMigrations: i === 0 ? schemaMigrations : [],
|
|
334
|
-
});
|
|
773
|
+
changes.push(change);
|
|
774
|
+
currentTxnChangeCount++;
|
|
335
775
|
}
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
776
|
+
return changes;
|
|
777
|
+
}
|
|
778
|
+
/**
|
|
779
|
+
* Resolve a change-log entry to the live {@link Change} it references, or
|
|
780
|
+
* `null` when the underlying column version / tombstone is gone — a stale log
|
|
781
|
+
* entry (e.g. a column overwritten, or a row deleted after the entry was
|
|
782
|
+
* written). The authoritative HLC and value come from the current version, not
|
|
783
|
+
* the log key.
|
|
784
|
+
*/
|
|
785
|
+
async resolveLogEntry(logEntry) {
|
|
786
|
+
if (logEntry.entryType === 'column') {
|
|
787
|
+
const cv = await this.columnVersions.getColumnVersion(logEntry.schema, logEntry.table, logEntry.pk, logEntry.column);
|
|
788
|
+
if (!cv)
|
|
789
|
+
return null;
|
|
790
|
+
const columnChange = {
|
|
791
|
+
type: 'column',
|
|
792
|
+
schema: logEntry.schema,
|
|
793
|
+
table: logEntry.table,
|
|
794
|
+
pk: logEntry.pk,
|
|
795
|
+
column: logEntry.column,
|
|
796
|
+
value: cv.value,
|
|
797
|
+
hlc: cv.hlc,
|
|
798
|
+
// Re-emit the stored before-image (spread only when present), so a
|
|
799
|
+
// receiver/relay forwards the origin's prior chain unchanged.
|
|
800
|
+
...(cv.priorHlc !== undefined ? { priorHlc: cv.priorHlc, priorValue: cv.priorValue } : {}),
|
|
801
|
+
};
|
|
802
|
+
return columnChange;
|
|
345
803
|
}
|
|
346
|
-
|
|
804
|
+
const tombstone = await this.tombstones.getTombstone(logEntry.schema, logEntry.table, logEntry.pk);
|
|
805
|
+
if (!tombstone)
|
|
806
|
+
return null;
|
|
807
|
+
const deletion = {
|
|
808
|
+
type: 'delete',
|
|
809
|
+
schema: logEntry.schema,
|
|
810
|
+
table: logEntry.table,
|
|
811
|
+
pk: logEntry.pk,
|
|
812
|
+
hlc: tombstone.hlc,
|
|
813
|
+
// Re-emit the stored before-image (spread only when present) so a
|
|
814
|
+
// receiver/relay forwards the origin's last-known row image unchanged.
|
|
815
|
+
...(tombstone.priorRow !== undefined ? { priorRow: tombstone.priorRow } : {}),
|
|
816
|
+
};
|
|
817
|
+
return deletion;
|
|
347
818
|
}
|
|
348
|
-
|
|
819
|
+
/**
|
|
820
|
+
* Full extraction (initial sync / delta-from-zero): every column version and
|
|
821
|
+
* tombstone not originating from the peer. Ordering is owned by
|
|
822
|
+
* {@link buildTransactionChangeSets}, so no pre-sort is needed here.
|
|
823
|
+
*
|
|
824
|
+
* Unlike {@link collectChangesSince}, this path cannot early-exit at scan time:
|
|
825
|
+
* the `cv:`/`tb:` scans are keyed by table/pk, NOT by HLC, so transactions are
|
|
826
|
+
* interleaved arbitrarily and no transaction boundary is reachable without a
|
|
827
|
+
* full scan + sort. It is left unbounded because it is the from-zero path: a
|
|
828
|
+
* large initial range is expected to be served by a snapshot rather than this
|
|
829
|
+
* delta path (docs/sync.md).
|
|
830
|
+
*/
|
|
831
|
+
async collectAllChanges(peerSiteId) {
|
|
832
|
+
const changes = [];
|
|
349
833
|
const cvBounds = buildAllColumnVersionsScanBounds();
|
|
350
834
|
for await (const entry of this.kv.iterate(cvBounds)) {
|
|
351
835
|
const parsed = parseColumnVersionKey(entry.key);
|
|
@@ -362,6 +846,7 @@ export class SyncManagerImpl {
|
|
|
362
846
|
column: parsed.column,
|
|
363
847
|
value: cv.value,
|
|
364
848
|
hlc: cv.hlc,
|
|
849
|
+
...(cv.priorHlc !== undefined ? { priorHlc: cv.priorHlc, priorValue: cv.priorValue } : {}),
|
|
365
850
|
};
|
|
366
851
|
changes.push(columnChange);
|
|
367
852
|
}
|
|
@@ -379,10 +864,107 @@ export class SyncManagerImpl {
|
|
|
379
864
|
table: parsed.table,
|
|
380
865
|
pk: parsed.pk,
|
|
381
866
|
hlc: tombstone.hlc,
|
|
867
|
+
...(tombstone.priorRow !== undefined ? { priorRow: tombstone.priorRow } : {}),
|
|
382
868
|
};
|
|
383
869
|
changes.push(deletion);
|
|
384
870
|
}
|
|
385
|
-
changes
|
|
871
|
+
return changes;
|
|
872
|
+
}
|
|
873
|
+
/**
|
|
874
|
+
* Collect forwardable held changes to re-offer to a peer — the outbound half of
|
|
875
|
+
* the `store-and-forward` unknown-table disposition. Source is
|
|
876
|
+
* {@link QuarantineStore.listForwardable}: straggler changes this peer held
|
|
877
|
+
* because it has retired the table, marked forwardable. Each is re-offered with
|
|
878
|
+
* its ORIGINAL `hlc` + `siteId` (the straggler's fact, not a new local one) — the
|
|
879
|
+
* identity that makes the relay loop-free and convergent across hops with NO
|
|
880
|
+
* per-table peer-membership oracle (none exists): a peer that already holds the
|
|
881
|
+
* change re-holds it idempotently (HLC-keyed) and the per-peer watermark stops
|
|
882
|
+
* re-send after one exchange.
|
|
883
|
+
*
|
|
884
|
+
* Two filters, mirroring {@link collectChangesSince} / {@link collectAllChanges}:
|
|
885
|
+
* - **Echo exclusion** — drop a change whose origin (`change.hlc.siteId`) is the
|
|
886
|
+
* peer itself, so a straggler's fact is never echoed back to its own author.
|
|
887
|
+
* - **Watermark filter** — when `sinceHLC` is defined (delta path), keep only
|
|
888
|
+
* `compareHLC(change.hlc, sinceHLC) > 0`. REQUIRED: the consumer advances its
|
|
889
|
+
* per-peer `lastSyncHLC` to `max(returned ChangeSet.hlc)`, so a round that
|
|
890
|
+
* returned a forwarded change with HLC ≤ the watermark would REGRESS it and
|
|
891
|
+
* trigger a re-scan/re-deliver flood. Filtering `> sinceHLC` keeps the merged
|
|
892
|
+
* response a safe contiguous prefix — exactly the change-log contract. When
|
|
893
|
+
* `sinceHLC` is undefined (from-zero / {@link collectAllChanges} path) there is
|
|
894
|
+
* no lower bound, so all (origin ≠ peer) are kept.
|
|
895
|
+
*
|
|
896
|
+
* Accepted limitation (documented intent, not a defect): a straggler whose change
|
|
897
|
+
* is causally older than the holder's sync recency with a peer (`HLC ≤ sinceHLC`)
|
|
898
|
+
* is NOT relayed via this delta path — the same scalar-watermark limitation the
|
|
899
|
+
* base delta layer already has. store-and-forward targets the transitional
|
|
900
|
+
* uneven-retirement window, where the straggler's writes are recent enough to
|
|
901
|
+
* exceed holder watermarks; quarantine already prevents write loss outside it.
|
|
902
|
+
*
|
|
903
|
+
* Snapshot carve-out: forwardable entries are DELTA-ONLY. The snapshot collectors
|
|
904
|
+
* scan only `cv:`/`tb:`/`sm:` (never `qt:`), since a snapshot transfers the
|
|
905
|
+
* offering peer's OWN basis and a forwarded change is for a table that peer does
|
|
906
|
+
* not have.
|
|
907
|
+
*
|
|
908
|
+
* Full horizon-bounded scan (no early-exit), paralleling {@link collectAllChanges}:
|
|
909
|
+
* the forwardable set is bounded by the retention horizon and is EMPTY — so this
|
|
910
|
+
* is zero-cost — in the no-straggler case.
|
|
911
|
+
*/
|
|
912
|
+
async collectForwardableChanges(peerSiteId, sinceHLC) {
|
|
913
|
+
const held = await this.quarantine.listForwardable();
|
|
914
|
+
const changes = [];
|
|
915
|
+
for (const entry of held) {
|
|
916
|
+
const change = entry.change;
|
|
917
|
+
// Echo exclusion: the change's HLC siteId is the straggler origin.
|
|
918
|
+
if (siteIdEquals(change.hlc.siteId, peerSiteId))
|
|
919
|
+
continue;
|
|
920
|
+
// Watermark filter on the delta path (see contract above).
|
|
921
|
+
if (sinceHLC && compareHLC(change.hlc, sinceHLC) <= 0)
|
|
922
|
+
continue;
|
|
923
|
+
changes.push(change);
|
|
924
|
+
}
|
|
925
|
+
// Relay-activity telemetry: a held entry is counted on EVERY getChangesSince
|
|
926
|
+
// that re-offers it (repeatedly until GC), and the batch bound may defer some to
|
|
927
|
+
// a later round (re-counted then) — so this measures relay ACTIVITY, not distinct
|
|
928
|
+
// deliveries (distinct from the apply-time `forwarded` hold count).
|
|
929
|
+
this.unknownTableRelayed += changes.length;
|
|
930
|
+
return changes;
|
|
931
|
+
}
|
|
932
|
+
/**
|
|
933
|
+
* Collect schema migrations after `sinceHLC` not originating from the peer.
|
|
934
|
+
* Each migration shares its transaction's base HLC, so the grouping step
|
|
935
|
+
* rejoins it with that transaction's data facts (or forms a DDL-only ChangeSet).
|
|
936
|
+
*
|
|
937
|
+
* The `sm:` range is keyed by `{schema}.{table}:{version}`, not by HLC, so this
|
|
938
|
+
* scan cannot early-exit the way {@link collectChangesSince} does — it is drained
|
|
939
|
+
* in full even when the fact side stops early. That is acceptable because
|
|
940
|
+
* migrations are few, and {@link buildTransactionChangeSets} drops any migration
|
|
941
|
+
* that sorts past the bounded fact watermark (its DDL-only group falls beyond the
|
|
942
|
+
* `batchSize` cut), so over-scanning here costs work but never correctness. A
|
|
943
|
+
* peer with a pathological volume of un-synced DDL is the one case this leaves
|
|
944
|
+
* unbounded; see the bounded-extraction ticket's handoff.
|
|
945
|
+
*/
|
|
946
|
+
async collectSchemaMigrations(peerSiteId, sinceHLC) {
|
|
947
|
+
const schemaMigrations = [];
|
|
948
|
+
const smBounds = buildAllSchemaMigrationsScanBounds();
|
|
949
|
+
for await (const entry of this.kv.iterate(smBounds)) {
|
|
950
|
+
const parsed = parseSchemaMigrationKey(entry.key);
|
|
951
|
+
if (!parsed)
|
|
952
|
+
continue;
|
|
953
|
+
const migration = deserializeMigration(entry.value);
|
|
954
|
+
if (sinceHLC && compareHLC(migration.hlc, sinceHLC) <= 0)
|
|
955
|
+
continue;
|
|
956
|
+
if (siteIdEquals(migration.hlc.siteId, peerSiteId))
|
|
957
|
+
continue;
|
|
958
|
+
schemaMigrations.push({
|
|
959
|
+
type: migration.type,
|
|
960
|
+
schema: parsed.schema,
|
|
961
|
+
table: parsed.table,
|
|
962
|
+
ddl: migration.ddl,
|
|
963
|
+
hlc: migration.hlc,
|
|
964
|
+
schemaVersion: migration.schemaVersion,
|
|
965
|
+
});
|
|
966
|
+
}
|
|
967
|
+
return schemaMigrations;
|
|
386
968
|
}
|
|
387
969
|
// ============================================================================
|
|
388
970
|
// Delegated: Change Application
|
|
@@ -390,15 +972,18 @@ export class SyncManagerImpl {
|
|
|
390
972
|
async applyChanges(changes) {
|
|
391
973
|
return applyChangesImpl(this, changes);
|
|
392
974
|
}
|
|
975
|
+
async drainHeldChanges(schema, table) {
|
|
976
|
+
return drainHeldChangesImpl(this, schema, table);
|
|
977
|
+
}
|
|
393
978
|
async canDeltaSync(peerSiteId, sinceHLC) {
|
|
394
979
|
const peerState = await this.peerStates.getPeerState(peerSiteId);
|
|
395
980
|
if (!peerState) {
|
|
396
981
|
return false;
|
|
397
982
|
}
|
|
398
|
-
// Check if
|
|
983
|
+
// Check if retention horizon covers the requested time range
|
|
399
984
|
const now = Date.now();
|
|
400
985
|
const sinceTime = Number(sinceHLC.wallTime);
|
|
401
|
-
if (now - sinceTime > this.config.
|
|
986
|
+
if (now - sinceTime > this.config.retentionHorizonMs) {
|
|
402
987
|
return false;
|
|
403
988
|
}
|
|
404
989
|
return true;
|
|
@@ -429,7 +1014,7 @@ export class SyncManagerImpl {
|
|
|
429
1014
|
const tbBounds = buildAllTombstonesScanBounds();
|
|
430
1015
|
for await (const entry of this.kv.iterate(tbBounds)) {
|
|
431
1016
|
const tombstone = deserializeTombstone(entry.value);
|
|
432
|
-
if (now - tombstone.createdAt > this.config.
|
|
1017
|
+
if (now - tombstone.createdAt > this.config.retentionHorizonMs) {
|
|
433
1018
|
batch.delete(entry.key);
|
|
434
1019
|
count++;
|
|
435
1020
|
}
|
|
@@ -437,6 +1022,16 @@ export class SyncManagerImpl {
|
|
|
437
1022
|
await batch.write();
|
|
438
1023
|
return count;
|
|
439
1024
|
}
|
|
1025
|
+
/**
|
|
1026
|
+
* GC quarantined out-of-basis straggler changes past the retention horizon —
|
|
1027
|
+
* the quarantine sibling of {@link pruneTombstones}, keyed off the same
|
|
1028
|
+
* `retentionHorizonMs`. A held change older than the horizon was already
|
|
1029
|
+
* outside the delivery guarantee.
|
|
1030
|
+
*/
|
|
1031
|
+
async pruneQuarantine() {
|
|
1032
|
+
const cutoff = Date.now() - this.config.retentionHorizonMs;
|
|
1033
|
+
return this.quarantine.pruneOlderThan(cutoff);
|
|
1034
|
+
}
|
|
440
1035
|
getEventEmitter() {
|
|
441
1036
|
return this.syncEvents;
|
|
442
1037
|
}
|