@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.
Files changed (99) hide show
  1. package/CHANGELOG.md +42 -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
@@ -2,6 +2,7 @@ import { mongo } from '@powersync/lib-service-mongodb';
2
2
  import { ReplicationAssertionError } from '@powersync/lib-services-framework';
3
3
  import { VersionedPowerSyncMongo } from './db.js';
4
4
  import { MongoIdSequence, OpIdRange } from './MongoIdSequence.js';
5
+ import { REPLICATION_WRITE_OPTIONS } from './MongoReplicationOptions.js';
5
6
 
6
7
  const RESERVATION_SIZE = 65_536n;
7
8
  const LOW_WATER_MARK = 16_384n;
@@ -23,13 +24,19 @@ export class MongoOpIdAllocator {
23
24
  }
24
25
 
25
26
  sequence(persistedOp: bigint): MongoIdSequence {
26
- // The stream is fenced before reading this head. Another writer may have
27
- // committed since this allocator was last used, including in another process.
28
- // Never reuse reserved IDs below that durable head.
27
+ // Transactional writers pass the fenced stream head. Prepared v3/v4 writers
28
+ // pass their last ID within this publication (initially zero). Their per-lease
29
+ // ranges are globally disjoint, and assigned IDs are consumed before publish.
30
+ // Publication asserts that its final ID advances the fenced persisted head.
29
31
  return new MongoIdSequence(persistedOp, this.ranges.slice());
30
32
  }
31
33
 
32
- committed(lastOp: bigint) {
34
+ /**
35
+ * Remove IDs assigned to sealed work, even if publication is later abandoned.
36
+ * This only consumes allocation capacity; it does not advance durable progress.
37
+ * Keep the unused tail available to the next publication under this lease.
38
+ */
39
+ consume(lastOp: bigint) {
33
40
  this.ranges = this.ranges
34
41
  .filter((range) => range.end > lastOp)
35
42
  .map((range) => ({ start: range.start > lastOp ? range.start : lastOp + 1n, end: range.end }));
@@ -86,7 +93,7 @@ export class MongoOpIdAllocator {
86
93
  }
87
94
  }
88
95
  ],
89
- { upsert: true, returnDocument: 'before', writeConcern: { w: 'majority' } }
96
+ { upsert: true, returnDocument: 'before', ...REPLICATION_WRITE_OPTIONS }
90
97
  );
91
98
  const last = previous?.op_id ?? 0n;
92
99
  if (last > MAX_OP_ID - RESERVATION_SIZE) {
@@ -1,6 +1,10 @@
1
1
  import * as sqlite from 'node:sqlite';
2
2
 
3
- import { ReplicationAssertionError, ServiceAssertionError } from '@powersync/lib-services-framework';
3
+ import {
4
+ ReplicationAbortedError,
5
+ ReplicationAssertionError,
6
+ ServiceAssertionError
7
+ } from '@powersync/lib-services-framework';
4
8
  import {
5
9
  BucketDefinitionMapping,
6
10
  MultiSyncConfigBucketDefinitionMapping,
@@ -26,6 +30,41 @@ import { StorageConfig } from './models.js';
26
30
  import { MongoHydrationState } from './MongoHydrationState.js';
27
31
 
28
32
  export class MongoParsedSyncConfigSet implements storage.ParsedSyncConfigSet {
33
+ /** Config definitions/mappings are immutable; only their embedded lifecycle state changes. */
34
+ assertPublicationConfigs(states: readonly { _id: { toHexString(): string }; state: storage.SyncRuleState }[]): void {
35
+ const configured = new Set(this.#configsWithMapping.map((config) => config.syncConfigId));
36
+ const live = new Set(
37
+ states
38
+ .filter((state) =>
39
+ [storage.SyncRuleState.ACTIVE, storage.SyncRuleState.PROCESSING, storage.SyncRuleState.ERRORED].includes(
40
+ state.state
41
+ )
42
+ )
43
+ .map((state) => state._id.toHexString())
44
+ );
45
+ // Snapshot/metadata writers may intentionally select only one processing
46
+ // config while another active config serves clients. Full-stream checkpoint
47
+ // writers separately require every replicating config in their checkpoint guard.
48
+ if (![...live].some((id) => configured.has(id))) {
49
+ throw new ReplicationAbortedError('Replication writer must reload the selected sync configs');
50
+ }
51
+ const mappings = this.#configsWithMapping
52
+ .filter((config) => live.has(config.syncConfigId!))
53
+ .map((config) => config.mapping!);
54
+ // A stopped selected config is harmless only when all its persisted outputs
55
+ // are still owned by live selected configs. Cleanup may remove every other id.
56
+ const activeBuckets = new Set(mappings.flatMap((mapping) => mapping.allBucketDefinitionIds()));
57
+ const activeParameters = new Set(mappings.flatMap((mapping) => mapping.allParameterIndexIds()));
58
+ const activeEvents = new Set(mappings.flatMap((mapping) => mapping.allEventDefinitionIds()));
59
+ if (
60
+ this.mapping.allBucketDefinitionIds().some((id) => !activeBuckets.has(id)) ||
61
+ this.mapping.allParameterIndexIds().some((id) => !activeParameters.has(id)) ||
62
+ this.mapping.allEventDefinitionIds().some((id) => !activeEvents.has(id))
63
+ ) {
64
+ throw new ReplicationAbortedError('Replication writer mappings are no longer live; reload the sync configs');
65
+ }
66
+ }
67
+
29
68
  public readonly hydrationState: HydrationState;
30
69
  public readonly syncConfigs: SyncConfigWithErrors[];
31
70
  public readonly replicationStreamName: string;
@@ -0,0 +1,28 @@
1
+ import { mongo } from '@powersync/lib-service-mongodb';
2
+
3
+ /**
4
+ * Read options for replication preparation outside publication transactions.
5
+ * Preparation produces writes that publication replays without rereading their
6
+ * source records, so it must not depend on data that can later be rolled back.
7
+ * Majority reads, majority writes and the lease's shared causal session provide
8
+ * durable causal ordering. A causal session alone does not select majority read
9
+ * concern; the ordinary default is local. Primary is the usual routing default,
10
+ * made explicit here so preparation does not inherit a different preference.
11
+ * Set transaction read concern at transaction level, not through these options.
12
+ */
13
+ export const REPLICATION_READ_OPTIONS = {
14
+ readConcern: 'majority',
15
+ readPreference: 'primary'
16
+ } as const satisfies Pick<mongo.CommandOperationOptions, 'readConcern' | 'readPreference'>;
17
+
18
+ /**
19
+ * Acknowledge durable replication writes before using allocated IDs, reporting
20
+ * publication success or preparing subsequent work in the shared causal session.
21
+ * Apply at transaction level for publication, and at operation level for writes
22
+ * outside transactions (ID reservations, metadata and post-commit cleanup).
23
+ * Resume-position hints deliberately use w: 1 instead: losing a hint causes
24
+ * source replay, while row publication and checkpoints still require durability.
25
+ */
26
+ export const REPLICATION_WRITE_OPTIONS = {
27
+ writeConcern: { w: 'majority' }
28
+ } as const satisfies Pick<mongo.TransactionOptions, 'writeConcern'>;
@@ -34,7 +34,6 @@ import {
34
34
  MongoGetCheckpointChangesOptions,
35
35
  MongoSyncBucketStorageCheckpoint
36
36
  } from './common/MongoSyncBucketStorageCheckpoint.js';
37
- import { DEFAULT_INLINE_THRESHOLD_BYTES } from './common/PersistedBatch.js';
38
37
  import type { VersionedPowerSyncMongo } from './db.js';
39
38
  import { StorageConfig } from './models.js';
40
39
  import { MongoBucketBatchOptions } from './MongoBucketBatch.js';
@@ -46,6 +45,7 @@ import { MongoPersistedReplicationStream } from './MongoPersistedReplicationStre
46
45
  import { MongoSyncRulesLock } from './MongoSyncRulesLock.js';
47
46
  import { MongoCheckpointAPIOptions, MongoWriteCheckpointAPI } from './MongoWriteCheckpointAPI.js';
48
47
  import { ObjectStorage } from './v3/object-storage/ObjectStorage.js';
48
+ import { DEFAULT_INLINE_THRESHOLD_BYTES } from './v3/PersistedBatchV3.js';
49
49
 
50
50
  export interface MongoSyncBucketStorageOptions {
51
51
  checksumOptions?: Omit<MongoChecksumOptions, 'storageConfig'>;
@@ -278,7 +278,6 @@ export abstract class MongoSyncBucketStorage
278
278
  const replicationLock = this.requireReplicationLock();
279
279
  return {
280
280
  logger: options.logger ?? this.logger,
281
- db: this.db,
282
281
  parsedSyncConfig: this.getParsedSyncConfigSet(options),
283
282
  replicationStreamId: this.replicationStreamId,
284
283
  replicationStreamName: this.replicationStreamName,
@@ -464,7 +463,7 @@ export abstract class MongoSyncBucketStorage
464
463
  }
465
464
 
466
465
  const stream = mergeAsyncIterables(
467
- [this.checkpointChangesStream(signal), this.checkpointTimeoutStream(signal)],
466
+ [(signal) => this.checkpointChangesStream(signal), (signal) => this.checkpointTimeoutStream(signal)],
468
467
  signal
469
468
  );
470
469
 
@@ -12,8 +12,15 @@ const LOCK_DURATION_MS = 60 * 1000;
12
12
  * processes that replication stream at a time.
13
13
  */
14
14
  export class MongoSyncRulesLock implements storage.ReplicationLock {
15
- /** FIFO admission shared by this lease's writers; database fencing still enforces ownership. */
15
+ /** Public writer operations hold this mutex through preparation, publication and cleanup. */
16
16
  readonly writerMutex = new utils.Mutex();
17
+ /**
18
+ * All writers use this causal session sequentially under writerMutex. Reusing
19
+ * it carries earlier writes into later majority reads without copying clocks.
20
+ * Only lease teardown ends it; disposing one writer must not affect the rest.
21
+ * Lease acquisition and renewal use independent operations and never wait here.
22
+ */
23
+ readonly session: mongo.ClientSession;
17
24
 
18
25
  private readonly abort = new AbortController();
19
26
  readonly signal = this.abort.signal;
@@ -71,37 +78,42 @@ export class MongoSyncRulesLock implements storage.ReplicationLock {
71
78
  }
72
79
 
73
80
  constructor(
74
- private db: VersionedPowerSyncMongo,
81
+ public readonly db: VersionedPowerSyncMongo,
75
82
  public sync_rules_id: number,
76
83
  public readonly lock_id: string,
77
84
  private logger: Logger
78
85
  ) {
86
+ this.session = db.client.startSession({ causalConsistency: true });
79
87
  this.refreshInterval = setInterval(async () => {
80
88
  try {
81
89
  await this.refresh();
82
90
  } catch (e) {
83
- this.abort.abort(e);
91
+ this.invalidate(e);
84
92
  this.logger.error('Failed to refresh lock', e);
85
- clearInterval(this.refreshInterval);
86
93
  }
87
94
  }, 30_130);
88
95
  }
89
96
 
90
97
  async release(): Promise<void> {
91
- this.abort.abort(new Error('Replication lock released'));
92
- clearInterval(this.refreshInterval);
93
- const result = await this.db.sync_rules.updateOne(
94
- {
95
- _id: this.sync_rules_id,
96
- 'lock.id': this.lock_id
97
- },
98
- {
99
- $unset: { lock: 1 }
98
+ this.invalidate(new Error('Replication lock released'));
99
+ try {
100
+ const result = await this.db.sync_rules.updateOne(
101
+ {
102
+ _id: this.sync_rules_id,
103
+ 'lock.id': this.lock_id
104
+ },
105
+ {
106
+ $unset: { lock: 1 }
107
+ }
108
+ );
109
+ if (result.modifiedCount == 0) {
110
+ // Log and ignore
111
+ this.logger.warn(`Lock already released: ${this.sync_rules_id}/${this.lock_id}`);
100
112
  }
101
- );
102
- if (result.modifiedCount == 0) {
103
- // Log and ignore
104
- this.logger.warn(`Lock already released: ${this.sync_rules_id}/${this.lock_id}`);
113
+ } finally {
114
+ // Let an active operation finish/abort before ending its session. Queued
115
+ // writers check the aborted lease after acquiring the mutex and do no I/O.
116
+ await this.writerMutex.exclusiveLock(() => this.session.endSession());
105
117
  }
106
118
  }
107
119
 
@@ -109,6 +121,16 @@ export class MongoSyncRulesLock implements storage.ReplicationLock {
109
121
  this.signal.throwIfAborted();
110
122
  }
111
123
 
124
+ /**
125
+ * Stop all local writers, including queued operations, when continuing this
126
+ * replication attempt would be unsafe. The job still releases the database
127
+ * lease during teardown; otherwise it expires after renewal stops.
128
+ */
129
+ invalidate(reason: unknown): void {
130
+ this.abort.abort(reason);
131
+ clearInterval(this.refreshInterval);
132
+ }
133
+
112
134
  static ownerFilter(streamId: number, lock: MongoSyncRulesLock) {
113
135
  lock.throwIfAborted();
114
136
  return { _id: streamId, 'lock.id': lock.lock_id };
@@ -0,0 +1,353 @@
1
+ import { logger as defaultLogger, Logger } from '@powersync/lib-services-framework';
2
+ import { InternalOpId, storage, utils } from '@powersync/service-core';
3
+ import { JSONBig } from '@powersync/service-jsonbig';
4
+ import { BucketDataSource, BucketDefinitionId } from '@powersync/service-sync-rules';
5
+ import { mongoTableId, replicaIdToSubkey } from '../../../utils/util.js';
6
+ import { currentBucketKey, MAX_ROW_SIZE } from '../MongoBucketBatchShared.js';
7
+ import { TaggedBucketParameterDocument } from '../models.js';
8
+ import { BucketDataDoc, BucketKey } from './BucketDataDoc.js';
9
+ import { BucketStateUpdate, SaveBucketDataOptions } from './PersistedBatch.js';
10
+ import { PublicationStats } from './PreparedPublication.js';
11
+ import { SourceRecordBucketState } from './SourceRecordStore.js';
12
+
13
+ /**
14
+ * Maximum size of operations we write in a single transaction.
15
+ *
16
+ * It's tricky to find the exact limit, but from experience, over 100MB
17
+ * can cause an error:
18
+ * > transaction is too large and will not fit in the storage engine cache
19
+ *
20
+ * Additionally, unbounded size here can balloon our memory usage in some edge
21
+ * cases.
22
+ *
23
+ * When we reach this threshold, we commit the transaction and start a new one.
24
+ */
25
+ const MAX_TRANSACTION_BATCH_SIZE = 30_000_000;
26
+
27
+ /**
28
+ * Limit number of documents to write in a single transaction.
29
+ *
30
+ * This has an effect on error message size in some cases.
31
+ */
32
+ const MAX_TRANSACTION_DOC_COUNT = 2_000;
33
+
34
+ /**
35
+ * Shared operation generation, size accounting and diagnostics. Each storage
36
+ * version owns this buffer and decides when to write, retain or clear its data.
37
+ * No transaction, sealing or payload-preparation state lives here.
38
+ */
39
+ export class BatchOperationBuffer {
40
+ bucketData: BucketDataDoc[] = [];
41
+ bucketParameters: TaggedBucketParameterDocument[] = [];
42
+ bucketStates = new Map<string, BucketStateUpdate>();
43
+
44
+ /**
45
+ * For debug logging only.
46
+ */
47
+ debugLastOpId: InternalOpId | null = null;
48
+
49
+ /**
50
+ * Very rough estimate of transaction size.
51
+ */
52
+ currentSize: number;
53
+
54
+ constructor(
55
+ private readonly group_id: number,
56
+ writtenSize: number,
57
+ private readonly definitions: {
58
+ checkDefinitionId(id: BucketDefinitionId | null): BucketDefinitionId;
59
+ getBucketDefinitionId(source: BucketDataSource): BucketDefinitionId;
60
+ },
61
+ private readonly logger: Logger = defaultLogger
62
+ ) {
63
+ this.currentSize = writtenSize;
64
+ }
65
+
66
+ saveBucketData(options: SaveBucketDataOptions) {
67
+ const remaining_buckets = new Map<string, SourceRecordBucketState>();
68
+ for (let bucket of options.before_buckets) {
69
+ const mapped: SourceRecordBucketState = {
70
+ bucket: bucket.bucket,
71
+ definitionId: this.definitions.checkDefinitionId(bucket.definitionId),
72
+ id: bucket.id,
73
+ table: bucket.table
74
+ };
75
+ if (options.table.bucketDataSourceIds != null && !options.table.bucketDataSourceIds.has(mapped.definitionId!)) {
76
+ // The bucket definition is not active anymore.
77
+ // We don't cleanup these references upfront, but do need to ignore them after the definition is removed.
78
+ continue;
79
+ }
80
+ remaining_buckets.set(currentBucketKey(mapped), mapped);
81
+ }
82
+
83
+ const subkey = this.persistedSubkey(options.table.id, options.sourceKey);
84
+ const dchecksum = BigInt(utils.hashDelete(subkey));
85
+
86
+ for (const evaluated of options.evaluated) {
87
+ const definitionId = this.definitions.getBucketDefinitionId(evaluated.source);
88
+ const key = currentBucketKey({
89
+ definitionId: definitionId,
90
+ bucket: evaluated.bucket,
91
+ table: evaluated.table,
92
+ id: evaluated.id
93
+ });
94
+
95
+ const recordData = JSONBig.stringify(evaluated.data);
96
+ const checksum = utils.hashData(evaluated.table, evaluated.id, recordData);
97
+ if (recordData.length > MAX_ROW_SIZE) {
98
+ const message = `Row ${key} too large: ${recordData.length} bytes. Removing.`;
99
+ this.logger.error(message);
100
+ continue;
101
+ }
102
+
103
+ remaining_buckets.delete(key);
104
+ const byteEstimate = recordData.length + 200;
105
+ this.currentSize += byteEstimate;
106
+
107
+ const op_id = options.op_seq.next();
108
+ this.debugLastOpId = op_id;
109
+
110
+ this.addBucketDataPut({
111
+ bucketKey: {
112
+ bucket: evaluated.bucket,
113
+ definitionId: definitionId,
114
+ replicationStreamId: this.group_id
115
+ },
116
+ op_id,
117
+ bucket: evaluated.bucket,
118
+ sourceTableId: options.table.id,
119
+ sourceKey: options.sourceKey,
120
+ subkey,
121
+ table: evaluated.table,
122
+ rowId: evaluated.id,
123
+ checksum: BigInt(checksum),
124
+ data: recordData
125
+ });
126
+ this.incrementBucket(definitionId, evaluated.bucket, op_id, byteEstimate);
127
+ }
128
+
129
+ for (let bucket of remaining_buckets.values()) {
130
+ const definitionId = bucket.definitionId!;
131
+ const op_id = options.op_seq.next();
132
+ this.debugLastOpId = op_id;
133
+
134
+ this.addBucketDataRemove({
135
+ bucketKey: {
136
+ replicationStreamId: this.group_id,
137
+ definitionId,
138
+ bucket: bucket.bucket
139
+ },
140
+ op_id,
141
+ sourceTableId: options.table.id,
142
+ sourceKey: options.sourceKey,
143
+ subkey,
144
+ table: bucket.table,
145
+ rowId: bucket.id,
146
+ checksum: dchecksum
147
+ });
148
+ this.currentSize += 200;
149
+ this.incrementBucket(definitionId, bucket.bucket, op_id, 200);
150
+ }
151
+ }
152
+
153
+ /**
154
+ * New bucket operations persist protocol subkeys during replication. Older
155
+ * V1 documents may not have one, so sync reads retain a fallback.
156
+ */
157
+ persistedSubkey(table: storage.SourceTableId, key: storage.ReplicaId): string {
158
+ return replicaIdToSubkey(table, key);
159
+ }
160
+
161
+ get bucketDataCount(): number {
162
+ return this.bucketData.length;
163
+ }
164
+
165
+ incrementBucket(definitionId: BucketDefinitionId, bucket: string, op_id: InternalOpId, bytes: number) {
166
+ const key = `${definitionId ?? ''}:${bucket}`;
167
+ let existingState = this.bucketStates.get(key);
168
+ if (existingState) {
169
+ existingState.lastOp = op_id;
170
+ existingState.incrementCount += 1;
171
+ existingState.incrementBytes += bytes;
172
+ } else {
173
+ this.bucketStates.set(key, {
174
+ definitionId,
175
+ bucket,
176
+ lastOp: op_id,
177
+ incrementCount: 1,
178
+ incrementBytes: bytes,
179
+ incrementChunks: 0
180
+ });
181
+ }
182
+ }
183
+
184
+ /**
185
+ * V3 persists operations in chunks. Keep this separate from incrementBucket:
186
+ * operation counts are known while evaluating rows, while chunk counts and
187
+ * exact persisted bytes are only known after the writer has chunked a flush.
188
+ */
189
+ incrementBucketPersistedChunk(definitionId: BucketDefinitionId, bucket: string, bytes: number) {
190
+ const key = `${definitionId ?? ''}:${bucket}`;
191
+ const existingState = this.bucketStates.get(key);
192
+ if (existingState != null) {
193
+ existingState.incrementChunks += 1;
194
+ existingState.incrementBytes += bytes;
195
+ }
196
+ }
197
+
198
+ /**
199
+ * V3's compactor calculates byte deltas from persisted chunk metadata.
200
+ * Replace the writer's per-operation estimate with those exact sizes before
201
+ * flushing the corresponding bucket state.
202
+ */
203
+ resetBucketPersistedBytes(definitionId: BucketDefinitionId, bucket: string) {
204
+ const state = this.bucketStates.get(`${definitionId ?? ''}:${bucket}`);
205
+ if (state != null) {
206
+ state.incrementBytes = 0;
207
+ }
208
+ }
209
+
210
+ addBucketDataPut(options: {
211
+ op_id: InternalOpId;
212
+ bucketKey: BucketKey;
213
+ bucket: string;
214
+ sourceTableId: storage.SourceTable['id'];
215
+ sourceKey: storage.ReplicaId;
216
+ subkey: string;
217
+ table: string;
218
+ rowId: string;
219
+ checksum: bigint;
220
+ data: string;
221
+ }) {
222
+ this.bucketData.push({
223
+ bucketKey: options.bucketKey,
224
+ o: options.op_id,
225
+ op: 'PUT',
226
+ source_table: mongoTableId(options.sourceTableId),
227
+ source_key: options.sourceKey,
228
+ subkey: options.subkey,
229
+ table: options.table,
230
+ row_id: options.rowId,
231
+ checksum: options.checksum,
232
+ data: options.data
233
+ });
234
+ }
235
+
236
+ addBucketDataRemove(options: {
237
+ op_id: InternalOpId;
238
+ bucketKey: BucketKey;
239
+ sourceTableId: storage.SourceTable['id'];
240
+ sourceKey: storage.ReplicaId;
241
+ subkey: string;
242
+ table: string;
243
+ rowId: string;
244
+ checksum: bigint;
245
+ }) {
246
+ this.bucketData.push({
247
+ bucketKey: options.bucketKey,
248
+ o: options.op_id,
249
+ op: 'REMOVE',
250
+ source_table: mongoTableId(options.sourceTableId),
251
+ source_key: options.sourceKey,
252
+ subkey: options.subkey,
253
+ table: options.table,
254
+ row_id: options.rowId,
255
+ checksum: options.checksum,
256
+ data: null
257
+ });
258
+ }
259
+
260
+ shouldFlushTransaction(currentDataCount: number) {
261
+ return (
262
+ this.currentSize >= MAX_TRANSACTION_BATCH_SIZE ||
263
+ this.bucketDataCount >= MAX_TRANSACTION_DOC_COUNT ||
264
+ currentDataCount >= MAX_TRANSACTION_DOC_COUNT ||
265
+ this.bucketParameters.length >= MAX_TRANSACTION_DOC_COUNT
266
+ );
267
+ }
268
+
269
+ /** Adopt one fully evaluated row; unfinished rows never mutate this buffer. */
270
+ append(row: BatchOperationBuffer): void {
271
+ // A single high-fanout row may exceed a normal batch target. Avoid the
272
+ // JavaScript argument-count limit while retaining its operation order.
273
+ for (const operation of row.bucketData) {
274
+ this.bucketData.push(operation);
275
+ }
276
+ for (const parameter of row.bucketParameters) {
277
+ this.bucketParameters.push(parameter);
278
+ }
279
+ for (const [key, state] of row.bucketStates) {
280
+ const previous = this.bucketStates.get(key);
281
+ this.bucketStates.set(key, {
282
+ ...state,
283
+ incrementCount: (previous?.incrementCount ?? 0) + state.incrementCount,
284
+ incrementBytes: (previous?.incrementBytes ?? 0) + state.incrementBytes,
285
+ incrementChunks: (previous?.incrementChunks ?? 0) + state.incrementChunks
286
+ });
287
+ }
288
+ this.currentSize += row.currentSize;
289
+ this.debugLastOpId = row.debugLastOpId ?? this.debugLastOpId;
290
+ }
291
+
292
+ clear(): void {
293
+ this.bucketData = [];
294
+ this.bucketParameters = [];
295
+ this.bucketStates.clear();
296
+ this.currentSize = 0;
297
+ this.debugLastOpId = null;
298
+ }
299
+
300
+ logFlush(startAt: number, currentDataCount: number, options?: storage.BucketBatchCommitOptions): PublicationStats {
301
+ const flushedSomething =
302
+ this.bucketDataCount > 0 ||
303
+ this.bucketParameters.length > 0 ||
304
+ currentDataCount > 0 ||
305
+ this.bucketStates.size > 0;
306
+ if (flushedSomething) {
307
+ const duration = Math.round(performance.now() - startAt);
308
+ if (options?.oldestUncommittedChange != null) {
309
+ const replicationLag = Math.round((Date.now() - options.oldestUncommittedChange.getTime()) / 1000);
310
+
311
+ this.logger.info(
312
+ `Flushed: ${this.bucketDataCount} ops, ${this.bucketParameters.length} index entries, ${
313
+ currentDataCount
314
+ } records. ${Math.round(this.currentSize / 1024)}kb in ${duration}ms. Last op_id: ${this.debugLastOpId}. Replication lag: ${replicationLag}s`,
315
+ {
316
+ flushed: {
317
+ duration: duration,
318
+ size: this.currentSize,
319
+ bucket_ops_count: this.bucketDataCount,
320
+ parameter_indexes_count: this.bucketParameters.length,
321
+ source_records_count: currentDataCount,
322
+ replication_lag_seconds: replicationLag
323
+ }
324
+ }
325
+ );
326
+ } else {
327
+ this.logger.info(
328
+ `Flushed: ${this.bucketDataCount} ops, ${this.bucketParameters.length} index entries, ${
329
+ currentDataCount
330
+ } records. ${Math.round(this.currentSize / 1024)}kb in ${duration}ms. Last op_id: ${this.debugLastOpId}`,
331
+ {
332
+ flushed: {
333
+ duration: duration,
334
+ size: this.currentSize,
335
+ bucket_ops_count: this.bucketDataCount,
336
+ parameter_indexes_count: this.bucketParameters.length,
337
+ source_records_count: currentDataCount
338
+ }
339
+ }
340
+ );
341
+ }
342
+ }
343
+
344
+ const stats = {
345
+ bucketDataCount: this.bucketDataCount,
346
+ parameterDataCount: this.bucketParameters.length,
347
+ currentDataCount: currentDataCount,
348
+ flushedAny: flushedSomething
349
+ };
350
+
351
+ return stats;
352
+ }
353
+ }