@powersync/service-module-mongodb-storage 0.23.2 → 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.
Files changed (99) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/LICENSE +201 -67
  3. package/dist/storage/MongoBucketStorage.d.ts +1 -0
  4. package/dist/storage/MongoBucketStorage.js +3 -0
  5. package/dist/storage/MongoBucketStorage.js.map +1 -1
  6. package/dist/storage/implementation/MongoBucketBatch.d.ts +60 -39
  7. package/dist/storage/implementation/MongoBucketBatch.js +123 -296
  8. package/dist/storage/implementation/MongoBucketBatch.js.map +1 -1
  9. package/dist/storage/implementation/MongoOpIdAllocator.d.ts +6 -1
  10. package/dist/storage/implementation/MongoOpIdAllocator.js +12 -5
  11. package/dist/storage/implementation/MongoOpIdAllocator.js.map +1 -1
  12. package/dist/storage/implementation/MongoParsedSyncConfigSet.d.ts +7 -0
  13. package/dist/storage/implementation/MongoParsedSyncConfigSet.js +28 -2
  14. package/dist/storage/implementation/MongoParsedSyncConfigSet.js.map +1 -1
  15. package/dist/storage/implementation/MongoReplicationOptions.d.ts +27 -0
  16. package/dist/storage/implementation/MongoReplicationOptions.js +26 -0
  17. package/dist/storage/implementation/MongoReplicationOptions.js.map +1 -0
  18. package/dist/storage/implementation/MongoSyncBucketStorage.js +2 -3
  19. package/dist/storage/implementation/MongoSyncBucketStorage.js.map +1 -1
  20. package/dist/storage/implementation/MongoSyncRulesLock.d.ts +15 -2
  21. package/dist/storage/implementation/MongoSyncRulesLock.js +36 -14
  22. package/dist/storage/implementation/MongoSyncRulesLock.js.map +1 -1
  23. package/dist/storage/implementation/common/BatchOperationBuffer.d.ts +79 -0
  24. package/dist/storage/implementation/common/BatchOperationBuffer.js +286 -0
  25. package/dist/storage/implementation/common/BatchOperationBuffer.js.map +1 -0
  26. package/dist/storage/implementation/common/PersistedBatch.d.ts +10 -103
  27. package/dist/storage/implementation/common/PersistedBatch.js +1 -291
  28. package/dist/storage/implementation/common/PersistedBatch.js.map +1 -1
  29. package/dist/storage/implementation/common/PreparedPublication.d.ts +43 -0
  30. package/dist/storage/implementation/common/PreparedPublication.js +2 -0
  31. package/dist/storage/implementation/common/PreparedPublication.js.map +1 -0
  32. package/dist/storage/implementation/common/SourceRecordStore.d.ts +0 -2
  33. package/dist/storage/implementation/v1/MongoBucketBatchV1.d.ts +17 -13
  34. package/dist/storage/implementation/v1/MongoBucketBatchV1.js +259 -18
  35. package/dist/storage/implementation/v1/MongoBucketBatchV1.js.map +1 -1
  36. package/dist/storage/implementation/v1/PersistedBatchV1.d.ts +21 -15
  37. package/dist/storage/implementation/v1/PersistedBatchV1.js +58 -24
  38. package/dist/storage/implementation/v1/PersistedBatchV1.js.map +1 -1
  39. package/dist/storage/implementation/v1/SourceRecordStoreV1.d.ts +0 -2
  40. package/dist/storage/implementation/v1/SourceRecordStoreV1.js +0 -3
  41. package/dist/storage/implementation/v1/SourceRecordStoreV1.js.map +1 -1
  42. package/dist/storage/implementation/v3/MongoBucketBatchV3.d.ts +25 -14
  43. package/dist/storage/implementation/v3/MongoBucketBatchV3.js +350 -23
  44. package/dist/storage/implementation/v3/MongoBucketBatchV3.js.map +1 -1
  45. package/dist/storage/implementation/v3/MongoCompactorV3.d.ts +1 -0
  46. package/dist/storage/implementation/v3/MongoCompactorV3.js +10 -4
  47. package/dist/storage/implementation/v3/MongoCompactorV3.js.map +1 -1
  48. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js +39 -28
  49. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js.map +1 -1
  50. package/dist/storage/implementation/v3/PersistedBatchV3.d.ts +46 -24
  51. package/dist/storage/implementation/v3/PersistedBatchV3.js +159 -83
  52. package/dist/storage/implementation/v3/PersistedBatchV3.js.map +1 -1
  53. package/dist/storage/implementation/v3/SourceRecordStoreV3.d.ts +12 -0
  54. package/dist/storage/implementation/v3/SourceRecordStoreV3.js +41 -13
  55. package/dist/storage/implementation/v3/SourceRecordStoreV3.js.map +1 -1
  56. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.d.ts +4 -0
  57. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js +4 -0
  58. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js.map +1 -1
  59. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.d.ts +27 -2
  60. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js +61 -5
  61. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js.map +1 -1
  62. package/package.json +9 -10
  63. package/src/storage/MongoBucketStorage.ts +4 -0
  64. package/src/storage/implementation/MongoBucketBatch.ts +185 -392
  65. package/src/storage/implementation/MongoOpIdAllocator.ts +12 -5
  66. package/src/storage/implementation/MongoParsedSyncConfigSet.ts +40 -1
  67. package/src/storage/implementation/MongoReplicationOptions.ts +28 -0
  68. package/src/storage/implementation/MongoSyncBucketStorage.ts +2 -3
  69. package/src/storage/implementation/MongoSyncRulesLock.ts +39 -17
  70. package/src/storage/implementation/common/BatchOperationBuffer.ts +353 -0
  71. package/src/storage/implementation/common/PersistedBatch.ts +12 -390
  72. package/src/storage/implementation/common/PreparedPublication.ts +49 -0
  73. package/src/storage/implementation/common/SourceRecordStore.ts +0 -2
  74. package/src/storage/implementation/v1/MongoBucketBatchV1.ts +339 -27
  75. package/src/storage/implementation/v1/PersistedBatchV1.ts +74 -32
  76. package/src/storage/implementation/v1/SourceRecordStoreV1.ts +0 -5
  77. package/src/storage/implementation/v3/MongoBucketBatchV3.ts +430 -32
  78. package/src/storage/implementation/v3/MongoCompactorV3.ts +13 -4
  79. package/src/storage/implementation/v3/MongoSyncBucketStorageV3.ts +43 -29
  80. package/src/storage/implementation/v3/PersistedBatchV3.ts +209 -108
  81. package/src/storage/implementation/v3/SourceRecordStoreV3.ts +45 -15
  82. package/src/storage/implementation/v3/object-storage/BucketDataObjectStorage.ts +9 -0
  83. package/src/storage/implementation/v3/object-storage/ObjectStorageLifecycle.ts +79 -5
  84. package/test/src/checkpoint_cleanup.test.ts +75 -0
  85. package/test/src/concurrent_writers.test.ts +127 -53
  86. package/test/src/helpers/PublicationCommandMonitor.ts +119 -0
  87. package/test/src/helpers/concurrentWriter.mjs +2 -2
  88. package/test/src/invalid_sync_config.test.ts +9 -0
  89. package/test/src/object_storage_lifecycle.test.ts +72 -2
  90. package/test/src/prepared_publication.test.ts +388 -0
  91. package/test/src/replication_preparation.test.ts +829 -0
  92. package/test/src/replication_publication_baseline.test.ts +203 -0
  93. package/test/src/replication_publication_batching.test.ts +212 -0
  94. package/test/src/replication_uploads.test.ts +393 -0
  95. package/test/src/source_table_config.test.ts +4 -4
  96. package/test/src/storage_object_storage_inline_threshold.test.ts +1 -1
  97. package/test/src/storage_s3_compaction_lifecycle.test.ts +93 -1
  98. package/test/src/storage_sync.test.ts +83 -2
  99. package/tsconfig.tsbuildinfo +1 -1
@@ -560,37 +560,51 @@ export async function getParameterSetsV3(
560
560
  };
561
561
  };
562
562
 
563
- const [firstLookup, ...remainingLookups] = lookups;
564
- const firstQuery = firstLookup == null ? null : buildLookupPipeline(firstLookup, 0);
565
- if (firstQuery == null) {
566
- return [];
567
- }
563
+ // Every lookup after the first adds a $unionWith stage, and MongoDB rejects
564
+ // pipelines with more than 1000 stages, so evaluate lookups in batches.
565
+ const MAX_LOOKUPS_PER_PIPELINE = 900;
566
+ const rows: { index: number; bucket_parameters: SqliteJsonRow }[] = [];
567
+
568
+ for (let start = 0; start < lookups.length; start += MAX_LOOKUPS_PER_PIPELINE) {
569
+ const remaining = limit + 1 - rows.length;
570
+ if (remaining <= 0) {
571
+ break;
572
+ }
568
573
 
569
- const pipeline: mongo.Document[] = [
570
- ...firstQuery.pipeline,
571
- ...remainingLookups.map((lookup, indexInRemaining) => {
572
- const query = buildLookupPipeline(lookup, indexInRemaining + 1);
573
- return {
574
- $unionWith: {
575
- coll: query.collection.collectionName,
576
- pipeline: query.pipeline
577
- }
578
- };
579
- }),
580
- { $unwind: '$bucket_parameters' },
581
- { $limit: limit + 1 }
582
- ];
574
+ const batch = lookups.slice(start, start + MAX_LOOKUPS_PER_PIPELINE);
575
+ const [firstLookup, ...remainingLookups] = batch;
576
+ const firstQuery = buildLookupPipeline(firstLookup, start);
577
+
578
+ const pipeline: mongo.Document[] = [
579
+ ...firstQuery.pipeline,
580
+ ...remainingLookups.map((lookup, indexInRemaining) => {
581
+ const query = buildLookupPipeline(lookup, start + indexInRemaining + 1);
582
+ return {
583
+ $unionWith: {
584
+ coll: query.collection.collectionName,
585
+ pipeline: query.pipeline
586
+ }
587
+ };
588
+ }),
589
+ { $unwind: '$bucket_parameters' },
590
+ { $limit: remaining }
591
+ ];
583
592
 
584
- const rows = await firstQuery.collection
585
- .aggregate<{ index: number; bucket_parameters: SqliteJsonRow }>(pipeline, {
586
- session,
587
- readConcern: 'snapshot',
588
- maxTimeMS: lib_mongo.db.MONGO_OPERATION_TIMEOUT_MS
589
- })
590
- .toArray()
591
- .catch((e) => {
592
- throw lib_mongo.mapQueryError(e, 'while evaluating parameter queries');
593
- });
593
+ const batchRows = await firstQuery.collection
594
+ .aggregate<{ index: number; bucket_parameters: SqliteJsonRow }>(pipeline, {
595
+ session,
596
+ readConcern: 'snapshot',
597
+ maxTimeMS: lib_mongo.db.MONGO_OPERATION_TIMEOUT_MS
598
+ })
599
+ .toArray()
600
+ .catch((e) => {
601
+ throw lib_mongo.mapQueryError(e, 'while evaluating parameter queries');
602
+ });
603
+
604
+ for (const row of batchRows) {
605
+ rows.push(row);
606
+ }
607
+ }
594
608
 
595
609
  if (rows.length > limit) {
596
610
  throw new ParameterSetLimitExceededError(limit);
@@ -1,17 +1,20 @@
1
1
  import { mongo } from '@powersync/lib-service-mongodb';
2
2
  import { ReplicationAssertionError } from '@powersync/lib-services-framework';
3
3
  import { BucketDefinitionMapping, InternalOpId, storage } from '@powersync/service-core';
4
- import { BucketDataSource, BucketDefinitionId } from '@powersync/service-sync-rules';
4
+ import { BucketDefinitionId } from '@powersync/service-sync-rules';
5
5
  import * as bson from 'bson';
6
6
  import { mongoTableId } from '../../../utils/util.js';
7
+ import { BatchOperationBuffer } from '../common/BatchOperationBuffer.js';
7
8
  import { BucketDataDoc } from '../common/BucketDataDoc.js';
8
9
  import {
9
10
  BucketStateUpdate,
10
11
  PersistedBatch,
11
12
  PersistedBatchOptions,
13
+ SaveBucketDataOptions,
12
14
  SaveParameterDataOptions,
13
15
  UpsertCurrentDataOptions
14
16
  } from '../common/PersistedBatch.js';
17
+ import { PreparedPublication } from '../common/PreparedPublication.js';
15
18
  import { SourceRecordLookupState } from '../common/SourceRecordStore.js';
16
19
  import { MongoWriteBatch } from '../MongoWriteBatch.js';
17
20
  import { serializeBucketData } from './bucket-format.js';
@@ -25,55 +28,165 @@ import {
25
28
  SourceTableDocumentV3,
26
29
  taggedBucketParameterDocumentToTagged
27
30
  } from './models.js';
28
- import { ObjectStorageLifecycle } from './object-storage/ObjectStorageLifecycle.js';
31
+ import { ObjectStorage } from './object-storage/ObjectStorage.js';
32
+ import { ObjectStorageLifecycle, PreparedObjectStorageUpload } from './object-storage/ObjectStorageLifecycle.js';
29
33
  import { ObjectStorageUsage } from './object-storage/ObjectStorageUsage.js';
30
34
  import { VersionedPowerSyncMongoV3 } from './VersionedPowerSyncMongoV3.js';
31
35
 
36
+ export const DEFAULT_INLINE_THRESHOLD_BYTES = 16 * 1024;
37
+
38
+ export interface PersistedBatchV3Options extends PersistedBatchOptions {
39
+ objectStorage?: ObjectStorage;
40
+ inlineThresholdBytes?: number;
41
+ /**
42
+ * Aborts in-flight object storage uploads when replication stops.
43
+ */
44
+ signal?: AbortSignal;
45
+ objectStorageUsageWriterId?: string;
46
+ }
47
+
32
48
  interface SourceRecordWrite {
33
49
  sourceTableId: bson.ObjectId;
34
50
  operation: mongo.AnyBulkWriteOperation<CurrentDataDocumentV3>;
35
51
  }
36
52
 
37
- export class PersistedBatchV3 extends PersistedBatch {
53
+ export class PersistedBatchV3 implements PersistedBatch, PreparedPublication {
38
54
  // Upserts and soft deletes supply the complete source-record state, including
39
55
  // pending_delete. Keep the final state per key, without removing any history.
40
- currentData = new Map<string, SourceRecordWrite>();
41
- sourceTablePendingDeletes = new Map<string, InternalOpId>();
42
- protected readonly objectStorageLifecycle?: ObjectStorageLifecycle;
43
- protected readonly objectStorageUsage?: ObjectStorageUsage;
44
-
45
- declare protected readonly db: VersionedPowerSyncMongoV3;
56
+ private readonly currentData = new Map<string, SourceRecordWrite>();
57
+ private readonly sourceTablePendingDeletes = new Map<string, InternalOpId>();
58
+ private readonly objectStorageLifecycle?: ObjectStorageLifecycle;
59
+ private readonly objectStorageUsage?: ObjectStorageUsage;
60
+
61
+ private readonly operations: BatchOperationBuffer;
62
+ private readonly inlineThresholdBytes: number;
63
+ private readonly signal?: AbortSignal;
64
+ private sealed = false;
65
+ private preparation?: Promise<void>;
66
+ private payloadsReady = false;
46
67
 
47
68
  constructor(
48
- db: VersionedPowerSyncMongoV3,
49
- group_id: number,
50
- mapping: BucketDefinitionMapping,
69
+ private readonly db: VersionedPowerSyncMongoV3,
70
+ private readonly group_id: number,
71
+ private readonly mapping: BucketDefinitionMapping,
51
72
  writtenSize: number,
52
- options?: PersistedBatchOptions
73
+ options?: PersistedBatchV3Options
53
74
  ) {
54
- super(db, group_id, mapping, writtenSize, options);
55
- if (this.objectStorage) {
56
- this.objectStorageLifecycle = new ObjectStorageLifecycle(this.db, this.group_id, this.objectStorage);
57
- this.objectStorageUsage = new ObjectStorageUsage(this.db, this.group_id, this.objectStorageUsageWriterId);
75
+ this.operations = new BatchOperationBuffer(
76
+ group_id,
77
+ writtenSize,
78
+ {
79
+ // V3-specific definition validation; v1 does not persist definition ids.
80
+ checkDefinitionId: (definitionId) => {
81
+ if (definitionId == null) {
82
+ throw new ReplicationAssertionError('Expected v3 bucket when incrementalReprocessing is enabled');
83
+ }
84
+ return definitionId;
85
+ },
86
+ getBucketDefinitionId: (source) => mapping.bucketSourceId(source)
87
+ },
88
+ options?.logger
89
+ );
90
+ this.inlineThresholdBytes = options?.inlineThresholdBytes ?? DEFAULT_INLINE_THRESHOLD_BYTES;
91
+ this.signal = options?.signal;
92
+ if (options?.objectStorage) {
93
+ this.objectStorageLifecycle = new ObjectStorageLifecycle(db, group_id, options.objectStorage);
94
+ this.objectStorageUsage = new ObjectStorageUsage(db, group_id, options.objectStorageUsageWriterId);
58
95
  }
59
96
  }
60
97
 
61
- // Abstract override from PersistedBatch (V3-specific error message)
98
+ get currentSize(): number {
99
+ return this.operations.currentSize;
100
+ }
101
+
102
+ saveBucketData(options: SaveBucketDataOptions): void {
103
+ this.assertMutable();
104
+ this.operations.saveBucketData(options);
105
+ }
62
106
 
63
- protected override checkDefinitionId(definitionId: BucketDefinitionId | null): BucketDefinitionId {
64
- if (definitionId == null) {
65
- throw new ReplicationAssertionError('Expected v3 bucket when incrementalReprocessing is enabled');
107
+ shouldFlushTransaction(): boolean {
108
+ return this.operations.shouldFlushTransaction(this.currentData.size);
109
+ }
110
+
111
+ append(row: PersistedBatchV3): void {
112
+ this.assertMutable();
113
+ this.operations.append(row.operations);
114
+ for (const [key, value] of row.currentData) {
115
+ this.currentData.set(key, value);
116
+ }
117
+ for (const [key, value] of row.sourceTablePendingDeletes) {
118
+ const previous = this.sourceTablePendingDeletes.get(key) ?? 0n;
119
+ this.sourceTablePendingDeletes.set(key, value > previous ? value : previous);
66
120
  }
67
- return definitionId;
68
121
  }
69
122
 
70
- // Concrete implementations from PersistedBatchShared
123
+ /** Return the sealed view of this batch without starting I/O or moving its data. */
124
+ seal(): PreparedPublication {
125
+ this.assertMutable();
126
+ this.sealed = true;
127
+ return this;
128
+ }
129
+
130
+ private assertMutable(): void {
131
+ if (this.sealed) {
132
+ throw new Error('Cannot modify a sealed publication');
133
+ }
134
+ }
135
+
136
+ /** Cache readiness, including failure; the v3 batch owns the entire lifecycle. */
137
+ preparePayloads(): Promise<void> {
138
+ if (!this.sealed) {
139
+ throw new ReplicationAssertionError('Cannot prepare an unsealed publication');
140
+ }
141
+ return (this.preparation ??= Promise.resolve()
142
+ .then(() => this.preparePayloadsImpl())
143
+ .then(() => {
144
+ this.payloadsReady = true;
145
+ }));
146
+ }
147
+
148
+ async publish(
149
+ session: mongo.ClientSession,
150
+ options?: storage.BucketBatchCommitOptions,
151
+ additionalWrites?: MongoWriteBatch
152
+ ) {
153
+ // Never start preparation from a transaction attempt. The eager coordinator
154
+ // awaits readiness for the entire bounded prefix before opening a transaction.
155
+ if (!this.payloadsReady) {
156
+ throw new ReplicationAssertionError('Prepare publication payloads before starting the transaction');
157
+ }
158
+ return this.write(session, options, additionalWrites);
159
+ }
71
160
 
72
- protected getBucketDefinitionId(bucketSource: BucketDataSource): BucketDefinitionId {
73
- return this.mapping.bucketSourceId(bucketSource);
161
+ private async write(
162
+ session: mongo.ClientSession,
163
+ options?: storage.BucketBatchCommitOptions,
164
+ additionalWrites?: MongoWriteBatch
165
+ ) {
166
+ const startAt = performance.now();
167
+ const writes = additionalWrites ?? this.db.createWriteBatch(session, { ordered: false });
168
+ // Checked marker removal, references and usage must commit together. Recheck
169
+ // expiry on every attempt; neither an expired nor a missing marker is a
170
+ // transient MongoDB error that may replay this generation indefinitely.
171
+ this.objectStorageLifecycle?.publishUploads(this.preparedUploads, writes);
172
+ if (this.operations.bucketDataCount > 0) {
173
+ await this.queueBucketData(writes);
174
+ }
175
+ if (this.operations.bucketParameters.length > 0) {
176
+ this.queueBucketParameters(writes);
177
+ }
178
+ if (this.currentData.size > 0) {
179
+ this.queueCurrentData(writes);
180
+ }
181
+ if (this.operations.bucketStates.size > 0) {
182
+ this.queueBucketStates(writes);
183
+ }
184
+ await writes.execute();
185
+ return this.operations.logFlush(startAt, this.currentData.size, options);
74
186
  }
75
187
 
76
188
  saveParameterData(data: SaveParameterDataOptions) {
189
+ this.assertMutable();
77
190
  const { sourceTable, sourceKey, evaluated } = data;
78
191
  const remaining_lookups = new Map<string, SourceRecordLookupState>();
79
192
 
@@ -95,7 +208,7 @@ export class PersistedBatchV3 extends PersistedBatch {
95
208
  remaining_lookups.delete(`${sourceDefinitionId}.${binLookup.toString('base64')}`);
96
209
 
97
210
  const op_id = data.op_seq.next();
98
- this.debugLastOpId = op_id;
211
+ this.operations.debugLastOpId = op_id;
99
212
  const values = {
100
213
  _id: op_id,
101
214
  key: {
@@ -105,17 +218,17 @@ export class PersistedBatchV3 extends PersistedBatch {
105
218
  lookup: binLookup,
106
219
  bucket_parameters: result.bucketParameters
107
220
  };
108
- this.bucketParameters.push({
221
+ this.operations.bucketParameters.push({
109
222
  ...values,
110
223
  index: sourceDefinitionId
111
224
  });
112
225
 
113
- this.currentSize += 200;
226
+ this.operations.currentSize += 200;
114
227
  }
115
228
 
116
229
  for (let lookup of remaining_lookups.values()) {
117
230
  const op_id = data.op_seq.next();
118
- this.debugLastOpId = op_id;
231
+ this.operations.debugLastOpId = op_id;
119
232
  const indexId = lookup.indexId;
120
233
  if (indexId == null) {
121
234
  throw new ReplicationAssertionError('Expected lookup when incrementalReprocessing is enabled');
@@ -129,16 +242,17 @@ export class PersistedBatchV3 extends PersistedBatch {
129
242
  lookup: lookup.lookup,
130
243
  bucket_parameters: []
131
244
  };
132
- this.bucketParameters.push({
245
+ this.operations.bucketParameters.push({
133
246
  ...values,
134
247
  index: indexId
135
248
  });
136
249
 
137
- this.currentSize += 200;
250
+ this.operations.currentSize += 200;
138
251
  }
139
252
  }
140
253
 
141
254
  hardDeleteCurrentData(sourceTableId: bson.ObjectId, replicaId: storage.ReplicaId) {
255
+ this.assertMutable();
142
256
  this.currentData.set(this.currentDataKey(sourceTableId, replicaId), {
143
257
  sourceTableId,
144
258
  operation: {
@@ -147,7 +261,7 @@ export class PersistedBatchV3 extends PersistedBatch {
147
261
  }
148
262
  }
149
263
  });
150
- this.currentSize += 50;
264
+ this.operations.currentSize += 50;
151
265
  }
152
266
 
153
267
  softDeleteCurrentData(
@@ -155,6 +269,7 @@ export class PersistedBatchV3 extends PersistedBatch {
155
269
  replicaId: storage.ReplicaId,
156
270
  checkpointGreaterThan: InternalOpId
157
271
  ) {
272
+ this.assertMutable();
158
273
  this.currentData.set(this.currentDataKey(sourceTableId, replicaId), {
159
274
  sourceTableId,
160
275
  operation: {
@@ -178,10 +293,11 @@ export class PersistedBatchV3 extends PersistedBatch {
178
293
  this.sourceTablePendingDeletes.set(sourceTableKey, checkpointGreaterThan);
179
294
  }
180
295
 
181
- this.currentSize += 50;
296
+ this.operations.currentSize += 50;
182
297
  }
183
298
 
184
299
  upsertCurrentData(values: UpsertCurrentDataOptions) {
300
+ this.assertMutable();
185
301
  const buckets = values.buckets.map((bucket) => {
186
302
  if (bucket.definitionId == null) {
187
303
  throw new ReplicationAssertionError('Expected bucket when incrementalReprocessing is enabled');
@@ -220,103 +336,93 @@ export class PersistedBatchV3 extends PersistedBatch {
220
336
  }
221
337
  }
222
338
  });
223
- this.currentSize += (values.data?.length() ?? 0) + 100;
224
- }
225
-
226
- protected get currentDataCount() {
227
- return this.currentData.size;
339
+ this.operations.currentSize += (values.data?.length() ?? 0) + 100;
228
340
  }
229
341
 
230
- // Flush methods
342
+ private preparedBucketWrites?: { definitionId: BucketDefinitionId; documents: BucketDataDocumentV3[] }[];
343
+ private readonly preparedUsageDeltas = new Map<BucketDefinitionId, bigint>();
344
+ private preparedUploads: PreparedObjectStorageUpload[] = [];
231
345
 
232
- protected async queueBucketData(writes: MongoWriteBatch) {
346
+ private async preparePayloadsImpl(): Promise<void> {
233
347
  const operationsByDefinition = new Map<BucketDefinitionId, BucketDataDoc[]>();
234
- for (const document of this.bucketData) {
348
+ for (const document of this.operations.bucketData) {
235
349
  const existing = operationsByDefinition.get(document.bucketKey.definitionId) ?? [];
236
350
  existing.push(document);
237
351
  operationsByDefinition.set(document.bucketKey.definitionId, existing);
238
352
  }
239
353
 
240
- const plans = Array.from(operationsByDefinition, ([definitionId, documents]) => {
354
+ const uploads: { path: string; payload: Uint8Array }[] = [];
355
+ this.preparedBucketWrites = Array.from(operationsByDefinition, ([definitionId, documents]) => {
241
356
  const operationsByBucket = Map.groupBy(documents, (document) => document.bucketKey.bucket);
242
357
  const lifecycle = this.objectStorageLifecycle;
243
- type BucketDataInsert = { insertOne: { document: BucketDataDocumentV3 } };
244
- const createInserts: (() => Promise<BucketDataInsert>)[] = [];
358
+ const preparedDocuments: BucketDataDocumentV3[] = [];
245
359
 
246
360
  for (const [bucket, ops] of operationsByBucket) {
247
- this.resetBucketPersistedBytes(definitionId, bucket);
361
+ this.operations.resetBucketPersistedBytes(definitionId, bucket);
248
362
  for (const chunk of chunkBucketData(ops)) {
249
363
  const serialized = serializeBucketData(bucket, chunk);
250
- this.incrementBucketPersistedChunk(definitionId, bucket, serialized.size);
364
+ this.operations.incrementBucketPersistedChunk(definitionId, bucket, serialized.size);
251
365
  if (lifecycle == null || serialized.size <= this.inlineThresholdBytes) {
252
- createInserts.push(async () => ({
253
- insertOne: {
254
- document: serialized
255
- }
256
- }));
366
+ preparedDocuments.push(serialized);
257
367
  continue;
258
368
  }
259
369
 
260
- createInserts.push(async () => {
261
- const minOp = chunk[0].o;
262
- const maxOp = chunk[chunk.length - 1].o;
263
- const { ops: bucketOps, ...metadata } = serialized;
264
- const path = lifecycle.allocatePath(definitionId, bucket, minOp, maxOp);
265
- const { fileSize } = await lifecycle.bucketData.store(path, bucketOps!, { signal: this.signal });
266
- return {
267
- insertOne: {
268
- document: {
269
- ...metadata,
270
- storage_ref: {
271
- path,
272
- file_size: fileSize
273
- }
274
- }
275
- }
276
- };
370
+ const minOp = chunk[0].o;
371
+ const maxOp = chunk[chunk.length - 1].o;
372
+ const { ops: bucketOps, ...metadata } = serialized;
373
+ const path = lifecycle.allocatePath(definitionId, bucket, minOp, maxOp);
374
+ // Seal paths and bytes before starting any upload. Publication reads
375
+ // only the resulting metadata and never regenerates an object.
376
+ const payload = bson.serialize({ ops: bucketOps! });
377
+ preparedDocuments.push({
378
+ ...metadata,
379
+ storage_ref: { path, file_size: payload.byteLength }
277
380
  });
381
+ uploads.push({ path, payload });
278
382
  }
279
383
  }
280
384
 
281
- return { definitionId, createInserts };
385
+ const delta = preparedDocuments.reduce((sum, document) => sum + ObjectStorageUsage.bytes(document), 0n);
386
+ if (delta !== 0n) {
387
+ this.preparedUsageDeltas.set(definitionId, delta);
388
+ }
389
+ return { definitionId, documents: preparedDocuments };
282
390
  });
283
391
 
284
- const createAllInserts = () =>
285
- Promise.all(
286
- plans.map(async ({ definitionId, createInserts }) => ({
287
- definitionId,
288
- inserts: await Promise.all(createInserts.map((createInsert) => createInsert()))
289
- }))
290
- );
392
+ const lifecycle = this.objectStorageLifecycle;
393
+ if (uploads.length === 0 || lifecycle == null) {
394
+ return;
395
+ }
396
+ this.signal?.throwIfAborted();
397
+ // Seal every path/byte first, then register the whole group durably. No PUT
398
+ // may start after a partial or ambiguous registration failure.
399
+ this.preparedUploads = await lifecycle.prepareUploads(
400
+ uploads.map((upload) => upload.path),
401
+ new Date()
402
+ );
403
+ this.signal?.throwIfAborted();
404
+ await lifecycle.storeUploads(
405
+ uploads.map(({ payload }, index) => ({ upload: this.preparedUploads[index], payload })),
406
+ { signal: this.signal }
407
+ );
408
+ }
291
409
 
292
- // S3ObjectStorage applies one shared concurrency limit across all callers,
293
- // so replication can schedule its uploads together without creating a
294
- // separate limiter here.
295
- const bucketWrites = await createAllInserts();
296
-
297
- const usageDeltas = this.objectStorageUsage == null ? undefined : new Map<BucketDefinitionId, bigint>();
298
- for (const { definitionId, inserts } of bucketWrites) {
299
- if (usageDeltas != null) {
300
- const delta = inserts.reduce(
301
- (sum, operation) => sum + ObjectStorageUsage.bytes(operation.insertOne.document),
302
- 0n
303
- );
304
- if (delta !== 0n) {
305
- usageDeltas.set(definitionId, delta);
306
- }
307
- }
308
- if (inserts.length > 0) {
309
- writes.bulkWriteUnordered(this.db.bucketData(this.group_id, definitionId), inserts);
310
- }
410
+ private async queueBucketData(writes: MongoWriteBatch) {
411
+ if (this.preparedBucketWrites == null) {
412
+ throw new ReplicationAssertionError('Publication payloads have not been prepared');
311
413
  }
312
- if (usageDeltas != null) {
313
- this.objectStorageUsage!.applyDeltas(usageDeltas, writes);
414
+ for (const { definitionId, documents } of this.preparedBucketWrites) {
415
+ writes.bulkWriteUnordered(
416
+ this.db.bucketData(this.group_id, definitionId),
417
+ documents.map((document) => ({ insertOne: { document } }))
418
+ );
314
419
  }
420
+ this.objectStorageUsage?.applyDeltas(this.preparedUsageDeltas, writes);
315
421
  }
316
422
 
317
- protected queueBucketParameters(writes: MongoWriteBatch): void {
318
- const operationsByIndex = new Map<string, typeof this.bucketParameters>();
319
- for (const document of this.bucketParameters) {
423
+ private queueBucketParameters(writes: MongoWriteBatch): void {
424
+ const operationsByIndex = new Map<string, typeof this.operations.bucketParameters>();
425
+ for (const document of this.operations.bucketParameters) {
320
426
  const existing = operationsByIndex.get(document.index) ?? [];
321
427
  existing.push(document);
322
428
  operationsByIndex.set(document.index, existing);
@@ -334,7 +440,7 @@ export class PersistedBatchV3 extends PersistedBatch {
334
440
  }
335
441
  }
336
442
 
337
- protected queueCurrentData(writes: MongoWriteBatch): void {
443
+ private queueCurrentData(writes: MongoWriteBatch): void {
338
444
  const operationsBySourceTable = new Map<string, SourceRecordWrite[]>();
339
445
  for (const operation of this.currentData.values()) {
340
446
  const sourceTableId = operation.sourceTableId.toHexString();
@@ -371,21 +477,16 @@ export class PersistedBatchV3 extends PersistedBatch {
371
477
  }
372
478
  }
373
479
 
374
- protected queueBucketStates(writes: MongoWriteBatch): void {
480
+ private queueBucketStates(writes: MongoWriteBatch): void {
375
481
  writes.bulkWriteUnordered(this.db.bucketState(this.group_id), this.getBucketStateUpdates());
376
482
  }
377
483
 
378
- protected resetCurrentData() {
379
- this.currentData.clear();
380
- this.sourceTablePendingDeletes.clear();
381
- }
382
-
383
484
  private currentDataKey(sourceTableId: bson.ObjectId, replicaId: storage.ReplicaId): string {
384
485
  return Buffer.from(bson.serialize({ t: sourceTableId, k: replicaId })).toString('base64');
385
486
  }
386
487
 
387
488
  private getBucketStateUpdates(): mongo.AnyBulkWriteOperation<BucketStateDocumentV3>[] {
388
- return Array.from(this.bucketStates.values()).map((state: BucketStateUpdate) => {
489
+ return Array.from(this.operations.bucketStates.values()).map((state: BucketStateUpdate) => {
389
490
  if (state.definitionId == null) {
390
491
  throw new ReplicationAssertionError('Expected bucket definition id when incrementalReprocessing is enabled');
391
492
  }