@powersync/service-module-mongodb-storage 0.0.0-dev-20250310210938 → 0.0.0-dev-20250312090341

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/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@powersync/service-module-mongodb-storage",
3
3
  "repository": "https://github.com/powersync-ja/powersync-service",
4
4
  "types": "dist/index.d.ts",
5
- "version": "0.0.0-dev-20250310210938",
5
+ "version": "0.0.0-dev-20250312090341",
6
6
  "main": "dist/index.js",
7
7
  "license": "FSL-1.1-Apache-2.0",
8
8
  "type": "module",
@@ -28,15 +28,15 @@
28
28
  "lru-cache": "^10.2.2",
29
29
  "uuid": "^9.0.1",
30
30
  "@powersync/lib-services-framework": "0.5.3",
31
- "@powersync/service-core": "0.0.0-dev-20250310210938",
31
+ "@powersync/service-core": "0.0.0-dev-20250312090341",
32
32
  "@powersync/service-jsonbig": "0.17.10",
33
33
  "@powersync/service-sync-rules": "0.24.1",
34
- "@powersync/service-types": "0.0.0-dev-20250310210938",
35
- "@powersync/lib-service-mongodb": "0.0.0-dev-20250310210938"
34
+ "@powersync/service-types": "0.9.0",
35
+ "@powersync/lib-service-mongodb": "0.0.0-dev-20250312090341"
36
36
  },
37
37
  "devDependencies": {
38
38
  "@types/uuid": "^9.0.4",
39
- "@powersync/service-core-tests": "0.0.0-dev-20250310210938"
39
+ "@powersync/service-core-tests": "0.0.0-dev-20250312090341"
40
40
  },
41
41
  "scripts": {
42
42
  "build": "tsc -b",
@@ -0,0 +1,40 @@
1
+ import { migrations } from '@powersync/service-core';
2
+ import * as storage from '../../../storage/storage-index.js';
3
+ import { MongoStorageConfig } from '../../../types/types.js';
4
+
5
+ const INDEX_NAME = 'bucket_updates';
6
+
7
+ export const up: migrations.PowerSyncMigrationFunction = async (context) => {
8
+ const {
9
+ service_context: { configuration }
10
+ } = context;
11
+ const db = storage.createPowerSyncMongo(configuration.storage as MongoStorageConfig);
12
+
13
+ try {
14
+ await db.bucket_state.createIndex(
15
+ {
16
+ '_id.g': 1,
17
+ last_op: 1
18
+ },
19
+ { name: INDEX_NAME, unique: true }
20
+ );
21
+ } finally {
22
+ await db.client.close();
23
+ }
24
+ };
25
+
26
+ export const down: migrations.PowerSyncMigrationFunction = async (context) => {
27
+ const {
28
+ service_context: { configuration }
29
+ } = context;
30
+
31
+ const db = storage.createPowerSyncMongo(configuration.storage as MongoStorageConfig);
32
+
33
+ try {
34
+ if (await db.bucket_state.indexExists(INDEX_NAME)) {
35
+ await db.bucket_state.dropIndex(INDEX_NAME);
36
+ }
37
+ } finally {
38
+ await db.client.close();
39
+ }
40
+ };
@@ -314,10 +314,12 @@ export class MongoCompactor {
314
314
  let lastOpId: BucketDataKey | null = null;
315
315
  let targetOp: bigint | null = null;
316
316
  let gotAnOp = false;
317
+ let numberOfOpsToClear = 0;
317
318
  for await (let op of query.stream()) {
318
319
  if (op.op == 'MOVE' || op.op == 'REMOVE' || op.op == 'CLEAR') {
319
320
  checksum = utils.addChecksums(checksum, op.checksum);
320
321
  lastOpId = op._id;
322
+ numberOfOpsToClear += 1;
321
323
  if (op.op != 'CLEAR') {
322
324
  gotAnOp = true;
323
325
  }
@@ -337,7 +339,7 @@ export class MongoCompactor {
337
339
  return;
338
340
  }
339
341
 
340
- logger.info(`Flushing CLEAR at ${lastOpId?.o}`);
342
+ logger.info(`Flushing CLEAR for ${numberOfOpsToClear} ops at ${lastOpId?.o}`);
341
343
  await this.db.bucket_data.deleteMany(
342
344
  {
343
345
  _id: {
@@ -362,6 +364,22 @@ export class MongoCompactor {
362
364
  },
363
365
  { session }
364
366
  );
367
+
368
+ // Note: This does not update anything if there is no existing state
369
+ await this.db.bucket_state.updateOne(
370
+ {
371
+ _id: {
372
+ g: this.group_id,
373
+ b: bucket
374
+ }
375
+ },
376
+ {
377
+ $inc: {
378
+ op_count: 1 - numberOfOpsToClear
379
+ }
380
+ },
381
+ { session }
382
+ );
365
383
  },
366
384
  {
367
385
  writeConcern: { w: 'majority' },
@@ -9,27 +9,30 @@ import {
9
9
  } from '@powersync/lib-services-framework';
10
10
  import {
11
11
  BroadcastIterable,
12
- CHECKPOINT_INVALIDATE_ALL,
13
12
  CheckpointChanges,
14
13
  GetCheckpointChangesOptions,
15
14
  InternalOpId,
16
15
  internalToExternalOpId,
17
16
  ProtocolOpId,
17
+ getLookupBucketDefinitionName,
18
18
  ReplicationCheckpoint,
19
- SourceTable,
20
19
  storage,
21
20
  utils,
22
- WatchWriteCheckpointOptions
21
+ WatchWriteCheckpointOptions,
22
+ CHECKPOINT_INVALIDATE_ALL,
23
+ deserializeParameterLookup
23
24
  } from '@powersync/service-core';
24
25
  import { SqliteJsonRow, SqliteJsonValue, SqlSyncRules } from '@powersync/service-sync-rules';
25
26
  import * as bson from 'bson';
26
27
  import { wrapWithAbort } from 'ix/asynciterable/operators/withabort.js';
28
+ import { LRUCache } from 'lru-cache';
27
29
  import * as timers from 'timers/promises';
28
30
  import { MongoBucketStorage } from '../MongoBucketStorage.js';
29
31
  import { PowerSyncMongo } from './db.js';
30
32
  import {
31
33
  BucketDataDocument,
32
34
  BucketDataKey,
35
+ BucketStateDocument,
33
36
  SourceKey,
34
37
  SourceTableDocument,
35
38
  SyncRuleCheckpointState,
@@ -39,6 +42,7 @@ import { MongoBucketBatch } from './MongoBucketBatch.js';
39
42
  import { MongoCompactor } from './MongoCompactor.js';
40
43
  import { MongoWriteCheckpointAPI } from './MongoWriteCheckpointAPI.js';
41
44
  import { idPrefixFilter, mapOpEntry, readSingleBatch } from './util.js';
45
+ import { JSONBig } from '@powersync/service-jsonbig';
42
46
 
43
47
  export class MongoSyncBucketStorage
44
48
  extends BaseObserver<storage.SyncRulesBucketStorageListener>
@@ -585,6 +589,13 @@ export class MongoSyncBucketStorage
585
589
  { maxTimeMS: lib_mongo.db.MONGO_CLEAR_OPERATION_TIMEOUT_MS }
586
590
  );
587
591
 
592
+ await this.db.bucket_state.deleteMany(
593
+ {
594
+ _id: idPrefixFilter<BucketStateDocument['_id']>({ g: this.group_id }, ['b'])
595
+ },
596
+ { maxTimeMS: lib_mongo.db.MONGO_CLEAR_OPERATION_TIMEOUT_MS }
597
+ );
598
+
588
599
  await this.db.source_tables.deleteMany(
589
600
  {
590
601
  group_id: this.group_id
@@ -795,12 +806,7 @@ export class MongoSyncBucketStorage
795
806
 
796
807
  const updates: CheckpointChanges =
797
808
  lastCheckpoint == null
798
- ? {
799
- invalidateDataBuckets: true,
800
- invalidateParameterBuckets: true,
801
- updatedDataBuckets: [],
802
- updatedParameterBucketDefinitions: []
803
- }
809
+ ? CHECKPOINT_INVALIDATE_ALL
804
810
  : await this.getCheckpointChanges({
805
811
  lastCheckpoint: lastCheckpoint,
806
812
  nextCheckpoint: checkpoint
@@ -869,7 +875,119 @@ export class MongoSyncBucketStorage
869
875
  return pipeline;
870
876
  }
871
877
 
878
+ private async getDataBucketChanges(
879
+ options: GetCheckpointChangesOptions
880
+ ): Promise<Pick<CheckpointChanges, 'updatedDataBuckets' | 'invalidateDataBuckets'>> {
881
+ const bucketStateUpdates = await this.db.bucket_state
882
+ .find(
883
+ {
884
+ // We have an index on (_id.g, last_op).
885
+ '_id.g': this.group_id,
886
+ last_op: { $gt: BigInt(options.lastCheckpoint) }
887
+ },
888
+ {
889
+ projection: {
890
+ '_id.b': 1
891
+ },
892
+ limit: 1001,
893
+ batchSize: 1001,
894
+ singleBatch: true
895
+ }
896
+ )
897
+ .toArray();
898
+
899
+ const buckets = bucketStateUpdates.map((doc) => doc._id.b);
900
+ const invalidateDataBuckets = buckets.length > 1000;
901
+
902
+ return {
903
+ invalidateDataBuckets: invalidateDataBuckets,
904
+ updatedDataBuckets: invalidateDataBuckets ? [] : buckets
905
+ };
906
+ }
907
+
908
+ private async getParameterBucketChanges(
909
+ options: GetCheckpointChangesOptions
910
+ ): Promise<Pick<CheckpointChanges, 'updatedParameterLookups' | 'invalidateParameterBuckets'>> {
911
+ // TODO: limit max query running time
912
+ const parameterUpdates = await this.db.bucket_parameters
913
+ .find(
914
+ {
915
+ _id: { $gt: BigInt(options.lastCheckpoint), $lt: BigInt(options.nextCheckpoint) },
916
+ 'key.g': this.group_id
917
+ },
918
+ {
919
+ projection: {
920
+ lookup: 1
921
+ },
922
+ limit: 1001,
923
+ batchSize: 1001,
924
+ singleBatch: true
925
+ }
926
+ )
927
+ .toArray();
928
+ const invalidateParameterUpdates = parameterUpdates.length > 1000;
929
+
930
+ return {
931
+ invalidateParameterBuckets: invalidateParameterUpdates,
932
+ updatedParameterLookups: invalidateParameterUpdates
933
+ ? new Set<string>()
934
+ : new Set<string>(parameterUpdates.map((p) => JSONBig.stringify(deserializeParameterLookup(p.lookup))))
935
+ };
936
+ }
937
+
938
+ // TODO:
939
+ // We can optimize this by implementing it like ChecksumCache: We can use partial cache results to do
940
+ // more efficient lookups in some cases.
941
+ private checkpointChangesCache = new LRUCache<string, CheckpointChanges, { options: GetCheckpointChangesOptions }>({
942
+ max: 50,
943
+ maxSize: 10 * 1024 * 1024,
944
+ sizeCalculation: (value: CheckpointChanges) => {
945
+ const paramSize = [...value.updatedParameterLookups].reduce<number>((a, b) => a + b.length, 0);
946
+ const bucketSize = [...value.updatedDataBuckets].reduce<number>((a, b) => a + b.length, 0);
947
+ return 100 + paramSize + bucketSize;
948
+ },
949
+ fetchMethod: async (_key, _staleValue, options) => {
950
+ return this.getCheckpointChangesInternal(options.context.options);
951
+ }
952
+ });
953
+
954
+ private _hasDynamicBucketsCached: boolean | undefined = undefined;
955
+
956
+ private hasDynamicBucketQueries(): boolean {
957
+ if (this._hasDynamicBucketsCached != null) {
958
+ return this._hasDynamicBucketsCached;
959
+ }
960
+ const syncRules = this.getParsedSyncRules({
961
+ defaultSchema: 'default' // n/a
962
+ });
963
+ const hasDynamicBuckets = syncRules.hasDynamicBucketQueries();
964
+ this._hasDynamicBucketsCached = hasDynamicBuckets;
965
+ return hasDynamicBuckets;
966
+ }
967
+
872
968
  async getCheckpointChanges(options: GetCheckpointChangesOptions): Promise<CheckpointChanges> {
873
- return CHECKPOINT_INVALIDATE_ALL;
969
+ if (!this.hasDynamicBucketQueries()) {
970
+ // Special case when we have no dynamic parameter queries.
971
+ // In this case, we can avoid doing any queries.
972
+ return {
973
+ invalidateDataBuckets: true,
974
+ updatedDataBuckets: [],
975
+ invalidateParameterBuckets: false,
976
+ updatedParameterLookups: new Set<string>()
977
+ };
978
+ }
979
+ const key = `${options.lastCheckpoint}_${options.nextCheckpoint}`;
980
+ const result = await this.checkpointChangesCache.fetch(key, { context: { options } });
981
+ return result!;
982
+ }
983
+
984
+ private async getCheckpointChangesInternal(options: GetCheckpointChangesOptions): Promise<CheckpointChanges> {
985
+ const dataUpdates = await this.getDataBucketChanges(options);
986
+ const parameterUpdates = await this.getParameterBucketChanges(options);
987
+
988
+ return {
989
+ ...dataUpdates,
990
+ ...parameterUpdates
991
+ };
874
992
  }
875
993
  }
@@ -11,6 +11,7 @@ import { PowerSyncMongo } from './db.js';
11
11
  import {
12
12
  BucketDataDocument,
13
13
  BucketParameterDocument,
14
+ BucketStateDocument,
14
15
  CurrentBucket,
15
16
  CurrentDataDocument,
16
17
  SourceKey
@@ -48,6 +49,7 @@ export class PersistedBatch {
48
49
  bucketData: mongo.AnyBulkWriteOperation<BucketDataDocument>[] = [];
49
50
  bucketParameters: mongo.AnyBulkWriteOperation<BucketParameterDocument>[] = [];
50
51
  currentData: mongo.AnyBulkWriteOperation<CurrentDataDocument>[] = [];
52
+ bucketStates: Map<string, BucketStateUpdate> = new Map();
51
53
 
52
54
  /**
53
55
  * For debug logging only.
@@ -66,6 +68,19 @@ export class PersistedBatch {
66
68
  this.currentSize = writtenSize;
67
69
  }
68
70
 
71
+ private incrementBucket(bucket: string, op_id: InternalOpId) {
72
+ let existingState = this.bucketStates.get(bucket);
73
+ if (existingState) {
74
+ existingState.lastOp = op_id;
75
+ existingState.incrementCount += 1;
76
+ } else {
77
+ this.bucketStates.set(bucket, {
78
+ lastOp: op_id,
79
+ incrementCount: 1
80
+ });
81
+ }
82
+ }
83
+
69
84
  saveBucketData(options: {
70
85
  op_seq: MongoIdSequence;
71
86
  sourceKey: storage.ReplicaId;
@@ -120,6 +135,7 @@ export class PersistedBatch {
120
135
  }
121
136
  }
122
137
  });
138
+ this.incrementBucket(k.bucket, op_id);
123
139
  }
124
140
 
125
141
  for (let bd of remaining_buckets.values()) {
@@ -147,6 +163,7 @@ export class PersistedBatch {
147
163
  }
148
164
  });
149
165
  this.currentSize += 200;
166
+ this.incrementBucket(bd.bucket, op_id);
150
167
  }
151
168
  }
152
169
 
@@ -277,6 +294,14 @@ export class PersistedBatch {
277
294
  });
278
295
  }
279
296
 
297
+ if (this.bucketStates.size > 0) {
298
+ await db.bucket_state.bulkWrite(this.getBucketStateUpdates(), {
299
+ session,
300
+ // Per-bucket operation - order doesn't matter
301
+ ordered: false
302
+ });
303
+ }
304
+
280
305
  const duration = performance.now() - startAt;
281
306
  logger.info(
282
307
  `powersync_${this.group_id} Flushed ${this.bucketData.length} + ${this.bucketParameters.length} + ${
@@ -287,7 +312,37 @@ export class PersistedBatch {
287
312
  this.bucketData = [];
288
313
  this.bucketParameters = [];
289
314
  this.currentData = [];
315
+ this.bucketStates.clear();
290
316
  this.currentSize = 0;
291
317
  this.debugLastOpId = null;
292
318
  }
319
+
320
+ private getBucketStateUpdates(): mongo.AnyBulkWriteOperation<BucketStateDocument>[] {
321
+ return Array.from(this.bucketStates.entries()).map(([bucket, state]) => {
322
+ return {
323
+ updateOne: {
324
+ filter: {
325
+ _id: {
326
+ g: this.group_id,
327
+ b: bucket
328
+ }
329
+ },
330
+ update: {
331
+ $set: {
332
+ last_op: state.lastOp
333
+ },
334
+ $inc: {
335
+ op_count: state.incrementCount
336
+ }
337
+ },
338
+ upsert: true
339
+ }
340
+ } satisfies mongo.AnyBulkWriteOperation<BucketStateDocument>;
341
+ });
342
+ }
343
+ }
344
+
345
+ interface BucketStateUpdate {
346
+ lastOp: InternalOpId;
347
+ incrementCount: number;
293
348
  }
@@ -6,6 +6,7 @@ import { MongoStorageConfig } from '../../types/types.js';
6
6
  import {
7
7
  BucketDataDocument,
8
8
  BucketParameterDocument,
9
+ BucketStateDocument,
9
10
  CurrentDataDocument,
10
11
  CustomWriteCheckpointDocument,
11
12
  IdSequenceDocument,
@@ -33,6 +34,7 @@ export class PowerSyncMongo {
33
34
  readonly write_checkpoints: mongo.Collection<WriteCheckpointDocument>;
34
35
  readonly instance: mongo.Collection<InstanceDocument>;
35
36
  readonly locks: mongo.Collection<lib_mongo.locks.Lock>;
37
+ readonly bucket_state: mongo.Collection<BucketStateDocument>;
36
38
 
37
39
  readonly client: mongo.MongoClient;
38
40
  readonly db: mongo.Db;
@@ -55,6 +57,7 @@ export class PowerSyncMongo {
55
57
  this.write_checkpoints = db.collection('write_checkpoints');
56
58
  this.instance = db.collection('instance');
57
59
  this.locks = this.db.collection('locks');
60
+ this.bucket_state = this.db.collection('bucket_state');
58
61
  }
59
62
 
60
63
  /**
@@ -70,6 +73,7 @@ export class PowerSyncMongo {
70
73
  await this.write_checkpoints.deleteMany({});
71
74
  await this.instance.deleteOne({});
72
75
  await this.locks.deleteMany({});
76
+ await this.bucket_state.deleteMany({});
73
77
  }
74
78
 
75
79
  /**
@@ -75,6 +75,15 @@ export interface SourceTableDocument {
75
75
  snapshot_done: boolean | undefined;
76
76
  }
77
77
 
78
+ export interface BucketStateDocument {
79
+ _id: {
80
+ g: number;
81
+ b: string;
82
+ };
83
+ last_op: bigint;
84
+ op_count: number;
85
+ }
86
+
78
87
  export interface IdSequenceDocument {
79
88
  _id: string;
80
89
  op_id: bigint;
package/test/src/setup.ts CHANGED
@@ -1,12 +1,13 @@
1
1
  import { container } from '@powersync/lib-services-framework';
2
+ import { test_utils } from '@powersync/service-core-tests';
2
3
  import { beforeAll, beforeEach } from 'vitest';
3
- import { METRICS_HELPER } from '@powersync/service-core-tests';
4
4
 
5
5
  beforeAll(async () => {
6
6
  // Executes for every test file
7
7
  container.registerDefaults();
8
+ await test_utils.initMetrics();
8
9
  });
9
10
 
10
11
  beforeEach(async () => {
11
- METRICS_HELPER.resetMetrics();
12
+ await test_utils.resetMetrics();
12
13
  });