@powersync/service-module-mongodb-storage 0.0.0-dev-20251111070830 → 0.0.0-dev-20251120150014

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.
@@ -61,6 +61,7 @@ export interface MongoCompactOptions extends storage.CompactOptions {}
61
61
  const DEFAULT_CLEAR_BATCH_LIMIT = 5000;
62
62
  const DEFAULT_MOVE_BATCH_LIMIT = 2000;
63
63
  const DEFAULT_MOVE_BATCH_QUERY_LIMIT = 10_000;
64
+ const DEFAULT_MIN_BUCKET_CHANGES = 10;
64
65
 
65
66
  /** This default is primarily for tests. */
66
67
  const DEFAULT_MEMORY_LIMIT_MB = 64;
@@ -73,6 +74,7 @@ export class MongoCompactor {
73
74
  private moveBatchLimit: number;
74
75
  private moveBatchQueryLimit: number;
75
76
  private clearBatchLimit: number;
77
+ private minBucketChanges: number;
76
78
  private maxOpId: bigint;
77
79
  private buckets: string[] | undefined;
78
80
  private signal?: AbortSignal;
@@ -88,6 +90,7 @@ export class MongoCompactor {
88
90
  this.moveBatchLimit = options?.moveBatchLimit ?? DEFAULT_MOVE_BATCH_LIMIT;
89
91
  this.moveBatchQueryLimit = options?.moveBatchQueryLimit ?? DEFAULT_MOVE_BATCH_QUERY_LIMIT;
90
92
  this.clearBatchLimit = options?.clearBatchLimit ?? DEFAULT_CLEAR_BATCH_LIMIT;
93
+ this.minBucketChanges = options?.minBucketChanges ?? DEFAULT_MIN_BUCKET_CHANGES;
91
94
  this.maxOpId = options?.maxOpId ?? 0n;
92
95
  this.buckets = options?.compactBuckets;
93
96
  this.signal = options?.signal;
@@ -113,14 +116,26 @@ export class MongoCompactor {
113
116
 
114
117
  private async compactDirtyBuckets() {
115
118
  while (!this.signal?.aborted) {
116
- // Process all buckets with 1 or more changes since last time
117
- const buckets = await this.dirtyBucketBatch({ minBucketChanges: 1 });
119
+ // Process all buckets with 10 or more changes since last time.
120
+ // We exclude the last 100 compacted buckets, to avoid repeatedly re-compacting the same buckets over and over
121
+ // if they are modified while compacting.
122
+ const TRACK_RECENTLY_COMPACTED_NUMBER = 100;
123
+
124
+ let recentlyCompacted: string[] = [];
125
+ const buckets = await this.dirtyBucketBatch({
126
+ minBucketChanges: this.minBucketChanges,
127
+ exclude: recentlyCompacted
128
+ });
118
129
  if (buckets.length == 0) {
119
130
  // All done
120
131
  break;
121
132
  }
122
- for (let bucket of buckets) {
133
+ for (let { bucket } of buckets) {
123
134
  await this.compactSingleBucket(bucket);
135
+ recentlyCompacted.push(bucket);
136
+ }
137
+ if (recentlyCompacted.length > TRACK_RECENTLY_COMPACTED_NUMBER) {
138
+ recentlyCompacted = recentlyCompacted.slice(-TRACK_RECENTLY_COMPACTED_NUMBER);
124
139
  }
125
140
  }
126
141
  }
@@ -482,10 +497,20 @@ export class MongoCompactor {
482
497
  break;
483
498
  }
484
499
  const start = Date.now();
485
- logger.info(`Calculating checksums for batch of ${buckets.length} buckets, starting at ${buckets[0]}`);
500
+ logger.info(`Calculating checksums for batch of ${buckets.length} buckets`);
486
501
 
487
- await this.updateChecksumsBatch(buckets);
488
- logger.info(`Updated checksums for batch of ${buckets.length} buckets in ${Date.now() - start}ms`);
502
+ // Filter batch by estimated bucket size, to reduce possibility of timeouts
503
+ let checkBuckets: typeof buckets = [];
504
+ let totalCountEstimate = 0;
505
+ for (let bucket of buckets) {
506
+ checkBuckets.push(bucket);
507
+ totalCountEstimate += bucket.estimatedCount;
508
+ if (totalCountEstimate > 50_000) {
509
+ break;
510
+ }
511
+ }
512
+ await this.updateChecksumsBatch(checkBuckets.map((b) => b.bucket));
513
+ logger.info(`Updated checksums for batch of ${checkBuckets.length} buckets in ${Date.now() - start}ms`);
489
514
  count += buckets.length;
490
515
  }
491
516
  return { buckets: count };
@@ -497,7 +522,10 @@ export class MongoCompactor {
497
522
  * This cannot be used to iterate on its own - the client is expected to process these buckets and
498
523
  * set estimate_since_compact.count: 0 when done, before fetching the next batch.
499
524
  */
500
- private async dirtyBucketBatch(options: { minBucketChanges: number }): Promise<string[]> {
525
+ private async dirtyBucketBatch(options: {
526
+ minBucketChanges: number;
527
+ exclude?: string[];
528
+ }): Promise<{ bucket: string; estimatedCount: number }[]> {
501
529
  if (options.minBucketChanges <= 0) {
502
530
  throw new ReplicationAssertionError('minBucketChanges must be >= 1');
503
531
  }
@@ -506,22 +534,28 @@ export class MongoCompactor {
506
534
  .find(
507
535
  {
508
536
  '_id.g': this.group_id,
509
- 'estimate_since_compact.count': { $gte: options.minBucketChanges }
537
+ 'estimate_since_compact.count': { $gte: options.minBucketChanges },
538
+ '_id.b': { $nin: options.exclude ?? [] }
510
539
  },
511
540
  {
512
541
  projection: {
513
- _id: 1
542
+ _id: 1,
543
+ estimate_since_compact: 1,
544
+ compacted_state: 1
514
545
  },
515
546
  sort: {
516
547
  'estimate_since_compact.count': -1
517
548
  },
518
- limit: 5_000,
549
+ limit: 200,
519
550
  maxTimeMS: MONGO_OPERATION_TIMEOUT_MS
520
551
  }
521
552
  )
522
553
  .toArray();
523
554
 
524
- return dirtyBuckets.map((bucket) => bucket._id.b);
555
+ return dirtyBuckets.map((bucket) => ({
556
+ bucket: bucket._id.b,
557
+ estimatedCount: bucket.estimate_since_compact!.count + (bucket.compacted_state?.count ?? 0)
558
+ }));
525
559
  }
526
560
 
527
561
  private async updateChecksumsBatch(buckets: string[]) {
@@ -33,10 +33,19 @@ export class MongoSyncRulesLock implements storage.ReplicationLock {
33
33
  );
34
34
 
35
35
  if (doc == null) {
36
- throw new ServiceError(
37
- ErrorCode.PSYNC_S1003,
38
- `Sync rules: ${sync_rules.id} have been locked by another process for replication.`
39
- );
36
+ // Query the existing lock to get the expiration time (best effort - it may have been released in the meantime).
37
+ const heldLock = await db.sync_rules.findOne({ _id: sync_rules.id }, { projection: { lock: 1 } });
38
+ if (heldLock?.lock?.expires_at) {
39
+ throw new ServiceError(
40
+ ErrorCode.PSYNC_S1003,
41
+ `Sync rules: ${sync_rules.id} have been locked by another process for replication, expiring at ${heldLock.lock.expires_at.toISOString()}.`
42
+ );
43
+ } else {
44
+ throw new ServiceError(
45
+ ErrorCode.PSYNC_S1003,
46
+ `Sync rules: ${sync_rules.id} have been locked by another process for replication.`
47
+ );
48
+ }
40
49
  }
41
50
  return new MongoSyncRulesLock(db, sync_rules.id, lockId);
42
51
  }
@@ -197,6 +197,11 @@ export interface SyncRuleDocument {
197
197
  last_fatal_error: string | null;
198
198
 
199
199
  content: string;
200
+
201
+ lock?: {
202
+ id: string;
203
+ expires_at: Date;
204
+ } | null;
200
205
  }
201
206
 
202
207
  export interface CheckpointEventDocument {
package/src/utils/util.ts CHANGED
@@ -125,24 +125,15 @@ export const createPaginatedConnectionQuery = async <T extends mongo.Document>(
125
125
  if (!cursor) {
126
126
  return query;
127
127
  }
128
+ const connected_at = query.connected_at
129
+ ? { $lt: new Date(cursor), $gte: query.connected_at.$gte }
130
+ : { $lt: new Date(cursor) };
128
131
  return {
129
- $and: [
130
- query,
131
- {
132
- /** We are using the connected at date as the cursor so that the functionality works the same on Postgres implementation
133
- * The id field in postgres is an uuid, this will work similarly to the ObjectId in Mongodb
134
- * */
135
- connected_at: {
136
- $lt: new Date(cursor)
137
- }
138
- }
139
- ]
132
+ ...query,
133
+ connected_at
140
134
  } as mongo.Filter<T>;
141
135
  };
142
136
 
143
- /** cursor.count() deprecated */
144
- const total = await collection.countDocuments(query);
145
-
146
137
  const findCursor = collection.find(createQuery(cursor), {
147
138
  sort: {
148
139
  /** We are sorting by connected at date descending to match cursor Postgres implementation */
@@ -152,12 +143,14 @@ export const createPaginatedConnectionQuery = async <T extends mongo.Document>(
152
143
 
153
144
  const items = await findCursor.limit(limit).toArray();
154
145
  const count = items.length;
146
+ /** The returned total has been defaulted to 0 due to the overhead using documentCount from the mogo driver.
147
+ * cursor.count has been deprecated.
148
+ * */
155
149
  return {
156
150
  items,
157
- total,
158
151
  count,
159
152
  /** Setting the cursor to the connected at date of the last item in the list */
160
153
  cursor: count === limit ? items[items.length - 1].connected_at.toISOString() : undefined,
161
- more: count < total
154
+ more: !(count !== limit)
162
155
  };
163
156
  };
@@ -227,7 +227,6 @@ exports[`Report storage tests > Should show paginated response of all connection
227
227
  },
228
228
  ],
229
229
  "more": false,
230
- "total": 1,
231
230
  }
232
231
  `;
233
232
 
@@ -262,7 +261,6 @@ exports[`Report storage tests > Should show paginated response of all connection
262
261
  },
263
262
  ],
264
263
  "more": true,
265
- "total": 8,
266
264
  }
267
265
  `;
268
266
 
@@ -271,18 +269,74 @@ exports[`Report storage tests > Should show paginated response of all connection
271
269
  "count": 4,
272
270
  "cursor": undefined,
273
271
  "items": [
272
+ {
273
+ "client_id": "client_three",
274
+ "sdk": "powersync-js/1.21.2",
275
+ "user_agent": "powersync-js/1.21.0 powersync-web Firefox/141 linux",
276
+ "user_id": "user_three",
277
+ },
274
278
  {
275
279
  "client_id": "client_one",
276
- "sdk": "powersync-dart/1.6.4",
277
- "user_agent": "powersync-dart/1.6.4 Dart (flutter-web) Chrome/128 android",
280
+ "sdk": "powersync-js/1.24.5",
281
+ "user_agent": "powersync-js/1.21.0 powersync-web Firefox/141 linux",
282
+ "user_id": "user_week",
283
+ },
284
+ {
285
+ "client_id": "client_month",
286
+ "sdk": "powersync-js/1.23.6",
287
+ "user_agent": "powersync-js/1.23.0 powersync-web Firefox/141 linux",
288
+ "user_id": "user_month",
289
+ },
290
+ {
291
+ "client_id": "client_expired",
292
+ "sdk": "powersync-js/1.23.7",
293
+ "user_agent": "powersync-js/1.23.0 powersync-web Firefox/141 linux",
294
+ "user_id": "user_expired",
295
+ },
296
+ ],
297
+ "more": false,
298
+ }
299
+ `;
300
+
301
+ exports[`Report storage tests > Should show paginated response of all connections with a limit with date range 1`] = `
302
+ {
303
+ "count": 4,
304
+ "cursor": "<removed-for-snapshot>",
305
+ "items": [
306
+ {
307
+ "client_id": "",
308
+ "sdk": "unknown",
309
+ "user_agent": "Dart (flutter-web) Chrome/128 android",
278
310
  "user_id": "user_one",
279
311
  },
280
312
  {
281
- "client_id": "client_four",
282
- "sdk": "powersync-js/1.21.4",
313
+ "client_id": "client_two",
314
+ "sdk": "powersync-js/1.21.1",
315
+ "user_agent": "powersync-js/1.21.0 powersync-web Chromium/138 linux",
316
+ "user_id": "user_two",
317
+ },
318
+ {
319
+ "client_id": "client_three",
320
+ "sdk": "powersync-js/1.21.2",
283
321
  "user_agent": "powersync-js/1.21.0 powersync-web Firefox/141 linux",
284
- "user_id": "user_four",
322
+ "user_id": "user_three",
285
323
  },
324
+ {
325
+ "client_id": "client_one",
326
+ "sdk": "powersync-js/1.24.5",
327
+ "user_agent": "powersync-js/1.21.0 powersync-web Firefox/141 linux",
328
+ "user_id": "user_week",
329
+ },
330
+ ],
331
+ "more": true,
332
+ }
333
+ `;
334
+
335
+ exports[`Report storage tests > Should show paginated response of all connections with a limit with date range 2`] = `
336
+ {
337
+ "count": 2,
338
+ "cursor": undefined,
339
+ "items": [
286
340
  {
287
341
  "client_id": "",
288
342
  "sdk": "unknown",
@@ -295,9 +349,20 @@ exports[`Report storage tests > Should show paginated response of all connection
295
349
  "user_agent": "powersync-js/1.21.0 powersync-web Chromium/138 linux",
296
350
  "user_id": "user_two",
297
351
  },
352
+ {
353
+ "client_id": "client_three",
354
+ "sdk": "powersync-js/1.21.2",
355
+ "user_agent": "powersync-js/1.21.0 powersync-web Firefox/141 linux",
356
+ "user_id": "user_three",
357
+ },
358
+ {
359
+ "client_id": "client_one",
360
+ "sdk": "powersync-js/1.24.5",
361
+ "user_agent": "powersync-js/1.21.0 powersync-web Firefox/141 linux",
362
+ "user_id": "user_week",
363
+ },
298
364
  ],
299
- "more": true,
300
- "total": 8,
365
+ "more": false,
301
366
  }
302
367
  `;
303
368
 
@@ -320,7 +385,6 @@ exports[`Report storage tests > Should show paginated response of connections of
320
385
  },
321
386
  ],
322
387
  "more": false,
323
- "total": 2,
324
388
  }
325
389
  `;
326
390
 
@@ -367,6 +431,21 @@ exports[`Report storage tests > Should show paginated response of connections ov
367
431
  },
368
432
  ],
369
433
  "more": false,
370
- "total": 6,
434
+ }
435
+ `;
436
+
437
+ exports[`Report storage tests > Should show paginated response of connections over a date range of specified client_id and user_id 1`] = `
438
+ {
439
+ "count": 1,
440
+ "cursor": undefined,
441
+ "items": [
442
+ {
443
+ "client_id": "client_one",
444
+ "sdk": "powersync-dart/1.6.4",
445
+ "user_agent": "powersync-dart/1.6.4 Dart (flutter-web) Chrome/128 android",
446
+ "user_id": "user_one",
447
+ },
448
+ ],
449
+ "more": false,
371
450
  }
372
451
  `;
@@ -62,6 +62,7 @@ bucket_definitions:
62
62
  clearBatchLimit: 200,
63
63
  moveBatchLimit: 10,
64
64
  moveBatchQueryLimit: 10,
65
+ minBucketChanges: 1,
65
66
  maxOpId: checkpoint,
66
67
  signal: null as any
67
68
  });