@powersync/service-module-mongodb-storage 0.23.1 → 0.24.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/CHANGELOG.md +42 -0
- package/LICENSE +201 -67
- package/dist/storage/MongoBucketStorage.d.ts +1 -0
- package/dist/storage/MongoBucketStorage.js +3 -0
- 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 +4 -0
- 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 +83 -2
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -1,16 +1,24 @@
|
|
|
1
1
|
import * as lib_mongo from '@powersync/lib-service-mongodb';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
ErrorCode,
|
|
4
|
+
ReplicationAbortedError,
|
|
5
|
+
ReplicationAssertionError,
|
|
6
|
+
ServiceAssertionError,
|
|
7
|
+
ServiceError
|
|
8
|
+
} from '@powersync/lib-services-framework';
|
|
3
9
|
import { InternalOpId, storage } from '@powersync/service-core';
|
|
4
10
|
import { EventDefinitionId } from '@powersync/service-sync-rules';
|
|
5
11
|
import * as bson from 'bson';
|
|
6
12
|
import { mongoTableId } from '../../../utils/util.js';
|
|
7
13
|
import { canCheckpointState } from '../CheckpointState.js';
|
|
8
14
|
import { MongoBucketBatch, MongoBucketBatchOptions } from '../MongoBucketBatch.js';
|
|
15
|
+
import { OpIdRangeExhausted } from '../MongoIdSequence.js';
|
|
9
16
|
import { MongoParsedSyncConfigSet } from '../MongoParsedSyncConfigSet.js';
|
|
10
17
|
import { MongoWriteBatch } from '../MongoWriteBatch.js';
|
|
18
|
+
import { OperationBatch } from '../OperationBatch.js';
|
|
11
19
|
import { stopReplicationStreamPipeline } from '../SyncRuleStateUpdate.js';
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
20
|
+
import { PreparedPublication } from '../common/PreparedPublication.js';
|
|
21
|
+
import { LoadedSourceRecord } from '../common/SourceRecordStore.js';
|
|
14
22
|
import { SyncRuleDocumentBase } from '../models.js';
|
|
15
23
|
import { PersistedBatchV3 } from './PersistedBatchV3.js';
|
|
16
24
|
import { SourceRecordStoreV3 } from './SourceRecordStoreV3.js';
|
|
@@ -28,7 +36,7 @@ import {
|
|
|
28
36
|
export class MongoBucketBatchV3 extends MongoBucketBatch {
|
|
29
37
|
declare readonly db: VersionedPowerSyncMongoV3;
|
|
30
38
|
|
|
31
|
-
private readonly store:
|
|
39
|
+
private readonly store: SourceRecordStoreV3;
|
|
32
40
|
/** Avoid repeating idempotent createIndexes calls for every checkpoint flush. */
|
|
33
41
|
private readonly initializedCustomCheckpointEventIds = new Set<EventDefinitionId>();
|
|
34
42
|
// Used only to validate supplied checkpoint ids; it does not initialize collections.
|
|
@@ -48,7 +56,7 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
|
|
|
48
56
|
this.store = new SourceRecordStoreV3(this.db, this.replicationStreamId, this.mapping);
|
|
49
57
|
}
|
|
50
58
|
|
|
51
|
-
protected createPersistedBatch(writtenSize: number):
|
|
59
|
+
protected createPersistedBatch(writtenSize: number): PersistedBatchV3 {
|
|
52
60
|
return new PersistedBatchV3(this.db, this.replicationStreamId, this.mapping, writtenSize, {
|
|
53
61
|
logger: this.logger,
|
|
54
62
|
objectStorage: this.options.objectStorage,
|
|
@@ -58,10 +66,374 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
|
|
|
58
66
|
});
|
|
59
67
|
}
|
|
60
68
|
|
|
61
|
-
protected get sourceRecordStore():
|
|
69
|
+
protected get sourceRecordStore(): SourceRecordStoreV3 {
|
|
62
70
|
return this.store;
|
|
63
71
|
}
|
|
64
72
|
|
|
73
|
+
protected override async withTransaction<T>(
|
|
74
|
+
callback: (stream: SyncRuleDocumentBase) => Promise<T>,
|
|
75
|
+
session = this.session
|
|
76
|
+
): Promise<T> {
|
|
77
|
+
return this.withFencedTransaction(
|
|
78
|
+
() => this.fence(session, { state: 1, sync_configs: 1 }),
|
|
79
|
+
async (stream) => {
|
|
80
|
+
this.assertPreparationContext(stream as ReplicationStreamDocumentV3);
|
|
81
|
+
return callback(stream);
|
|
82
|
+
},
|
|
83
|
+
session
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
protected override async flushInner(
|
|
88
|
+
options?: storage.BatchBucketFlushOptions,
|
|
89
|
+
checkpoint?: (stream: SyncRuleDocumentBase, lastOp: InternalOpId, writes?: MongoWriteBatch) => Promise<void>,
|
|
90
|
+
projection?: lib_mongo.mongo.Document
|
|
91
|
+
): Promise<storage.FlushedResult | null> {
|
|
92
|
+
using _span = this.tracer.span('storage', 'flush');
|
|
93
|
+
await this.hooks?.beforeBatchFlush?.(this);
|
|
94
|
+
const input = this.batch;
|
|
95
|
+
const checkpoints = this.write_checkpoint_batch.slice();
|
|
96
|
+
const allocator = this.options.opIdAllocator;
|
|
97
|
+
await allocator.ensureCapacity();
|
|
98
|
+
const contextProjection = { ...projection, state: 1, last_persisted_op: 1, sync_configs: 1 };
|
|
99
|
+
if (checkpoints.length > 0) {
|
|
100
|
+
await this.prepareCustomWriteCheckpoints();
|
|
101
|
+
}
|
|
102
|
+
const prepared = await this.prepareRows(this.session, input);
|
|
103
|
+
let lastOp = prepared.lastOp;
|
|
104
|
+
let checkpointOp: bigint | undefined;
|
|
105
|
+
if (checkpoints.length > 0) {
|
|
106
|
+
while (true) {
|
|
107
|
+
try {
|
|
108
|
+
checkpointOp = allocator.sequence(lastOp).next();
|
|
109
|
+
lastOp = checkpointOp;
|
|
110
|
+
break;
|
|
111
|
+
} catch (error) {
|
|
112
|
+
if (!(error instanceof OpIdRangeExhausted)) {
|
|
113
|
+
throw error;
|
|
114
|
+
}
|
|
115
|
+
await allocator.reserve();
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// Reservations are globally disjoint and this lease's allocator consumes IDs
|
|
120
|
+
// assigned to sealed work, even if publication is abandoned. The writer mutex
|
|
121
|
+
// excludes other writers under this lease during preparation and publication;
|
|
122
|
+
// the transaction fence excludes a different lease owner. Thus preparation
|
|
123
|
+
// does not need to read the persisted head to allocate IDs or reuse records.
|
|
124
|
+
allocator.consume(lastOp);
|
|
125
|
+
const clearError = !this.clearedError && prepared.hasWrites;
|
|
126
|
+
await this.publishPrepared(lastOp, contextProjection, prepared.publications, options, async (stream, writes) => {
|
|
127
|
+
// An empty flush allocates no ID. Use the durable head from the
|
|
128
|
+
// fresh fence for its checkpoint and returned flushed operation.
|
|
129
|
+
if (lastOp === 0n) {
|
|
130
|
+
lastOp = this.persistedOpHead(stream);
|
|
131
|
+
}
|
|
132
|
+
if (clearError) {
|
|
133
|
+
writes.updateOne(
|
|
134
|
+
this.db.sync_rules,
|
|
135
|
+
{ _id: this.replicationStreamId },
|
|
136
|
+
{
|
|
137
|
+
$set: { last_fatal_error: null, last_fatal_error_ts: null }
|
|
138
|
+
}
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
if (checkpointOp != null) {
|
|
142
|
+
this.queueCustomWriteCheckpoints(writes, checkpointOp, checkpoints);
|
|
143
|
+
}
|
|
144
|
+
if (checkpoint != null && prepared.resumeBatch == null) {
|
|
145
|
+
// Checkpoint decisions use this attempt's freshly fenced state.
|
|
146
|
+
await checkpoint(stream, lastOp, writes);
|
|
147
|
+
} else {
|
|
148
|
+
this.onReplicationTransactionFlush(writes, lastOp);
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
this.batch = prepared.resumeBatch;
|
|
152
|
+
this.write_checkpoint_batch.splice(0, checkpoints.length);
|
|
153
|
+
this.clearedError ||= clearError;
|
|
154
|
+
this.last_flushed_op = lastOp;
|
|
155
|
+
// markRecordUnavailable only synchronously queues connector-owned repair
|
|
156
|
+
// keys. It must not mutate input, perform async work, or re-enter storage:
|
|
157
|
+
// the writer mutex is still held. Delivery must finish before flush() returns;
|
|
158
|
+
// WalStream drains that queue before commit()/ack().
|
|
159
|
+
// Once committed, these writes cannot be rolled back if the callback
|
|
160
|
+
// throws. Invalidate the whole local lease while the mutex is held, so
|
|
161
|
+
// no writer sharing it (including queued calls) can advance progress
|
|
162
|
+
// past an undelivered repair. A fresh replication attempt must replay
|
|
163
|
+
// from the old source position and redetect incomplete rows.
|
|
164
|
+
try {
|
|
165
|
+
const notify = this.markRecordUnavailable;
|
|
166
|
+
if (notify != null) {
|
|
167
|
+
for (const record of prepared.repairs) {
|
|
168
|
+
notify(record);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
} catch (error) {
|
|
172
|
+
this.options.replicationLock.invalidate(
|
|
173
|
+
new ReplicationAbortedError(
|
|
174
|
+
'Committed replication repair notification failed; restart replication before advancing source progress',
|
|
175
|
+
error
|
|
176
|
+
)
|
|
177
|
+
);
|
|
178
|
+
throw error;
|
|
179
|
+
}
|
|
180
|
+
await this.hooks?.afterBatchFlush?.(this);
|
|
181
|
+
return { flushed_op: lastOp };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private assertPreparationContext(stream: ReplicationStreamDocumentV3): void {
|
|
185
|
+
if ([storage.SyncRuleState.STOP, storage.SyncRuleState.TERMINATED].includes(stream.state)) {
|
|
186
|
+
throw new ReplicationAbortedError('Replication stream has stopped');
|
|
187
|
+
}
|
|
188
|
+
this.options.parsedSyncConfig.assertPublicationConfigs(stream.sync_configs);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Internal publisher: the public operation holds the mutex through commit.
|
|
193
|
+
* finish() must only queue writes independent of the final data group, using
|
|
194
|
+
* the fresh fence and planned IDs; it must not execute the supplied batch or
|
|
195
|
+
* read results from unpublished data. Activation and cleanup remain separate.
|
|
196
|
+
*/
|
|
197
|
+
private async publishPrepared(
|
|
198
|
+
lastOp: InternalOpId,
|
|
199
|
+
projection: lib_mongo.mongo.Document,
|
|
200
|
+
publications: PreparedPublication[],
|
|
201
|
+
options: storage.BatchBucketFlushOptions | undefined,
|
|
202
|
+
finish: (stream: ReplicationStreamDocumentV3, writes: MongoWriteBatch) => Promise<void>
|
|
203
|
+
): Promise<void> {
|
|
204
|
+
// This gate covers every sealed batch in the prefix, including truncate/drop.
|
|
205
|
+
// The transaction must never wait for source reads, evaluation or S3 PUTs.
|
|
206
|
+
// Sequential preparation also avoids leaving another group's uploads running
|
|
207
|
+
// when one group fails; each group's readiness joins all of its own uploads.
|
|
208
|
+
for (const publication of publications) {
|
|
209
|
+
await publication.preparePayloads();
|
|
210
|
+
}
|
|
211
|
+
let attempts = 0;
|
|
212
|
+
const retryDeadline = Date.now() + 90_000;
|
|
213
|
+
try {
|
|
214
|
+
await this.withFencedTransaction(
|
|
215
|
+
() => this.fence(this.session, projection),
|
|
216
|
+
async (document) => {
|
|
217
|
+
attempts++;
|
|
218
|
+
if (attempts > 20 && Date.now() > retryDeadline) {
|
|
219
|
+
throw new ServiceError(ErrorCode.PSYNC_S1402, 'Max transaction tries exceeded');
|
|
220
|
+
}
|
|
221
|
+
const stream = document as ReplicationStreamDocumentV3;
|
|
222
|
+
this.assertPreparationContext(stream);
|
|
223
|
+
const head = this.persistedOpHead(stream);
|
|
224
|
+
if (lastOp !== 0n && lastOp <= head) {
|
|
225
|
+
throw new ReplicationAssertionError(
|
|
226
|
+
`Prepared operation ${lastOp} does not advance persisted stream head ${head}`
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
// Earlier groups may replace the same source record or bucket state.
|
|
230
|
+
// Preserve their execution order; only the final group's writes are
|
|
231
|
+
// independent of the head/checkpoint tail built from this fresh fence.
|
|
232
|
+
for (const publication of publications.slice(0, -1)) {
|
|
233
|
+
using _persist = this.tracer.span('storage', 'persist_flush');
|
|
234
|
+
await publication.publish(this.session, options);
|
|
235
|
+
}
|
|
236
|
+
const writes = this.db.createWriteBatch(this.session, { ordered: false });
|
|
237
|
+
await finish(stream, writes);
|
|
238
|
+
const finalPublication = publications.at(-1);
|
|
239
|
+
if (finalPublication != null) {
|
|
240
|
+
using _persist = this.tracer.span('storage', 'persist_flush');
|
|
241
|
+
await finalPublication.publish(this.session, options, writes);
|
|
242
|
+
} else {
|
|
243
|
+
await writes.execute();
|
|
244
|
+
}
|
|
245
|
+
},
|
|
246
|
+
this.session,
|
|
247
|
+
'Publishing prepared work'
|
|
248
|
+
);
|
|
249
|
+
} catch (error) {
|
|
250
|
+
// Publication failed after the driver's retries or our attempt limit.
|
|
251
|
+
// Leave uploaded objects and markers alone: the commit may have succeeded.
|
|
252
|
+
// Stop every writer on this lease and recover from persisted source progress.
|
|
253
|
+
this.options.replicationLock.invalidate(
|
|
254
|
+
new ReplicationAbortedError('Publication failed; restart replication from persisted progress', error)
|
|
255
|
+
);
|
|
256
|
+
throw error;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
private async prepareRows(session: lib_mongo.mongo.ClientSession, input: OperationBatch | null) {
|
|
261
|
+
const publications: PreparedPublication[] = [];
|
|
262
|
+
const repairs: storage.SaveUpdate[] = [];
|
|
263
|
+
let resumeBatch: OperationBatch | null = null;
|
|
264
|
+
let lastOp = 0n;
|
|
265
|
+
let hasWrites = false;
|
|
266
|
+
if (input == null) {
|
|
267
|
+
return { publications, repairs, resumeBatch, lastOp, hasWrites };
|
|
268
|
+
}
|
|
269
|
+
using _preparation = this.tracer.span('storage', 'prepare');
|
|
270
|
+
const allocator = this.options.opIdAllocator;
|
|
271
|
+
let sizes: Map<string, number> | undefined;
|
|
272
|
+
// Keep the existing size-based read partitioning, including per-table raw
|
|
273
|
+
// data storage and snapshot skip-existing exceptions. Move these reads;
|
|
274
|
+
// publication never repeats them as a validation pass.
|
|
275
|
+
if (
|
|
276
|
+
this.options.storeCurrentData &&
|
|
277
|
+
!this.skipExistingRows &&
|
|
278
|
+
input.batch.some((op) => op.record.sourceTable.storeCurrentData)
|
|
279
|
+
) {
|
|
280
|
+
sizes = await this.sourceRecordStore.loadSizes(
|
|
281
|
+
session,
|
|
282
|
+
input.batch
|
|
283
|
+
.filter((op) => op.record.sourceTable.storeCurrentData)
|
|
284
|
+
.map((op) => ({ sourceTableId: mongoTableId(op.record.sourceTable.id), replicaId: op.beforeId }))
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
// Only this bounded publication's observations/evaluated rows are retained.
|
|
288
|
+
// No state is shared with another pending publication or another writer.
|
|
289
|
+
const records = new Map<string, LoadedSourceRecord | null>();
|
|
290
|
+
let writtenSize = 0;
|
|
291
|
+
for (const block of input.batched(sizes)) {
|
|
292
|
+
if (resumeBatch != null) {
|
|
293
|
+
for (const op of block) {
|
|
294
|
+
resumeBatch.push(op);
|
|
295
|
+
}
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
const missing = block.filter((op) => !records.has(op.internalBeforeKey));
|
|
299
|
+
if (missing.length > 0) {
|
|
300
|
+
using _lookup = this.tracer.span('storage', 'lookup');
|
|
301
|
+
const loaded = await this.sourceRecordStore.loadDocuments(
|
|
302
|
+
session,
|
|
303
|
+
missing.map((op) => ({
|
|
304
|
+
sourceTableId: mongoTableId(op.record.sourceTable.id),
|
|
305
|
+
replicaId: op.beforeId
|
|
306
|
+
})),
|
|
307
|
+
this.skipExistingRows
|
|
308
|
+
);
|
|
309
|
+
for (const op of missing) {
|
|
310
|
+
records.set(op.internalBeforeKey, loaded.get(op.internalBeforeKey) ?? null);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
let group: PersistedBatchV3 | null = this.createPersistedBatch(writtenSize);
|
|
314
|
+
for (const op of block) {
|
|
315
|
+
if (resumeBatch != null) {
|
|
316
|
+
resumeBatch.push(op);
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
let next: LoadedSourceRecord | null;
|
|
320
|
+
while (true) {
|
|
321
|
+
const rowRepairs: storage.SaveUpdate[] = [];
|
|
322
|
+
const row = this.createPersistedBatch(0);
|
|
323
|
+
const sequence = allocator.sequence(lastOp);
|
|
324
|
+
try {
|
|
325
|
+
using _evaluate = this.tracer.span('evaluate');
|
|
326
|
+
next = this.saveOperation(
|
|
327
|
+
row,
|
|
328
|
+
op,
|
|
329
|
+
records.get(op.internalBeforeKey) ?? null,
|
|
330
|
+
sequence,
|
|
331
|
+
this.markRecordUnavailable == null
|
|
332
|
+
? undefined
|
|
333
|
+
: (record) => {
|
|
334
|
+
rowRepairs.push(record);
|
|
335
|
+
}
|
|
336
|
+
);
|
|
337
|
+
group!.append(row);
|
|
338
|
+
hasWrites ||= row.currentSize > 0;
|
|
339
|
+
repairs.push(...rowRepairs);
|
|
340
|
+
lastOp = sequence.last();
|
|
341
|
+
break;
|
|
342
|
+
} catch (error) {
|
|
343
|
+
if (!(error instanceof OpIdRangeExhausted)) {
|
|
344
|
+
throw error;
|
|
345
|
+
}
|
|
346
|
+
// Retry only the unfinished row; no payload has been uploaded yet.
|
|
347
|
+
this.options.replicationLock.throwIfAborted();
|
|
348
|
+
await allocator.reserve();
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
if (op.afterId == null || !storage.replicaIdEquals(op.beforeId, op.afterId)) {
|
|
352
|
+
// Preserve evaluation's existing repeated-row behavior: a deleted
|
|
353
|
+
// identity no longer supplies raw values or membership to later rows.
|
|
354
|
+
records.set(op.internalBeforeKey, null);
|
|
355
|
+
}
|
|
356
|
+
if (next != null) {
|
|
357
|
+
records.set(op.internalAfterKey!, next);
|
|
358
|
+
sizes?.set(op.internalAfterKey!, next.data?.length() ?? 0);
|
|
359
|
+
}
|
|
360
|
+
if (group!.shouldFlushTransaction()) {
|
|
361
|
+
publications.push(group!.seal());
|
|
362
|
+
group = null;
|
|
363
|
+
resumeBatch = new OperationBatch();
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
if (group != null) {
|
|
367
|
+
writtenSize = group.currentSize;
|
|
368
|
+
publications.push(group.seal());
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return { publications, repairs, resumeBatch: resumeBatch?.hasData() ? resumeBatch : null, lastOp, hasWrites };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
protected override async truncateSingleInternal(sourceTable: storage.SourceTable): Promise<InternalOpId> {
|
|
375
|
+
const sourceId = mongoTableId(sourceTable.id);
|
|
376
|
+
const session = this.session;
|
|
377
|
+
const projection = { state: 1, sync_configs: 1, last_persisted_op: 1 };
|
|
378
|
+
const allocator = this.options.opIdAllocator;
|
|
379
|
+
while (true) {
|
|
380
|
+
await allocator.ensureCapacity();
|
|
381
|
+
// Preserve the existing bounded table scan. Hard deletion lets the next
|
|
382
|
+
// scan resume at remaining live records without retaining a table overlay.
|
|
383
|
+
const records = await this.sourceRecordStore.loadTruncateBatch(session, sourceId, 2000);
|
|
384
|
+
const group = this.createPersistedBatch(0);
|
|
385
|
+
let lastOp = 0n;
|
|
386
|
+
let processed = 0;
|
|
387
|
+
for (const record of records) {
|
|
388
|
+
while (true) {
|
|
389
|
+
const row = this.createPersistedBatch(0);
|
|
390
|
+
const sequence = allocator.sequence(lastOp);
|
|
391
|
+
try {
|
|
392
|
+
row.saveBucketData({
|
|
393
|
+
op_seq: sequence,
|
|
394
|
+
before_buckets: record.buckets,
|
|
395
|
+
evaluated: [],
|
|
396
|
+
table: sourceTable,
|
|
397
|
+
sourceKey: record.replicaId
|
|
398
|
+
});
|
|
399
|
+
row.saveParameterData({
|
|
400
|
+
op_seq: sequence,
|
|
401
|
+
existing_lookups: record.lookups,
|
|
402
|
+
evaluated: [],
|
|
403
|
+
sourceTable,
|
|
404
|
+
sourceKey: record.replicaId
|
|
405
|
+
});
|
|
406
|
+
// Since this is not from streaming replication, we can do a hard delete.
|
|
407
|
+
row.hardDeleteCurrentData(sourceId, record.replicaId);
|
|
408
|
+
group.append(row);
|
|
409
|
+
lastOp = sequence.last();
|
|
410
|
+
break;
|
|
411
|
+
} catch (error) {
|
|
412
|
+
if (!(error instanceof OpIdRangeExhausted)) {
|
|
413
|
+
throw error;
|
|
414
|
+
}
|
|
415
|
+
this.options.replicationLock.throwIfAborted();
|
|
416
|
+
await allocator.reserve();
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
processed++;
|
|
420
|
+
if (group.shouldFlushTransaction()) {
|
|
421
|
+
break;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
allocator.consume(lastOp);
|
|
425
|
+
await this.publishPrepared(lastOp, projection, [group.seal()], undefined, async (stream, writes) => {
|
|
426
|
+
if (lastOp === 0n) {
|
|
427
|
+
lastOp = this.persistedOpHead(stream);
|
|
428
|
+
}
|
|
429
|
+
this.onReplicationTransactionFlush(writes, lastOp);
|
|
430
|
+
});
|
|
431
|
+
if (processed === records.length && records.length < 2000) {
|
|
432
|
+
return lastOp;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
65
437
|
protected override persistedOpHead(stream: SyncRuleDocumentBase): InternalOpId {
|
|
66
438
|
return (stream as ReplicationStreamDocumentV3).last_persisted_op ?? 0n;
|
|
67
439
|
}
|
|
@@ -151,7 +523,9 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
|
|
|
151
523
|
}
|
|
152
524
|
}
|
|
153
525
|
|
|
154
|
-
async
|
|
526
|
+
protected override async resolveTablesInternal(
|
|
527
|
+
options: storage.ResolveTablesOptions
|
|
528
|
+
): Promise<storage.ResolveTablesResult> {
|
|
155
529
|
// The test-only override is a whole parsed set, so the sync rules and the mapping
|
|
156
530
|
// used below always come from the same parse.
|
|
157
531
|
const parsedOverride = options.parsedSyncConfig as MongoParsedSyncConfigSet | undefined;
|
|
@@ -173,8 +547,7 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
|
|
|
173
547
|
};
|
|
174
548
|
|
|
175
549
|
let result: storage.ResolveTablesResult | null = null;
|
|
176
|
-
const session = this.
|
|
177
|
-
await using _ = { [Symbol.asyncDispose]: () => session.endSession() };
|
|
550
|
+
const session = this.session;
|
|
178
551
|
|
|
179
552
|
await this.withTransaction(async () => {
|
|
180
553
|
const col = this.db.sourceTables(this.replicationStreamId);
|
|
@@ -251,7 +624,9 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
|
|
|
251
624
|
return result!;
|
|
252
625
|
}
|
|
253
626
|
|
|
254
|
-
async
|
|
627
|
+
protected override async getSourceTableStatusInternal(
|
|
628
|
+
table: storage.SourceTable
|
|
629
|
+
): Promise<storage.SourceTable | null> {
|
|
255
630
|
const doc = (await this.db
|
|
256
631
|
.sourceTables(this.replicationStreamId)
|
|
257
632
|
.findOne({ _id: mongoTableId(table.id) }, { session: this.session })) as SourceTableDocumentV3 | null;
|
|
@@ -268,15 +643,38 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
|
|
|
268
643
|
);
|
|
269
644
|
}
|
|
270
645
|
|
|
271
|
-
async
|
|
646
|
+
protected override async commitInternal(
|
|
647
|
+
lsn: string,
|
|
648
|
+
options?: storage.BucketBatchCommitOptions
|
|
649
|
+
): Promise<storage.CheckpointResult> {
|
|
650
|
+
this.options.replicationLock.throwIfAborted();
|
|
651
|
+
if (this.markRecordUnavailable != null && this.batch?.hasData()) {
|
|
652
|
+
// A combined data+checkpoint commit cannot return control to the connector
|
|
653
|
+
// to process repair notifications between those two durable boundaries.
|
|
654
|
+
// Require its explicit flush -> repair -> commit ordering even when this
|
|
655
|
+
// particular input might turn out not to need repair. An empty batch does
|
|
656
|
+
// not prove its external repair queue is drained; that remains the caller's
|
|
657
|
+
// responsibility. keepalive() delegates here and has the same requirement.
|
|
658
|
+
throw new ReplicationAssertionError(
|
|
659
|
+
'Flush pending rows and process markRecordUnavailable repairs before committing source progress'
|
|
660
|
+
);
|
|
661
|
+
}
|
|
272
662
|
const { createEmptyCheckpoints } = { ...storage.DEFAULT_BUCKET_BATCH_COMMIT_OPTIONS, ...options };
|
|
273
663
|
|
|
274
664
|
using _ = this.tracer.span('storage', 'commit');
|
|
275
665
|
|
|
276
|
-
const checkpoint = async (
|
|
666
|
+
const checkpoint = async (
|
|
667
|
+
stream: SyncRuleDocumentBase,
|
|
668
|
+
lastOp?: InternalOpId,
|
|
669
|
+
additionalWrites?: MongoWriteBatch
|
|
670
|
+
) => {
|
|
277
671
|
const now = new Date();
|
|
278
672
|
const preUpdateDocument = stream as ReplicationStreamDocumentV3;
|
|
279
|
-
|
|
673
|
+
this.assertPreparationContext(preUpdateDocument);
|
|
674
|
+
// Combined commits join the final data group's batch. Empty commits own
|
|
675
|
+
// and execute their batch here. Decisions depend only on the fresh fence
|
|
676
|
+
// and planned IDs, so no read of the queued data writes is required.
|
|
677
|
+
const writes = additionalWrites ?? this.db.createWriteBatch(this.session, { ordered: false });
|
|
280
678
|
writes.updateMany(
|
|
281
679
|
this.db.write_checkpoints,
|
|
282
680
|
{ processed_at_lsn: null, 'lsns.1': { $lte: lsn } },
|
|
@@ -398,10 +796,12 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
|
|
|
398
796
|
);
|
|
399
797
|
}
|
|
400
798
|
|
|
401
|
-
|
|
799
|
+
if (additionalWrites == null) {
|
|
800
|
+
await writes.execute();
|
|
801
|
+
}
|
|
402
802
|
return { checkpointBlocked, checkpointCreated, checkpointLogState, newCheckpoint };
|
|
403
803
|
};
|
|
404
|
-
const projection = { sync_configs: 1, last_persisted_op: 1 };
|
|
804
|
+
const projection = { state: 1, sync_configs: 1, last_persisted_op: 1 };
|
|
405
805
|
const { checkpointBlocked, checkpointCreated, checkpointLogState, newCheckpoint } = await this.flushAndCommit(
|
|
406
806
|
checkpoint,
|
|
407
807
|
() => this.withFencedTransaction(() => this.fence(this.session, projection), checkpoint),
|
|
@@ -432,11 +832,7 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
|
|
|
432
832
|
};
|
|
433
833
|
}
|
|
434
834
|
|
|
435
|
-
async
|
|
436
|
-
return await this.commit(lsn, { createEmptyCheckpoints: true });
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
async setResumeLsn(lsn: string): Promise<void> {
|
|
835
|
+
protected override async setResumeLsnInternal(lsn: string): Promise<void> {
|
|
440
836
|
using _ = this.tracer.span('storage', 'set_resume_lsn');
|
|
441
837
|
// Losing occasional resume LSN would only reprocess source changes.
|
|
442
838
|
// Keep the lease check atomic, but do not wait for majority replication of this resume hint.
|
|
@@ -574,13 +970,16 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
|
|
|
574
970
|
});
|
|
575
971
|
}
|
|
576
972
|
|
|
577
|
-
async
|
|
973
|
+
protected override async markAllSnapshotDoneInternal(no_checkpoint_before_lsn: string): Promise<void> {
|
|
578
974
|
await this.updateStreamMetadata(this.snapshotDoneUpdate(no_checkpoint_before_lsn), {
|
|
579
975
|
'sync_configs._id': { $in: this.syncConfigIds }
|
|
580
976
|
});
|
|
581
977
|
}
|
|
582
978
|
|
|
583
|
-
async
|
|
979
|
+
protected override async markSnapshotDoneInternal(
|
|
980
|
+
no_checkpoint_before_lsn: string,
|
|
981
|
+
options?: { throwOnConflict?: boolean }
|
|
982
|
+
): Promise<void> {
|
|
584
983
|
await this.withTransaction(async () => {
|
|
585
984
|
// Protect against race conditions
|
|
586
985
|
const blockingTables = await this.db
|
|
@@ -607,7 +1006,7 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
|
|
|
607
1006
|
});
|
|
608
1007
|
}
|
|
609
1008
|
|
|
610
|
-
async
|
|
1009
|
+
protected override async markTableSnapshotRequiredInternal(table: storage.SourceTable): Promise<void> {
|
|
611
1010
|
const syncConfigIds = this.relevantSyncConfigIds(table);
|
|
612
1011
|
if (syncConfigIds.length == 0) {
|
|
613
1012
|
return;
|
|
@@ -617,7 +1016,7 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
|
|
|
617
1016
|
});
|
|
618
1017
|
}
|
|
619
1018
|
|
|
620
|
-
async
|
|
1019
|
+
protected override async markTableSnapshotDoneInternal(
|
|
621
1020
|
tables: storage.SourceTable[],
|
|
622
1021
|
no_checkpoint_before_lsn?: string
|
|
623
1022
|
): Promise<storage.SourceTable[]> {
|
|
@@ -667,23 +1066,22 @@ export class MongoBucketBatchV3 extends MongoBucketBatch {
|
|
|
667
1066
|
});
|
|
668
1067
|
}
|
|
669
1068
|
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
opId: InternalOpId
|
|
673
|
-
|
|
674
|
-
|
|
1069
|
+
private queueCustomWriteCheckpoints(
|
|
1070
|
+
writes: MongoWriteBatch,
|
|
1071
|
+
opId: InternalOpId,
|
|
1072
|
+
checkpoints: storage.CustomWriteCheckpointOptions[]
|
|
1073
|
+
): void {
|
|
1074
|
+
if (checkpoints.length == 0) {
|
|
675
1075
|
return;
|
|
676
1076
|
}
|
|
677
1077
|
|
|
678
|
-
const checkpointsByEvent = Map.groupBy(
|
|
1078
|
+
const checkpointsByEvent = Map.groupBy(checkpoints, (checkpoint) =>
|
|
679
1079
|
this.validateCustomCheckpointEventId(checkpoint.event_id)
|
|
680
1080
|
);
|
|
681
1081
|
|
|
682
|
-
const writes = this.db.createWriteBatch(session, { ordered: false });
|
|
683
1082
|
for (const [eventId, checkpoints] of checkpointsByEvent) {
|
|
684
1083
|
this.batchCreateEventCustomWriteCheckpoints(writes, opId, eventId, checkpoints);
|
|
685
1084
|
}
|
|
686
|
-
await writes.execute();
|
|
687
1085
|
}
|
|
688
1086
|
|
|
689
1087
|
protected override async prepareCustomWriteCheckpoints(): Promise<void> {
|
|
@@ -645,7 +645,7 @@ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalC
|
|
|
645
645
|
bucketContext: BucketDataContextV3,
|
|
646
646
|
objectStorageUsage: ObjectStorageUsage
|
|
647
647
|
): Promise<BucketStatsWithChecksum> {
|
|
648
|
-
if (inputs.length == 1) {
|
|
648
|
+
if (inputs.length == 1 && !this.shouldMoveToObjectStorage(inputs[0])) {
|
|
649
649
|
return statsForDocument(inputs[0]);
|
|
650
650
|
}
|
|
651
651
|
|
|
@@ -1093,7 +1093,7 @@ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalC
|
|
|
1093
1093
|
context: { replicationStreamId: number; definitionId: string },
|
|
1094
1094
|
objectStorageUsage: ObjectStorageUsage
|
|
1095
1095
|
): Promise<CompactionGroupResult> {
|
|
1096
|
-
if (group.inputs.length == 1 && !group.changed) {
|
|
1096
|
+
if (group.inputs.length == 1 && !group.changed && !this.shouldMoveToObjectStorage(group.inputs[0])) {
|
|
1097
1097
|
return {
|
|
1098
1098
|
documentId: group.inputs[0]._id,
|
|
1099
1099
|
stats: statsForDocument(group.inputs[0])
|
|
@@ -1588,6 +1588,14 @@ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalC
|
|
|
1588
1588
|
objectStorageUsage.applyDelta(definitionId, newBytes - oldBytes, writes);
|
|
1589
1589
|
}
|
|
1590
1590
|
|
|
1591
|
+
private shouldMoveToObjectStorage(document: BucketDataDocumentV3): boolean {
|
|
1592
|
+
return (
|
|
1593
|
+
this.storage.objectStorage != null &&
|
|
1594
|
+
document.storage_ref == null &&
|
|
1595
|
+
document.size > this.storage.inlineThresholdBytes
|
|
1596
|
+
);
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1591
1599
|
private async persistBucketData(
|
|
1592
1600
|
bucket: string,
|
|
1593
1601
|
chunks: BucketDataDoc[][],
|
|
@@ -1607,8 +1615,9 @@ export class MongoCompactorV3 extends MongoCompactor implements CompactIntervalC
|
|
|
1607
1615
|
const store = new BucketDataObjectStorage(this.storage.objectStorage);
|
|
1608
1616
|
const storagePaths = new Set<string>();
|
|
1609
1617
|
const lifecycle = this.objectStorageLifecycle;
|
|
1610
|
-
// Base placement on the final compacted size. Unchanged documents are
|
|
1611
|
-
// rewritten, while small MOVE/merge results
|
|
1618
|
+
// Base placement on the final compacted size. Unchanged documents are only
|
|
1619
|
+
// rewritten to move them to object storage, while small MOVE/merge results
|
|
1620
|
+
// and CLEAR ops stay inline.
|
|
1612
1621
|
const storedIndexes = serializedChunks.flatMap((document, index) =>
|
|
1613
1622
|
document.size > this.storage.inlineThresholdBytes ? [index] : []
|
|
1614
1623
|
);
|