@powersync/service-module-mongodb-storage 0.23.2 → 0.24.1
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/CHANGELOG.md +45 -0
- package/LICENSE +201 -67
- package/dist/storage/MongoBucketStorage.d.ts +1 -0
- package/dist/storage/MongoBucketStorage.js +45 -83
- package/dist/storage/MongoBucketStorage.js.map +1 -1
- package/dist/storage/implementation/MongoBucketBatch.d.ts +60 -39
- package/dist/storage/implementation/MongoBucketBatch.js +123 -296
- package/dist/storage/implementation/MongoBucketBatch.js.map +1 -1
- package/dist/storage/implementation/MongoOpIdAllocator.d.ts +6 -1
- package/dist/storage/implementation/MongoOpIdAllocator.js +12 -5
- package/dist/storage/implementation/MongoOpIdAllocator.js.map +1 -1
- package/dist/storage/implementation/MongoParsedSyncConfigSet.d.ts +7 -0
- package/dist/storage/implementation/MongoParsedSyncConfigSet.js +28 -2
- package/dist/storage/implementation/MongoParsedSyncConfigSet.js.map +1 -1
- package/dist/storage/implementation/MongoReplicationOptions.d.ts +27 -0
- package/dist/storage/implementation/MongoReplicationOptions.js +26 -0
- package/dist/storage/implementation/MongoReplicationOptions.js.map +1 -0
- package/dist/storage/implementation/MongoSyncBucketStorage.js +2 -3
- package/dist/storage/implementation/MongoSyncBucketStorage.js.map +1 -1
- package/dist/storage/implementation/MongoSyncRulesLock.d.ts +15 -2
- package/dist/storage/implementation/MongoSyncRulesLock.js +36 -14
- package/dist/storage/implementation/MongoSyncRulesLock.js.map +1 -1
- package/dist/storage/implementation/common/BatchOperationBuffer.d.ts +79 -0
- package/dist/storage/implementation/common/BatchOperationBuffer.js +286 -0
- package/dist/storage/implementation/common/BatchOperationBuffer.js.map +1 -0
- package/dist/storage/implementation/common/PersistedBatch.d.ts +10 -103
- package/dist/storage/implementation/common/PersistedBatch.js +1 -291
- package/dist/storage/implementation/common/PersistedBatch.js.map +1 -1
- package/dist/storage/implementation/common/PreparedPublication.d.ts +43 -0
- package/dist/storage/implementation/common/PreparedPublication.js +2 -0
- package/dist/storage/implementation/common/PreparedPublication.js.map +1 -0
- package/dist/storage/implementation/common/SourceRecordStore.d.ts +0 -2
- package/dist/storage/implementation/v1/MongoBucketBatchV1.d.ts +17 -13
- package/dist/storage/implementation/v1/MongoBucketBatchV1.js +259 -18
- package/dist/storage/implementation/v1/MongoBucketBatchV1.js.map +1 -1
- package/dist/storage/implementation/v1/PersistedBatchV1.d.ts +21 -15
- package/dist/storage/implementation/v1/PersistedBatchV1.js +58 -24
- package/dist/storage/implementation/v1/PersistedBatchV1.js.map +1 -1
- package/dist/storage/implementation/v1/SourceRecordStoreV1.d.ts +0 -2
- package/dist/storage/implementation/v1/SourceRecordStoreV1.js +0 -3
- package/dist/storage/implementation/v1/SourceRecordStoreV1.js.map +1 -1
- package/dist/storage/implementation/v3/MongoBucketBatchV3.d.ts +25 -14
- package/dist/storage/implementation/v3/MongoBucketBatchV3.js +350 -23
- package/dist/storage/implementation/v3/MongoBucketBatchV3.js.map +1 -1
- package/dist/storage/implementation/v3/MongoCompactorV3.d.ts +1 -0
- package/dist/storage/implementation/v3/MongoCompactorV3.js +10 -4
- package/dist/storage/implementation/v3/MongoCompactorV3.js.map +1 -1
- package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js +39 -28
- package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js.map +1 -1
- package/dist/storage/implementation/v3/PersistedBatchV3.d.ts +46 -24
- package/dist/storage/implementation/v3/PersistedBatchV3.js +159 -83
- package/dist/storage/implementation/v3/PersistedBatchV3.js.map +1 -1
- package/dist/storage/implementation/v3/SourceRecordStoreV3.d.ts +12 -0
- package/dist/storage/implementation/v3/SourceRecordStoreV3.js +41 -13
- package/dist/storage/implementation/v3/SourceRecordStoreV3.js.map +1 -1
- package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.d.ts +4 -0
- package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js +4 -0
- package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js.map +1 -1
- package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.d.ts +27 -2
- package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js +61 -5
- package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js.map +1 -1
- package/package.json +9 -10
- package/src/storage/MongoBucketStorage.ts +55 -122
- package/src/storage/implementation/MongoBucketBatch.ts +185 -392
- package/src/storage/implementation/MongoOpIdAllocator.ts +12 -5
- package/src/storage/implementation/MongoParsedSyncConfigSet.ts +40 -1
- package/src/storage/implementation/MongoReplicationOptions.ts +28 -0
- package/src/storage/implementation/MongoSyncBucketStorage.ts +2 -3
- package/src/storage/implementation/MongoSyncRulesLock.ts +39 -17
- package/src/storage/implementation/common/BatchOperationBuffer.ts +353 -0
- package/src/storage/implementation/common/PersistedBatch.ts +12 -390
- package/src/storage/implementation/common/PreparedPublication.ts +49 -0
- package/src/storage/implementation/common/SourceRecordStore.ts +0 -2
- package/src/storage/implementation/v1/MongoBucketBatchV1.ts +339 -27
- package/src/storage/implementation/v1/PersistedBatchV1.ts +74 -32
- package/src/storage/implementation/v1/SourceRecordStoreV1.ts +0 -5
- package/src/storage/implementation/v3/MongoBucketBatchV3.ts +430 -32
- package/src/storage/implementation/v3/MongoCompactorV3.ts +13 -4
- package/src/storage/implementation/v3/MongoSyncBucketStorageV3.ts +43 -29
- package/src/storage/implementation/v3/PersistedBatchV3.ts +209 -108
- package/src/storage/implementation/v3/SourceRecordStoreV3.ts +45 -15
- package/src/storage/implementation/v3/object-storage/BucketDataObjectStorage.ts +9 -0
- package/src/storage/implementation/v3/object-storage/ObjectStorageLifecycle.ts +79 -5
- package/test/src/checkpoint_cleanup.test.ts +75 -0
- package/test/src/concurrent_writers.test.ts +127 -53
- package/test/src/helpers/PublicationCommandMonitor.ts +119 -0
- package/test/src/helpers/concurrentWriter.mjs +2 -2
- package/test/src/invalid_sync_config.test.ts +9 -0
- package/test/src/object_storage_lifecycle.test.ts +72 -2
- package/test/src/prepared_publication.test.ts +388 -0
- package/test/src/replication_preparation.test.ts +829 -0
- package/test/src/replication_publication_baseline.test.ts +203 -0
- package/test/src/replication_publication_batching.test.ts +212 -0
- package/test/src/replication_uploads.test.ts +393 -0
- package/test/src/source_table_config.test.ts +4 -4
- package/test/src/storage_object_storage_inline_threshold.test.ts +1 -1
- package/test/src/storage_s3_compaction_lifecycle.test.ts +93 -1
- package/test/src/storage_sync.test.ts +101 -2
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -6,14 +6,15 @@ import * as timers from 'node:timers/promises';
|
|
|
6
6
|
import { mongoTableId } from '../../utils/util.js';
|
|
7
7
|
import { MAX_ROW_SIZE } from './MongoBucketBatchShared.js';
|
|
8
8
|
import { OpIdRangeExhausted } from './MongoIdSequence.js';
|
|
9
|
+
import { REPLICATION_WRITE_OPTIONS } from './MongoReplicationOptions.js';
|
|
9
10
|
import { MongoSyncRulesLock } from './MongoSyncRulesLock.js';
|
|
10
11
|
import { OperationBatch, RecordOperation } from './OperationBatch.js';
|
|
11
12
|
import { createObjectStorageUsageWriterId } from './v3/object-storage/ObjectStorageUsage.js';
|
|
12
13
|
export class MongoBucketBatch extends BaseObserver {
|
|
13
14
|
options;
|
|
14
15
|
logger;
|
|
15
|
-
client;
|
|
16
16
|
db;
|
|
17
|
+
/** Shared by this lease's writers; only use inside a serialized public operation. */
|
|
17
18
|
session;
|
|
18
19
|
sync_rules;
|
|
19
20
|
replicationStreamId;
|
|
@@ -61,11 +62,12 @@ export class MongoBucketBatch extends BaseObserver {
|
|
|
61
62
|
? options.replicationLock.signal
|
|
62
63
|
: AbortSignal.any([options.signal, options.replicationLock.signal])
|
|
63
64
|
};
|
|
64
|
-
|
|
65
|
-
this
|
|
65
|
+
// A MongoDB session can only be used with its originating client. Writers
|
|
66
|
+
// created through another factory must still use this lease's database.
|
|
67
|
+
this.db = options.replicationLock.db;
|
|
66
68
|
this.replicationStreamId = options.replicationStreamId;
|
|
67
69
|
this.resumeFromLsn = options.resumeFromLsn;
|
|
68
|
-
this.session =
|
|
70
|
+
this.session = options.replicationLock.session;
|
|
69
71
|
this.replicationStreamName = options.replicationStreamName;
|
|
70
72
|
this.sync_rules = options.parsedSyncConfig.hydratedSyncConfig;
|
|
71
73
|
this.storeCurrentData = options.storeCurrentData;
|
|
@@ -82,9 +84,40 @@ export class MongoBucketBatch extends BaseObserver {
|
|
|
82
84
|
sync_rules_id: this.replicationStreamId
|
|
83
85
|
});
|
|
84
86
|
}
|
|
87
|
+
async resolveTables(options) {
|
|
88
|
+
return this.withWriterLock(() => this.resolveTablesInternal(options));
|
|
89
|
+
}
|
|
90
|
+
async commit(lsn, options) {
|
|
91
|
+
return this.withWriterLock(() => this.commitInternal(lsn, options));
|
|
92
|
+
}
|
|
93
|
+
async keepalive(lsn) {
|
|
94
|
+
return this.commit(lsn, { createEmptyCheckpoints: true });
|
|
95
|
+
}
|
|
96
|
+
async setResumeLsn(lsn) {
|
|
97
|
+
return this.withWriterLock(() => this.setResumeLsnInternal(lsn));
|
|
98
|
+
}
|
|
99
|
+
async getSourceTableStatus(table) {
|
|
100
|
+
return this.withWriterLock(() => this.getSourceTableStatusInternal(table));
|
|
101
|
+
}
|
|
102
|
+
async markAllSnapshotDone(no_checkpoint_before_lsn) {
|
|
103
|
+
return this.withWriterLock(() => this.markAllSnapshotDoneInternal(no_checkpoint_before_lsn));
|
|
104
|
+
}
|
|
105
|
+
async markSnapshotDone(no_checkpoint_before_lsn, options) {
|
|
106
|
+
return this.withWriterLock(() => this.markSnapshotDoneInternal(no_checkpoint_before_lsn, options));
|
|
107
|
+
}
|
|
108
|
+
async markTableSnapshotRequired(table) {
|
|
109
|
+
return this.withWriterLock(() => this.markTableSnapshotRequiredInternal(table));
|
|
110
|
+
}
|
|
111
|
+
async markTableSnapshotDone(tables, no_checkpoint_before_lsn) {
|
|
112
|
+
return this.withWriterLock(() => this.markTableSnapshotDoneInternal(tables, no_checkpoint_before_lsn));
|
|
113
|
+
}
|
|
85
114
|
/** Perform any version-specific setup required before writing custom checkpoints. */
|
|
86
115
|
async prepareCustomWriteCheckpoints() { }
|
|
87
116
|
async flush(options) {
|
|
117
|
+
return this.withWriterLock(() => this.flushInternal(options));
|
|
118
|
+
}
|
|
119
|
+
/** Internal helpers require the public operation to hold the writer mutex. */
|
|
120
|
+
async flushInternal(options) {
|
|
88
121
|
let result = null;
|
|
89
122
|
// One flush may be split over multiple transactions.
|
|
90
123
|
// Each flushInner() is one transaction.
|
|
@@ -103,162 +136,21 @@ export class MongoBucketBatch extends BaseObserver {
|
|
|
103
136
|
}
|
|
104
137
|
let result;
|
|
105
138
|
while (this.batch != null || this.write_checkpoint_batch.length > 0) {
|
|
106
|
-
await this.flushInner(options, async (stream, lastOp) => {
|
|
107
|
-
result = await checkpoint(stream, lastOp);
|
|
139
|
+
await this.flushInner(options, async (stream, lastOp, writes) => {
|
|
140
|
+
result = await checkpoint(stream, lastOp, writes);
|
|
108
141
|
}, projection);
|
|
109
142
|
}
|
|
110
143
|
return result;
|
|
111
144
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
await this.prepareCustomWriteCheckpoints();
|
|
122
|
-
}
|
|
123
|
-
await this.withReplicationTransaction(`Flushing ${batch?.length ?? 0} ops`, async (session, opSeq) => {
|
|
124
|
-
clearedError = false;
|
|
125
|
-
if (batch != null) {
|
|
126
|
-
const result = await this.replicateBatch(session, batch, opSeq, options);
|
|
127
|
-
resumeBatch = result.resumeBatch;
|
|
128
|
-
clearedError ||= result.clearedError;
|
|
129
|
-
}
|
|
130
|
-
if (this.write_checkpoint_batch.length > 0) {
|
|
131
|
-
this.logger.info(`Writing ${this.write_checkpoint_batch.length} custom write checkpoints`);
|
|
132
|
-
await this.batchCreateCustomWriteCheckpoints(session, opSeq.next());
|
|
133
|
-
}
|
|
134
|
-
last_op = opSeq.last();
|
|
135
|
-
}, async (stream, lastOp) => {
|
|
136
|
-
if (checkpoint != null && resumeBatch == null) {
|
|
137
|
-
// The checkpoint must also persist the head, including when visibility is blocked.
|
|
138
|
-
await checkpoint(stream, lastOp);
|
|
139
|
-
return true;
|
|
140
|
-
}
|
|
141
|
-
return false;
|
|
142
|
-
}, projection);
|
|
143
|
-
// Keep checkpoints available if the transaction retries after writing them.
|
|
144
|
-
this.write_checkpoint_batch = [];
|
|
145
|
-
if (clearedError) {
|
|
146
|
-
this.clearedError = true;
|
|
147
|
-
}
|
|
148
|
-
// null if done, set if we need another flush
|
|
149
|
-
this.batch = resumeBatch;
|
|
150
|
-
if (last_op == null) {
|
|
151
|
-
throw new ReplicationAssertionError('Unexpected last_op == null');
|
|
152
|
-
}
|
|
153
|
-
this.recordPersistedOp(last_op);
|
|
154
|
-
this.last_flushed_op = last_op;
|
|
155
|
-
await this.hooks?.afterBatchFlush?.(this);
|
|
156
|
-
return { flushed_op: last_op };
|
|
157
|
-
}
|
|
158
|
-
async replicateBatch(session, batch, op_seq, options) {
|
|
159
|
-
let sizes = undefined;
|
|
160
|
-
using _ = this.tracer.span('storage', 'replicate_batch');
|
|
161
|
-
// Only look up current_data sizes if the batch stores current_data and at least one
|
|
162
|
-
// table in it does too (per-table can disable it, e.g. Postgres REPLICA IDENTITY FULL).
|
|
163
|
-
const anyTableStoresCurrentData = this.storeCurrentData && batch.batch.some((r) => r.record.sourceTable.storeCurrentData);
|
|
164
|
-
if (anyTableStoresCurrentData && !this.skipExistingRows) {
|
|
165
|
-
// We skip this step if no tables store current_data, since the sizes will
|
|
166
|
-
// always be small in that case.
|
|
167
|
-
// With skipExistingRows, we don't load the full documents into memory,
|
|
168
|
-
// so we can also skip the size lookup step.
|
|
169
|
-
// Find sizes of current_data documents, to assist in intelligent batching without
|
|
170
|
-
// exceeding memory limits.
|
|
171
|
-
//
|
|
172
|
-
// A previous attempt tried to do batching by the results of the current_data query
|
|
173
|
-
// (automatically limited to 48MB(?) per batch by MongoDB). The issue is that it changes
|
|
174
|
-
// the order of processing, which then becomes really tricky to manage.
|
|
175
|
-
// This now takes 2+ queries, but doesn't have any issues with order of operations.
|
|
176
|
-
// Within this branch this.storeCurrentData is true, so the per-table flag is the
|
|
177
|
-
// effective value - only look up sizes for tables that actually store current_data.
|
|
178
|
-
const sizeLookups = batch.batch
|
|
179
|
-
.filter((r) => r.record.sourceTable.storeCurrentData)
|
|
180
|
-
.map((r) => ({
|
|
181
|
-
sourceTableId: mongoTableId(r.record.sourceTable.id),
|
|
182
|
-
replicaId: r.beforeId
|
|
183
|
-
}));
|
|
184
|
-
sizes = await this.sourceRecordStore.loadSizes(session, sizeLookups);
|
|
185
|
-
}
|
|
186
|
-
// If set, we need to start a new transaction with this batch.
|
|
187
|
-
let resumeBatch = null;
|
|
188
|
-
let transactionSize = 0;
|
|
189
|
-
let didFlush = false;
|
|
190
|
-
// Now batch according to the sizes
|
|
191
|
-
// This is a single batch if storeCurrentData == false
|
|
192
|
-
for await (let b of batch.batched(sizes)) {
|
|
193
|
-
if (resumeBatch) {
|
|
194
|
-
for (let op of b) {
|
|
195
|
-
resumeBatch.push(op);
|
|
196
|
-
}
|
|
197
|
-
continue;
|
|
198
|
-
}
|
|
199
|
-
using lookupSpan = this.tracer.span('storage', 'lookup');
|
|
200
|
-
const lookups = b.map((r) => ({
|
|
201
|
-
sourceTableId: mongoTableId(r.record.sourceTable.id),
|
|
202
|
-
replicaId: r.beforeId
|
|
203
|
-
}));
|
|
204
|
-
let sourceRecordLookup = await this.sourceRecordStore.loadDocuments(session, lookups, this.skipExistingRows);
|
|
205
|
-
lookupSpan.end();
|
|
206
|
-
let persistedBatch = this.createPersistedBatch(transactionSize);
|
|
207
|
-
// The current code structure makes it tricky to cleanly split this span from the one
|
|
208
|
-
// where fluhsing. So we manually end and re-create this span whenever we flush.
|
|
209
|
-
let evalSpan = this.tracer.span('evaluate');
|
|
210
|
-
for (let op of b) {
|
|
211
|
-
if (resumeBatch) {
|
|
212
|
-
resumeBatch.push(op);
|
|
213
|
-
continue;
|
|
214
|
-
}
|
|
215
|
-
const sourceRecord = sourceRecordLookup.get(op.internalBeforeKey) ?? null;
|
|
216
|
-
if (sourceRecord != null) {
|
|
217
|
-
// If it will be used again later, it will be set again using nextData below
|
|
218
|
-
sourceRecordLookup.delete(op.internalBeforeKey);
|
|
219
|
-
}
|
|
220
|
-
const nextData = this.saveOperation(persistedBatch, op, sourceRecord, op_seq);
|
|
221
|
-
if (nextData != null) {
|
|
222
|
-
// Update our current_data and size cache
|
|
223
|
-
sourceRecordLookup.set(op.internalAfterKey, nextData);
|
|
224
|
-
sizes?.set(op.internalAfterKey, nextData.data?.length() ?? 0);
|
|
225
|
-
}
|
|
226
|
-
if (persistedBatch.shouldFlushTransaction()) {
|
|
227
|
-
evalSpan.end();
|
|
228
|
-
// Transaction is getting big.
|
|
229
|
-
// Flush, and resume in a new transaction.
|
|
230
|
-
using persistSpan = this.tracer.span('storage', 'persist_flush');
|
|
231
|
-
const { flushedAny } = await persistedBatch.flush(this.session, options);
|
|
232
|
-
didFlush ||= flushedAny;
|
|
233
|
-
persistedBatch = null;
|
|
234
|
-
// Computing our current progress is a little tricky here, since
|
|
235
|
-
// we're stopping in the middle of a batch.
|
|
236
|
-
// We create a new batch, and push any remaining operations to it.
|
|
237
|
-
resumeBatch = new OperationBatch();
|
|
238
|
-
persistSpan.end();
|
|
239
|
-
evalSpan = this.tracer.span('evaluate');
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
evalSpan.end();
|
|
243
|
-
if (persistedBatch) {
|
|
244
|
-
transactionSize = persistedBatch.currentSize;
|
|
245
|
-
using _ = this.tracer.span('storage', 'persist_flush');
|
|
246
|
-
const { flushedAny } = await persistedBatch.flush(this.session, options);
|
|
247
|
-
didFlush ||= flushedAny;
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
const clearedError = didFlush && !this.clearedError;
|
|
251
|
-
if (clearedError) {
|
|
252
|
-
// No need to clear an error more than once per batch, since an error would always result in restarting the batch.
|
|
253
|
-
using _ = this.tracer.span('storage', 'clear_error');
|
|
254
|
-
await this.clearError(this.session);
|
|
255
|
-
}
|
|
256
|
-
return {
|
|
257
|
-
resumeBatch: resumeBatch?.hasData() ? resumeBatch : null,
|
|
258
|
-
clearedError
|
|
259
|
-
};
|
|
260
|
-
}
|
|
261
|
-
saveOperation(batch, operation, sourceRecord, opSeq) {
|
|
145
|
+
/**
|
|
146
|
+
* Evaluate one source operation and append its storage writes to batch.
|
|
147
|
+
*
|
|
148
|
+
* Diagnostics run during evaluation. V1 calls markRecordUnavailable immediately;
|
|
149
|
+
* v3/v4 supply a collector and deliver repair notifications only after a
|
|
150
|
+
* confirmed publication. A failed row attempt may have registered a repair,
|
|
151
|
+
* so its caller must discard that row's collected notification on failure.
|
|
152
|
+
*/
|
|
153
|
+
saveOperation(batch, operation, sourceRecord, opSeq, onRecordUnavailable) {
|
|
262
154
|
const record = operation.record;
|
|
263
155
|
const beforeId = operation.beforeId;
|
|
264
156
|
const afterId = operation.afterId;
|
|
@@ -292,18 +184,6 @@ export class MongoBucketBatch extends BaseObserver {
|
|
|
292
184
|
// Not an error if we re-apply a transaction
|
|
293
185
|
existing_buckets = [];
|
|
294
186
|
existing_lookups = [];
|
|
295
|
-
if (!isCompleteRow(storeCurrentData, after)) {
|
|
296
|
-
if (this.markRecordUnavailable != null) {
|
|
297
|
-
// This will trigger a "resnapshot" of the record.
|
|
298
|
-
// This is not relevant if storeCurrentData is false, since we'll get the full row
|
|
299
|
-
// directly in the replication stream.
|
|
300
|
-
this.markRecordUnavailable(record);
|
|
301
|
-
}
|
|
302
|
-
else {
|
|
303
|
-
// Log to help with debugging if there was a consistency issue
|
|
304
|
-
this.logger.warn(`Cannot find previous record for update on ${record.sourceTable.qualifiedName}: ${beforeId} / ${record.before?.id}`);
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
187
|
}
|
|
308
188
|
else {
|
|
309
189
|
existing_buckets = result.buckets;
|
|
@@ -313,6 +193,23 @@ export class MongoBucketBatch extends BaseObserver {
|
|
|
313
193
|
after = storage.mergeToast(after, data);
|
|
314
194
|
}
|
|
315
195
|
}
|
|
196
|
+
if (!isCompleteRow(storeCurrentData, after)) {
|
|
197
|
+
if (onRecordUnavailable != null) {
|
|
198
|
+
// This will trigger a "resnapshot" of the record.
|
|
199
|
+
// This is not relevant if storeCurrentData is false, since we'll get the full row
|
|
200
|
+
// directly in the replication stream.
|
|
201
|
+
// Check after merging, including existing incomplete rows/tombstones.
|
|
202
|
+
// A flush can commit before a process dies or notification delivery
|
|
203
|
+
// fails. Its source position has not advanced, so WAL replay must
|
|
204
|
+
// request repair again rather than treating mere record existence as
|
|
205
|
+
// proof that the missing TOAST values have been recovered.
|
|
206
|
+
onRecordUnavailable(record);
|
|
207
|
+
}
|
|
208
|
+
else if (result == null) {
|
|
209
|
+
// Log to help with debugging if there was a consistency issue
|
|
210
|
+
this.logger.warn(`Cannot find previous record for update on ${record.sourceTable.qualifiedName}: ${beforeId} / ${record.before?.id}`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
316
213
|
}
|
|
317
214
|
else if (record.tag == SaveOperationTag.DELETE) {
|
|
318
215
|
const result = sourceRecord;
|
|
@@ -323,7 +220,7 @@ export class MongoBucketBatch extends BaseObserver {
|
|
|
323
220
|
// Log to help with debugging if there was a consistency issue.
|
|
324
221
|
// Gate on the batch-level flag: FULL tables (per-record flag false) still get a
|
|
325
222
|
// current_data entry, so a missing record on DELETE is meaningful for them too.
|
|
326
|
-
if (this.storeCurrentData &&
|
|
223
|
+
if (this.storeCurrentData && onRecordUnavailable == null) {
|
|
327
224
|
this.logger.warn(`Cannot find previous record for delete on ${record.sourceTable.qualifiedName}: ${beforeId} / ${record.before?.id}`);
|
|
328
225
|
}
|
|
329
226
|
}
|
|
@@ -483,21 +380,44 @@ export class MongoBucketBatch extends BaseObserver {
|
|
|
483
380
|
async withTransaction(cb, session = this.session) {
|
|
484
381
|
return this.withFencedTransaction(() => this.fence(session), cb, session);
|
|
485
382
|
}
|
|
486
|
-
/**
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
383
|
+
/**
|
|
384
|
+
* Internal transaction helper: the public writer operation already holds the
|
|
385
|
+
* mutex. The first operation must modify the stream document conditional on
|
|
386
|
+
* lease ownership. This database fence is required on every retry even though
|
|
387
|
+
* local writers are serialized; another process may have taken over the lease.
|
|
388
|
+
*/
|
|
389
|
+
async withFencedTransaction(acquireFence, cb, session = this.session, description) {
|
|
390
|
+
this.options.replicationLock.throwIfAborted();
|
|
391
|
+
let attempts = 0;
|
|
392
|
+
// Includes driver commit retries and the delays between transaction attempts.
|
|
393
|
+
using _transactionSpan = this.tracer.span('storage', 'transaction');
|
|
394
|
+
const result = await session.withTransaction(async () => {
|
|
395
|
+
attempts++;
|
|
396
|
+
// One callback invocation per transaction attempt. Commit happens after
|
|
397
|
+
// this span; command monitoring is needed to measure the full wire lifetime.
|
|
398
|
+
using attemptSpan = this.tracer.span('storage', 'transaction_body');
|
|
490
399
|
try {
|
|
400
|
+
const stream = await acquireFence();
|
|
491
401
|
const result = await cb(stream);
|
|
492
402
|
this.options.replicationLock.throwIfAborted();
|
|
493
403
|
return result;
|
|
494
404
|
}
|
|
495
405
|
catch (e) {
|
|
406
|
+
attemptSpan.end();
|
|
496
407
|
if (e instanceof OpIdRangeExhausted) {
|
|
497
408
|
throw e;
|
|
498
409
|
}
|
|
499
410
|
if (e instanceof mongo.MongoError && e.hasErrorLabel('TransientTransactionError')) {
|
|
500
|
-
//
|
|
411
|
+
// Log every tenth attempt, including failures of the fence, without
|
|
412
|
+
// logging a stack or bulk result for every driver retry.
|
|
413
|
+
if ((attempts + 1) % 10 === 0) {
|
|
414
|
+
this.logger.info(`${description ?? 'Replication transaction'} - try ${attempts + 1}`, {
|
|
415
|
+
error: e.message,
|
|
416
|
+
code: e.code,
|
|
417
|
+
codeName: e instanceof mongo.MongoServerError ? e.codeName : undefined,
|
|
418
|
+
errorLabels: e.errorLabels
|
|
419
|
+
});
|
|
420
|
+
}
|
|
501
421
|
}
|
|
502
422
|
else {
|
|
503
423
|
this.logger.warn('Transaction error', e);
|
|
@@ -507,19 +427,28 @@ export class MongoBucketBatch extends BaseObserver {
|
|
|
507
427
|
await timers.setTimeout(delay);
|
|
508
428
|
throw e;
|
|
509
429
|
}
|
|
510
|
-
}, { maxCommitTimeMS: 10000,
|
|
430
|
+
}, { maxCommitTimeMS: 10000, ...REPLICATION_WRITE_OPTIONS });
|
|
431
|
+
return result;
|
|
511
432
|
}
|
|
433
|
+
/**
|
|
434
|
+
* Public database operations acquire the lease's mutex once, then call only
|
|
435
|
+
* internal helpers until they finish. Those helpers must never call a public
|
|
436
|
+
* database operation or reacquire this mutex. All session use, including reads,
|
|
437
|
+
* belongs inside this boundary so it cannot join another writer's transaction.
|
|
438
|
+
* Transaction helpers still fence database ownership on every attempt.
|
|
439
|
+
*/
|
|
512
440
|
async withWriterLock(callback) {
|
|
513
441
|
using lockSpan = this.tracer.span('storage', 'internal_lock');
|
|
442
|
+
this.options.replicationLock.throwIfAborted();
|
|
514
443
|
return this.options.replicationLock.writerMutex.exclusiveLock(async () => {
|
|
515
444
|
lockSpan.end();
|
|
516
445
|
this.options.replicationLock.throwIfAborted();
|
|
517
446
|
return callback();
|
|
518
447
|
});
|
|
519
448
|
}
|
|
520
|
-
/**
|
|
521
|
-
async updateStreamMetadata(set, filter = {}, writeConcern =
|
|
522
|
-
await this.
|
|
449
|
+
/** Internal single-document update; the public operation already holds the mutex. */
|
|
450
|
+
async updateStreamMetadata(set, filter = {}, writeConcern = REPLICATION_WRITE_OPTIONS.writeConcern) {
|
|
451
|
+
await this.writeStreamMetadata(set, filter, writeConcern);
|
|
523
452
|
}
|
|
524
453
|
/** Caller must already be inside a fenced transaction holding the writer mutex. */
|
|
525
454
|
async updateStreamMetadataInTransaction(set, filter = {}) {
|
|
@@ -528,70 +457,9 @@ export class MongoBucketBatch extends BaseObserver {
|
|
|
528
457
|
async writeStreamMetadata(set, filter, writeConcern) {
|
|
529
458
|
const result = await this.db.sync_rules.updateOne({ ...filter, ...MongoSyncRulesLock.ownerFilter(this.replicationStreamId, this.options.replicationLock) }, [{ $set: { ...set, ...MongoSyncRulesLock.heartbeatUpdate() } }], { session: this.session, ...(writeConcern == null ? {} : { writeConcern }) });
|
|
530
459
|
if (result.matchedCount === 0) {
|
|
531
|
-
throw new ReplicationAbortedError('Replication writer
|
|
532
|
-
}
|
|
533
|
-
}
|
|
534
|
-
async withReplicationTransaction(description, callback, finish, projection) {
|
|
535
|
-
let flushTry = 0;
|
|
536
|
-
const lastTry = Date.now() + 90000;
|
|
537
|
-
const allocator = this.options.opIdAllocator;
|
|
538
|
-
let lastOp = 0n;
|
|
539
|
-
// Refill outside the publication transaction, before evaluating any rows.
|
|
540
|
-
// Exhaustion remains a fallback for large batches or a newer stream head.
|
|
541
|
-
this.options.replicationLock.throwIfAborted();
|
|
542
|
-
await allocator.ensureCapacity();
|
|
543
|
-
for (;;) {
|
|
544
|
-
try {
|
|
545
|
-
await this.withFencedTransaction(() => this.fence(this.session, projection), async (stream) => {
|
|
546
|
-
flushTry += 1;
|
|
547
|
-
if (flushTry % 10 == 0) {
|
|
548
|
-
this.logger.info(`${description} - try ${flushTry}`);
|
|
549
|
-
}
|
|
550
|
-
if (flushTry > 20 && Date.now() > lastTry) {
|
|
551
|
-
throw new ServiceError(ErrorCode.PSYNC_S1402, 'Max transaction tries exceeded');
|
|
552
|
-
}
|
|
553
|
-
// The fence already holds this stream document for the transaction.
|
|
554
|
-
// A different writer may have used higher IDs since our last flush.
|
|
555
|
-
const opSeq = allocator.sequence(this.persistedOpHead(stream));
|
|
556
|
-
await callback(this.session, opSeq);
|
|
557
|
-
lastOp = opSeq.last();
|
|
558
|
-
if (!(await finish?.(stream, lastOp))) {
|
|
559
|
-
const writes = this.db.createWriteBatch(this.session, { ordered: false });
|
|
560
|
-
// Allow subclasses to persist additional flush-time state in the same transaction.
|
|
561
|
-
this.onReplicationTransactionFlush(writes, lastOp);
|
|
562
|
-
await writes.execute();
|
|
563
|
-
}
|
|
564
|
-
// Notifications and cleanup must wait until the transaction commits.
|
|
565
|
-
});
|
|
566
|
-
allocator.committed(lastOp);
|
|
567
|
-
return;
|
|
568
|
-
}
|
|
569
|
-
catch (error) {
|
|
570
|
-
if (!(error instanceof OpIdRangeExhausted)) {
|
|
571
|
-
throw error;
|
|
572
|
-
}
|
|
573
|
-
// withTransaction has aborted every write. Reserve outside that transaction,
|
|
574
|
-
// then replay evaluation from the same committed stream head. Existing
|
|
575
|
-
// ranges can be reused on retry because no operation from this attempt committed.
|
|
576
|
-
this.options.replicationLock.throwIfAborted();
|
|
577
|
-
await allocator.reserve();
|
|
578
|
-
}
|
|
460
|
+
throw new ReplicationAbortedError('Replication writer lost its lease or metadata target changed');
|
|
579
461
|
}
|
|
580
462
|
}
|
|
581
|
-
/**
|
|
582
|
-
* Called after a replication transaction has successfully committed, with the last persisted op id.
|
|
583
|
-
*
|
|
584
|
-
* v1 storage tracks this in memory to fold into the next checkpoint. v3 storage does not need it:
|
|
585
|
-
* the stream-level `last_persisted_op` is already advanced durably by
|
|
586
|
-
* {@link onReplicationTransactionFlush} or the combined checkpoint update within the same
|
|
587
|
-
* transaction, and empty commits read it from the document.
|
|
588
|
-
*
|
|
589
|
-
* Keep calls to this adjacent to {@link withReplicationTransaction} usage - both must observe
|
|
590
|
-
* every path that persists ops.
|
|
591
|
-
*/
|
|
592
|
-
recordPersistedOp(_lastOp) {
|
|
593
|
-
// No-op by default.
|
|
594
|
-
}
|
|
595
463
|
async [Symbol.asyncDispose]() {
|
|
596
464
|
if (this.batch?.hasData() || this.write_checkpoint_batch.length > 0) {
|
|
597
465
|
// We don't error here, since:
|
|
@@ -599,7 +467,7 @@ export class MongoBucketBatch extends BaseObserver {
|
|
|
599
467
|
// 2. SuppressedError is messy to deal with.
|
|
600
468
|
this.logger.warn('Disposing writer with unflushed changes');
|
|
601
469
|
}
|
|
602
|
-
|
|
470
|
+
// The lease owns the shared session and ends it after all writers stop.
|
|
603
471
|
super.clearListeners();
|
|
604
472
|
}
|
|
605
473
|
async dispose() {
|
|
@@ -648,8 +516,10 @@ export class MongoBucketBatch extends BaseObserver {
|
|
|
648
516
|
* Drop is equivalent to TRUNCATE, plus removing our record of the table.
|
|
649
517
|
*/
|
|
650
518
|
async drop(sourceTables) {
|
|
651
|
-
|
|
652
|
-
|
|
519
|
+
return this.withWriterLock(() => this.dropInternal(sourceTables));
|
|
520
|
+
}
|
|
521
|
+
async dropInternal(sourceTables) {
|
|
522
|
+
await this.truncateInternal(sourceTables);
|
|
653
523
|
await this.withTransaction(async () => {
|
|
654
524
|
for (let table of sourceTables) {
|
|
655
525
|
await this.db
|
|
@@ -658,16 +528,18 @@ export class MongoBucketBatch extends BaseObserver {
|
|
|
658
528
|
}
|
|
659
529
|
});
|
|
660
530
|
await this.cleanupDroppedSourceTables(sourceTables);
|
|
661
|
-
return
|
|
531
|
+
return null;
|
|
662
532
|
}
|
|
663
533
|
async truncate(sourceTables) {
|
|
664
|
-
|
|
534
|
+
return this.withWriterLock(() => this.truncateInternal(sourceTables));
|
|
535
|
+
}
|
|
536
|
+
async truncateInternal(sourceTables) {
|
|
537
|
+
await this.flushInternal();
|
|
665
538
|
let last_op = null;
|
|
666
539
|
for (let table of sourceTables) {
|
|
667
|
-
last_op = await this.
|
|
540
|
+
last_op = await this.truncateSingleInternal(table);
|
|
668
541
|
}
|
|
669
542
|
if (last_op) {
|
|
670
|
-
this.recordPersistedOp(last_op);
|
|
671
543
|
return {
|
|
672
544
|
flushed_op: last_op
|
|
673
545
|
};
|
|
@@ -677,45 +549,12 @@ export class MongoBucketBatch extends BaseObserver {
|
|
|
677
549
|
}
|
|
678
550
|
}
|
|
679
551
|
async truncateSingle(sourceTable) {
|
|
680
|
-
|
|
681
|
-
// To avoid too large transactions, we limit the amount of data we delete per transaction.
|
|
682
|
-
// Since we don't use the record data here, we don't have explicit size limits per batch.
|
|
683
|
-
const BATCH_LIMIT = 2000;
|
|
684
|
-
let lastBatchCount = BATCH_LIMIT;
|
|
685
|
-
while (lastBatchCount == BATCH_LIMIT) {
|
|
686
|
-
await this.withReplicationTransaction(`Truncate ${sourceTable.qualifiedName}`, async (session, opSeq) => {
|
|
687
|
-
using evalSpan = this.tracer.span('evaluate');
|
|
688
|
-
const sourceTableId = mongoTableId(sourceTable.id);
|
|
689
|
-
const batch = await this.sourceRecordStore.loadTruncateBatch(session, sourceTableId, BATCH_LIMIT);
|
|
690
|
-
const persistedBatch = this.createPersistedBatch(0);
|
|
691
|
-
for (let value of batch) {
|
|
692
|
-
persistedBatch.saveBucketData({
|
|
693
|
-
op_seq: opSeq,
|
|
694
|
-
before_buckets: value.buckets,
|
|
695
|
-
evaluated: [],
|
|
696
|
-
table: sourceTable,
|
|
697
|
-
sourceKey: value.replicaId
|
|
698
|
-
});
|
|
699
|
-
persistedBatch.saveParameterData({
|
|
700
|
-
op_seq: opSeq,
|
|
701
|
-
existing_lookups: value.lookups,
|
|
702
|
-
evaluated: [],
|
|
703
|
-
sourceTable: sourceTable,
|
|
704
|
-
sourceKey: value.replicaId
|
|
705
|
-
});
|
|
706
|
-
// Since this is not from streaming replication, we can do a hard delete
|
|
707
|
-
persistedBatch.hardDeleteCurrentData(sourceTableId, value.replicaId);
|
|
708
|
-
}
|
|
709
|
-
evalSpan.end();
|
|
710
|
-
using _ = this.tracer.span('storage', 'persist_flush');
|
|
711
|
-
await persistedBatch.flush(session);
|
|
712
|
-
lastBatchCount = batch.length;
|
|
713
|
-
last_op = opSeq.last();
|
|
714
|
-
});
|
|
715
|
-
}
|
|
716
|
-
return last_op;
|
|
552
|
+
return this.withWriterLock(() => this.truncateSingleInternal(sourceTable));
|
|
717
553
|
}
|
|
718
554
|
async updateTableProgress(table, progress) {
|
|
555
|
+
return this.withWriterLock(() => this.updateTableProgressInternal(table, progress));
|
|
556
|
+
}
|
|
557
|
+
async updateTableProgressInternal(table, progress) {
|
|
719
558
|
const copy = table.clone();
|
|
720
559
|
const snapshotStatus = {
|
|
721
560
|
totalEstimatedCount: progress.totalEstimatedCount ?? copy.snapshotStatus?.totalEstimatedCount ?? 0,
|
|
@@ -736,18 +575,6 @@ export class MongoBucketBatch extends BaseObserver {
|
|
|
736
575
|
});
|
|
737
576
|
return copy;
|
|
738
577
|
}
|
|
739
|
-
async clearError(session) {
|
|
740
|
-
await this.db.sync_rules.updateOne({
|
|
741
|
-
_id: this.replicationStreamId
|
|
742
|
-
}, {
|
|
743
|
-
$set: {
|
|
744
|
-
last_fatal_error: null,
|
|
745
|
-
last_fatal_error_ts: null
|
|
746
|
-
}
|
|
747
|
-
}, {
|
|
748
|
-
session
|
|
749
|
-
});
|
|
750
|
-
}
|
|
751
578
|
/**
|
|
752
579
|
* Gets relevant {@link HydratedEventDescriptor}s for the given {@link SourceTable}
|
|
753
580
|
*/
|