@powersync/service-module-mongodb-storage 0.24.0 → 0.24.2

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.
@@ -52,6 +52,8 @@ export interface MongoBucketStorageOptions {
52
52
  */
53
53
  replicationStreamNamePrefix: string;
54
54
  readPreference?: mongo.ReadPreference;
55
+ /** Read preference for collection discovery and collection stats used by storage metrics. */
56
+ statsReadPreference?: mongo.ReadPreference;
55
57
  checksumCacheTtlMs?: number;
56
58
  clearBatchThrottleRate?: number;
57
59
  defaultStorageVersion?: number;
@@ -714,7 +716,12 @@ export class MongoBucketStorage extends storage.BucketStorageFactory {
714
716
 
715
717
  if (storageConfig.incrementalReprocessing) {
716
718
  const v3 = doc as ReplicationStreamDocumentV3;
717
- const matching = v3.sync_configs.filter((c) => stateFilter.includes(c.state));
719
+ // Cleanup of a stopped stream requires all embedded configs, regardless of
720
+ // their individual states, so every config's storage can be terminated.
721
+ const matching =
722
+ v3.state == storage.SyncRuleState.STOP && stateFilter.includes(storage.SyncRuleState.STOP)
723
+ ? v3.sync_configs
724
+ : v3.sync_configs.filter((c) => stateFilter.includes(c.state));
718
725
  if (matching.length == 0) {
719
726
  return null;
720
727
  }
@@ -828,89 +835,47 @@ export class MongoBucketStorage extends storage.BucketStorageFactory {
828
835
  }
829
836
 
830
837
  async getStorageMetrics(): Promise<storage.StorageMetrics> {
831
- const ignoreNotExisting = (e: unknown) => {
832
- if (lib_mongo.isMongoNamespaceNotFoundError(e)) {
833
- // Collection doesn't exist - return 0
834
- return [{ storageStats: { size: 0 } }];
835
- } else {
836
- return Promise.reject(e);
837
- }
838
- };
839
-
840
- const aggregateStaticCollection = async <T extends mongo.Document>(collection: mongo.Collection<T>) => {
841
- // We check whether the collection exists before getting the statistics. This avoids repeated
842
- // errors in the MongoDB logs if the collection hasn't been created yet.
843
- const exists =
844
- (await this.db.db.listCollections({ name: collection.collectionName }, { nameOnly: true }).toArray()).length >
845
- 0;
846
- if (!exists) {
847
- return [{ storageStats: { size: 0 } }];
848
- }
849
-
850
- return collection
851
- .aggregate([
852
- {
853
- $collStats: {
854
- storageStats: {}
855
- }
838
+ // We check which collections exist before getting statistics to avoid repeated errors in
839
+ // MongoDB logs for collections that have not been created yet.
840
+ const collections = await this.db.db
841
+ .listCollections(
842
+ {
843
+ name: {
844
+ $regex: '^(bucket_data|bucket_parameters|current_data)$|^(bucket_data_|parameter_index_|source_records_)'
856
845
  }
857
- ])
858
- .toArray()
859
- .catch(ignoreNotExisting);
860
- };
861
-
862
- const operations_aggregate = await aggregateStaticCollection(this.db.bucket_data);
863
- const v3OperationCollections = await this.db.listBucketDataCollectionsV3();
864
- const v3_operation_aggregates = await Promise.all(
865
- v3OperationCollections.map((collection) =>
866
- collection
867
- .aggregate([
868
- {
869
- $collStats: {
870
- storageStats: {}
871
- }
872
- }
873
- ])
874
- .toArray()
875
- .catch(ignoreNotExisting)
876
- )
877
- );
878
-
879
- const parameters_aggregate = await aggregateStaticCollection(this.db.bucket_parameters);
880
-
881
- const v3ParameterCollections = await this.db.listAllParameterIndexCollectionsV3();
882
- const v3_parameter_aggregates = await Promise.all(
883
- v3ParameterCollections.map((collection) =>
884
- collection
885
- .aggregate([
886
- {
887
- $collStats: {
888
- storageStats: {}
889
- }
890
- }
891
- ])
892
- .toArray()
893
- .catch(ignoreNotExisting)
894
- )
895
- );
896
-
897
- const v1_source_record_aggregate = await aggregateStaticCollection(this.db.current_data);
898
-
899
- const v3SourceRecordCollections = await this.db.listAllSourceRecordCollectionsV3();
900
- const source_record_aggregates = await Promise.all(
901
- v3SourceRecordCollections.map((collection) =>
902
- collection
903
- .aggregate([
904
- {
905
- $collStats: {
906
- storageStats: {}
907
- }
908
- }
909
- ])
910
- .toArray()
911
- .catch(ignoreNotExisting)
846
+ },
847
+ { nameOnly: true, readPreference: this.options.statsReadPreference }
912
848
  )
913
- );
849
+ .toArray();
850
+ const sizesByCollection = new Map<string, number>();
851
+ const statsPipeline: mongo.Document[] = [
852
+ { $collStats: { storageStats: {} } },
853
+ { $project: { ns: 1, size: '$storageStats.size' } }
854
+ ];
855
+ // MongoDB has a limit of 1000 stages per aggregation pipeline. Additionally, we want
856
+ // to avoid too much work per aggregation, so we limit the number of collections per batch.
857
+ const COLLECTIONS_PER_BATCH = 100;
858
+ // Query batches sequentially so metrics collection does not fill the connection pool on standby pods.
859
+ // If a collection disappears during aggregation, let the next metrics collection retry.
860
+ for (let offset = 0; offset < collections.length; offset += COLLECTIONS_PER_BATCH) {
861
+ const batch = collections.slice(offset, offset + COLLECTIONS_PER_BATCH);
862
+ const pipeline = [
863
+ ...statsPipeline,
864
+ ...batch.slice(1).map((collection) => ({
865
+ $unionWith: { coll: collection.name, pipeline: statsPipeline }
866
+ }))
867
+ ];
868
+ const stats = await this.db.db
869
+ .collection(batch[0].name)
870
+ .aggregate<{ ns: string; size: number | bigint }>(pipeline, {
871
+ readPreference: this.options.statsReadPreference
872
+ })
873
+ .toArray();
874
+ for (const stat of stats) {
875
+ const name = stat.ns.slice(this.db.db.databaseName.length + 1);
876
+ sizesByCollection.set(name, (sizesByCollection.get(name) ?? 0) + Number(stat.size));
877
+ }
878
+ }
914
879
 
915
880
  const v3StorageConfig = getMongoStorageConfig(storage.STORAGE_VERSION_3) as StorageConfig & {
916
881
  incrementalReprocessing: true;
@@ -922,29 +887,8 @@ export class MongoBucketStorage extends storage.BucketStorageFactory {
922
887
  .find({ storage_version: { $gte: storage.STORAGE_VERSION_3 } }, { projection: { _id: 1, sync_configs: 1 } })
923
888
  .toArray()) as unknown as Pick<ReplicationStreamDocumentV3, '_id' | 'sync_configs'>[];
924
889
 
925
- const collectionSizes = new Map<string, number>();
926
- const addCollectionSize = (
927
- collection: { collectionName: string },
928
- aggregate: { storageStats?: { size?: number | bigint } }[],
929
- prefix: string,
930
- sizes: Map<string, number>
931
- ) => {
932
- const match = collection.collectionName.match(new RegExp(`^${prefix}(\\d+)_(.+)$`));
933
- if (match == null) {
934
- return;
935
- }
936
- sizes.set(`${prefix}${match[1]}:${match[2]}`, Number(aggregate[0]?.storageStats?.size ?? 0));
937
- };
938
-
939
- v3OperationCollections.forEach((collection, index) =>
940
- addCollectionSize(collection, v3_operation_aggregates[index], 'bucket_data_', collectionSizes)
941
- );
942
- v3ParameterCollections.forEach((collection, index) =>
943
- addCollectionSize(collection, v3_parameter_aggregates[index], 'parameter_index_', collectionSizes)
944
- );
945
- v3SourceRecordCollections.forEach((collection, index) =>
946
- addCollectionSize(collection, source_record_aggregates[index], 'source_records_', collectionSizes)
947
- );
890
+ const sumCollectionSizesByPrefix = (prefix: string) =>
891
+ [...sizesByCollection].reduce((total, [name, size]) => total + (name.startsWith(prefix) ? size : 0), 0);
948
892
 
949
893
  const syncConfigIds = v3StreamDocs.flatMap((stream) => (stream.sync_configs ?? []).map((config) => config._id));
950
894
  const syncConfigDefinitions =
@@ -961,15 +905,13 @@ export class MongoBucketStorage extends storage.BucketStorageFactory {
961
905
  number,
962
906
  { _id: ObjectId; bucket_data_source_ids: string[]; parameter_lookup_source_ids: string[] }[]
963
907
  >();
964
- await Promise.all(
965
- v3StreamDocs.map(async (stream) => {
966
- const sourceTables = await v3Db
967
- .sourceTables(stream._id)
968
- .find({}, { projection: { _id: 1, bucket_data_source_ids: 1, parameter_lookup_source_ids: 1 } })
969
- .toArray();
970
- sourceTablesByStream.set(stream._id, sourceTables);
971
- })
972
- );
908
+ for (const stream of v3StreamDocs) {
909
+ const sourceTables = await v3Db
910
+ .sourceTables(stream._id)
911
+ .find({}, { projection: { _id: 1, bucket_data_source_ids: 1, parameter_lookup_source_ids: 1 } })
912
+ .toArray();
913
+ sourceTablesByStream.set(stream._id, sourceTables);
914
+ }
973
915
 
974
916
  const objectStorageSizeByDefinition = new Map<string, number>();
975
917
  for (const usage of objectStorageDefinitionUsage) {
@@ -980,7 +922,7 @@ export class MongoBucketStorage extends storage.BucketStorageFactory {
980
922
  }
981
923
 
982
924
  const sumCollectionSizes = (prefix: string, streamId: number, ids: ReadonlySet<string>) =>
983
- [...ids].reduce((total, id) => total + (collectionSizes.get(`${prefix}${streamId}:${id}`) ?? 0), 0);
925
+ [...ids].reduce((total, id) => total + (sizesByCollection.get(`${prefix}${streamId}_${id}`) ?? 0), 0);
984
926
 
985
927
  const syncConfigMetrics: storage.StorageSyncConfigMetrics[] = [];
986
928
  for (const stream of v3StreamDocs) {
@@ -1003,7 +945,7 @@ export class MongoBucketStorage extends storage.BucketStorageFactory {
1003
945
  ) {
1004
946
  return total;
1005
947
  }
1006
- return total + (collectionSizes.get(`source_records_${stream._id}:${sourceTable._id.toHexString()}`) ?? 0);
948
+ return total + (sizesByCollection.get(`source_records_${stream._id}_${sourceTable._id.toHexString()}`) ?? 0);
1007
949
  }, 0);
1008
950
 
1009
951
  syncConfigMetrics.push({
@@ -1026,15 +968,11 @@ export class MongoBucketStorage extends storage.BucketStorageFactory {
1026
968
  0n
1027
969
  );
1028
970
  return {
1029
- operations_size_bytes:
1030
- Number(operations_aggregate[0].storageStats.size) +
1031
- v3_operation_aggregates.reduce((total, aggregate) => total + Number(aggregate[0].storageStats.size), 0),
971
+ operations_size_bytes: (sizesByCollection.get('bucket_data') ?? 0) + sumCollectionSizesByPrefix('bucket_data_'),
1032
972
  parameters_size_bytes:
1033
- Number(parameters_aggregate[0].storageStats.size) +
1034
- v3_parameter_aggregates.reduce((total, aggregate) => total + Number(aggregate[0].storageStats.size), 0),
973
+ (sizesByCollection.get('bucket_parameters') ?? 0) + sumCollectionSizesByPrefix('parameter_index_'),
1035
974
  replication_size_bytes:
1036
- Number(v1_source_record_aggregate[0]?.storageStats?.size ?? 0) +
1037
- source_record_aggregates.reduce((total, aggregate) => total + Number(aggregate[0]?.storageStats?.size ?? 0), 0),
975
+ (sizesByCollection.get('current_data') ?? 0) + sumCollectionSizesByPrefix('source_records_'),
1038
976
  object_storage_size_bytes: Number(totalObjectStorageSize),
1039
977
  sync_config_metrics: syncConfigMetrics
1040
978
  };
@@ -78,6 +78,10 @@ export class MongoStorageProvider implements storage.StorageProvider {
78
78
  chunkCompactionConcurrency,
79
79
  replicationStreamNamePrefix: resolvedConfig.slot_name_prefix,
80
80
  readPreference,
81
+ statsReadPreference:
82
+ decodedConfig.stats_read_preference == null
83
+ ? undefined
84
+ : new lib_mongo.mongo.ReadPreference(decodedConfig.stats_read_preference),
81
85
  clearBatchThrottleRate: normalizeClearBatchThrottleRate(decodedConfig.clear_batch_throttle_rate),
82
86
  checksumCacheTtlMs: resolvedConfig.api_parameters.bucket_count_cache_ttl_minutes * 60_000,
83
87
  defaultStorageVersion: decodedConfig.default_storage_version,
@@ -10,6 +10,7 @@ import { MongoIdSequence, OpIdRangeExhausted } from '../MongoIdSequence.js';
10
10
  import { MongoSyncRulesLock } from '../MongoSyncRulesLock.js';
11
11
  import { MongoWriteBatch } from '../MongoWriteBatch.js';
12
12
  import { OperationBatch } from '../OperationBatch.js';
13
+ import { stopReplicationStreamPipeline } from '../SyncRuleStateUpdate.js';
13
14
  import { SourceRecordStore } from '../common/SourceRecordStore.js';
14
15
  import { SyncRuleDocumentBase } from '../models.js';
15
16
  import { PersistedBatchV1 } from './PersistedBatchV1.js';
@@ -853,11 +854,7 @@ export class MongoBucketBatchV1 extends MongoBucketBatch {
853
854
  _id: { $ne: this.replicationStreamId },
854
855
  state: { $in: [storage.SyncRuleState.ACTIVE, storage.SyncRuleState.ERRORED] }
855
856
  },
856
- {
857
- $set: {
858
- state: storage.SyncRuleState.STOP
859
- }
860
- },
857
+ stopReplicationStreamPipeline(),
861
858
  { session }
862
859
  );
863
860
  activated = true;
@@ -54,6 +54,12 @@ export const MongoStorageConfig = service_types.configFile.BaseStorageConfig.and
54
54
  * This is an experimental option, and may be removed in a future release.
55
55
  */
56
56
  bulk_read_preference: MongoStorageReadPreference.optional(),
57
+ /**
58
+ * Read preference for collection discovery and collection stats used by storage metrics.
59
+ * For example, use secondaryPreferred to offload collection stats from the primary.
60
+ * If unset, MongoDB driver defaults are used.
61
+ */
62
+ stats_read_preference: MongoStorageReadPreference.optional(),
57
63
  /**
58
64
  * Throttle clear operations after sync config deploys, by pausing between batches.
59
65
  * The rate produces a pause proportional to the previous batch duration.
@@ -7,7 +7,13 @@ import {
7
7
  storage,
8
8
  updateSyncRulesFromYaml
9
9
  } from '@powersync/service-core';
10
- import { bucketRequest, getTestStorage, register, test_utils } from '@powersync/service-core-tests';
10
+ import {
11
+ bucketRequest,
12
+ getTestStorage,
13
+ register,
14
+ releaseTestStorageLease,
15
+ test_utils
16
+ } from '@powersync/service-core-tests';
11
17
  import { parseSyncConfig, RequestParameters, ScopedParameterLookup } from '@powersync/service-sync-rules';
12
18
  import * as bson from 'bson';
13
19
  import { describe, expect, test } from 'vitest';
@@ -1951,6 +1957,24 @@ streams:
1951
1957
  }
1952
1958
  );
1953
1959
 
1960
+ test.runIf(storageVersion >= 3)('storage metrics collect sizes across union batches', async () => {
1961
+ await using factory = await storageConfig.factory();
1962
+ const mongoFactory = factory as MongoBucketStorage;
1963
+ const before = await factory.getStorageMetrics();
1964
+ let expectedSize = 0;
1965
+ for (let index = 0; index < 101; index++) {
1966
+ const collection = mongoFactory.db.db.collection(`bucket_data_999_metrics_${index}`);
1967
+ await collection.insertOne({ value: 'x'.repeat(index + 1) });
1968
+ const [stats] = await collection.aggregate([{ $collStats: { storageStats: {} } }]).toArray();
1969
+ expectedSize += Number(stats.storageStats.size);
1970
+ }
1971
+
1972
+ const after = await factory.getStorageMetrics();
1973
+ expect(after.operations_size_bytes - before.operations_size_bytes).toBe(expectedSize);
1974
+ expect(after.parameters_size_bytes).toBe(before.parameters_size_bytes);
1975
+ expect(after.replication_size_bytes).toBe(before.replication_size_bytes);
1976
+ });
1977
+
1954
1978
  test.runIf(storageVersion < 3)('storage metrics include v1 current_data', async () => {
1955
1979
  await using factory = await storageConfig.factory();
1956
1980
  const syncRules = await factory.updateSyncRules(
@@ -2160,6 +2184,91 @@ streams:
2160
2184
  }
2161
2185
 
2162
2186
  describe('sync - mongodb', () => {
2187
+ test.each([
2188
+ [1, false],
2189
+ [2, false],
2190
+ [1, true],
2191
+ [2, true]
2192
+ ] as const)('v%i activation cleans up v4 streams (previously stranded: %s)', async (storageVersion, stranded) => {
2193
+ await using factory = await INITIALIZED_MONGO_STORAGE_FACTORY.factory();
2194
+ const yaml = /* yaml */ `
2195
+ # sync config fixture.
2196
+ config:
2197
+ edition: 3
2198
+ streams:
2199
+ global:
2200
+ query: SELECT id FROM test
2201
+ `;
2202
+
2203
+ async function activate(version: number, lsn: string) {
2204
+ const stream = await factory.updateSyncRules(updateSyncRulesFromYaml(yaml, { storageVersion: version }));
2205
+ const bucketStorage = await getTestStorage(factory, stream);
2206
+ await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
2207
+ const sourceTable = await test_utils.resolveTestTable(writer, 'test', ['id'], INITIALIZED_MONGO_STORAGE_FACTORY);
2208
+ await writer.save({
2209
+ sourceTable,
2210
+ tag: storage.SaveOperationTag.INSERT,
2211
+ after: { id: 'item-1' },
2212
+ afterReplicaId: test_utils.rid('item-1')
2213
+ });
2214
+ await writer.markAllSnapshotDone(lsn);
2215
+ await writer.commit(lsn);
2216
+ return stream;
2217
+ }
2218
+
2219
+ const previous = await activate(storage.STORAGE_VERSION_4, '1/1');
2220
+ await releaseTestStorageLease(factory, previous.replicationStreamId);
2221
+ const oldCollections = await factory.db.db
2222
+ .listCollections(
2223
+ {
2224
+ name: new RegExp(
2225
+ `^(bucket_data|source_records|source_table|bucket_state|parameter_index)_${previous.replicationStreamId}(_|$)`
2226
+ )
2227
+ },
2228
+ { nameOnly: true }
2229
+ )
2230
+ .toArray();
2231
+ expect(oldCollections.some((collection) => collection.name.startsWith('bucket_data_'))).toBe(true);
2232
+ expect(oldCollections.some((collection) => collection.name.startsWith('source_records_'))).toBe(true);
2233
+
2234
+ const replacement = await activate(storageVersion, '2/1');
2235
+ const previousDoc = (await factory.db.sync_rules.findOne({
2236
+ _id: previous.replicationStreamId
2237
+ })) as ReplicationStreamDocumentV3;
2238
+ expect(previousDoc.state).toBe(storage.SyncRuleState.STOP);
2239
+ if (stranded) {
2240
+ // Reproduce databases left behind by older services: only the stream was stopped.
2241
+ await factory.db.sync_rules.updateOne(
2242
+ { _id: previous.replicationStreamId },
2243
+ { $set: { 'sync_configs.$[].state': storage.SyncRuleState.ACTIVE } }
2244
+ );
2245
+ } else {
2246
+ expect(previousDoc.sync_configs.map((config) => config.state)).toEqual([storage.SyncRuleState.STOP]);
2247
+ }
2248
+
2249
+ const stopped = await factory.getStoppedReplicationStreams();
2250
+ expect(stopped.map((stream) => stream.replicationStreamId)).toEqual([previous.replicationStreamId]);
2251
+ const lock = await stopped[0].lock();
2252
+ try {
2253
+ await factory.getInstance(stopped[0], { skipLifecycleHooks: true }).terminate({ clearStorage: true });
2254
+ } finally {
2255
+ await lock.release();
2256
+ }
2257
+
2258
+ expect((await factory.db.sync_rules.findOne({ _id: previous.replicationStreamId }))?.state).toBe(
2259
+ storage.SyncRuleState.TERMINATED
2260
+ );
2261
+ const remaining = new Set(
2262
+ (await factory.db.db.listCollections({}, { nameOnly: true }).toArray()).map((c) => c.name)
2263
+ );
2264
+ for (const collection of oldCollections) expect(remaining.has(collection.name)).toBe(false);
2265
+ expect(await factory.getStoppedReplicationStreams()).toEqual([]);
2266
+ expect((await factory.getActiveSyncConfig())?.replicationStream.replicationStreamId).toBe(
2267
+ replacement.replicationStreamId
2268
+ );
2269
+ expect(await factory.db.current_data.countDocuments({ '_id.g': replacement.replicationStreamId })).toBe(1);
2270
+ });
2271
+
2163
2272
  test('v3 activation stops legacy active sync rules', async () => {
2164
2273
  await using factory = await INITIALIZED_MONGO_STORAGE_FACTORY.factory();
2165
2274
  const mongoFactory = factory as MongoBucketStorage;