@powersync/service-core 0.10.1 → 0.11.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.
@@ -211,6 +211,13 @@ export interface StartBatchOptions extends ParseSyncRulesOptions {
211
211
  * database, for example from MongoDB.
212
212
  */
213
213
  storeCurrentData: boolean;
214
+
215
+ /**
216
+ * Set to true for initial replication.
217
+ *
218
+ * This will avoid creating new operations for rows previously replicated.
219
+ */
220
+ skipExistingRows?: boolean;
214
221
  }
215
222
 
216
223
  export interface SyncRulesBucketStorageListener extends DisposableListener {
@@ -199,6 +199,7 @@ export class MongoBucketStorage
199
199
  last_checkpoint: null,
200
200
  last_checkpoint_lsn: null,
201
201
  no_checkpoint_before: null,
202
+ keepalive_op: null,
202
203
  snapshot_done: false,
203
204
  state: SyncRuleState.PROCESSING,
204
205
  slot_name: slot_name,
@@ -9,6 +9,7 @@ import {
9
9
  BucketStorageBatch,
10
10
  FlushedResult,
11
11
  mergeToast,
12
+ SaveOperationTag,
12
13
  SaveOptions
13
14
  } from '../BucketStorage.js';
14
15
  import { SourceTable } from '../SourceTable.js';
@@ -20,6 +21,7 @@ import { batchCreateCustomWriteCheckpoints } from './MongoWriteCheckpointAPI.js'
20
21
  import { cacheKey, OperationBatch, RecordOperation } from './OperationBatch.js';
21
22
  import { PersistedBatch } from './PersistedBatch.js';
22
23
  import { BSON_DESERIALIZE_OPTIONS, idPrefixFilter, replicaIdEquals, serializeLookup } from './util.js';
24
+ import * as timers from 'node:timers/promises';
23
25
 
24
26
  /**
25
27
  * 15MB
@@ -39,8 +41,13 @@ export interface MongoBucketBatchOptions {
39
41
  groupId: number;
40
42
  slotName: string;
41
43
  lastCheckpointLsn: string | null;
44
+ keepaliveOp: string | null;
42
45
  noCheckpointBeforeLsn: string;
43
46
  storeCurrentData: boolean;
47
+ /**
48
+ * Set to true for initial replication.
49
+ */
50
+ skipExistingRows: boolean;
44
51
  }
45
52
 
46
53
  export class MongoBucketBatch extends DisposableObserver<BucketBatchStorageListener> implements BucketStorageBatch {
@@ -53,6 +60,7 @@ export class MongoBucketBatch extends DisposableObserver<BucketBatchStorageListe
53
60
 
54
61
  private readonly slot_name: string;
55
62
  private readonly storeCurrentData: boolean;
63
+ private readonly skipExistingRows: boolean;
56
64
 
57
65
  private batch: OperationBatch | null = null;
58
66
  private write_checkpoint_batch: CustomWriteCheckpointOptions[] = [];
@@ -86,7 +94,12 @@ export class MongoBucketBatch extends DisposableObserver<BucketBatchStorageListe
86
94
  this.slot_name = options.slotName;
87
95
  this.sync_rules = options.syncRules;
88
96
  this.storeCurrentData = options.storeCurrentData;
97
+ this.skipExistingRows = options.skipExistingRows;
89
98
  this.batch = new OperationBatch();
99
+
100
+ if (options.keepaliveOp) {
101
+ this.persisted_op = BigInt(options.keepaliveOp);
102
+ }
90
103
  }
91
104
 
92
105
  addCustomWriteCheckpoint(checkpoint: BatchedCustomWriteCheckpointOptions): void {
@@ -148,10 +161,13 @@ export class MongoBucketBatch extends DisposableObserver<BucketBatchStorageListe
148
161
  op_seq: MongoIdSequence
149
162
  ): Promise<OperationBatch | null> {
150
163
  let sizes: Map<string, number> | undefined = undefined;
151
- if (this.storeCurrentData) {
164
+ if (this.storeCurrentData && !this.skipExistingRows) {
152
165
  // We skip this step if we don't store current_data, since the sizes will
153
166
  // always be small in that case.
154
167
 
168
+ // With skipExistingRows, we don't load the full documents into memory,
169
+ // so we can also skip the size lookup step.
170
+
155
171
  // Find sizes of current_data documents, to assist in intelligent batching without
156
172
  // exceeding memory limits.
157
173
  //
@@ -204,11 +220,13 @@ export class MongoBucketBatch extends DisposableObserver<BucketBatchStorageListe
204
220
  return { g: this.group_id, t: r.record.sourceTable.id, k: r.beforeId };
205
221
  });
206
222
  let current_data_lookup = new Map<string, CurrentDataDocument>();
223
+ // With skipExistingRows, we only need to know whether or not the row exists.
224
+ const projection = this.skipExistingRows ? { _id: 1 } : undefined;
207
225
  const cursor = this.db.current_data.find(
208
226
  {
209
227
  _id: { $in: lookups }
210
228
  },
211
- { session }
229
+ { session, projection }
212
230
  );
213
231
  for await (let doc of cursor.stream()) {
214
232
  current_data_lookup.set(cacheKey(doc._id.t, doc._id.k), doc);
@@ -273,7 +291,21 @@ export class MongoBucketBatch extends DisposableObserver<BucketBatchStorageListe
273
291
 
274
292
  const before_key: SourceKey = { g: this.group_id, t: record.sourceTable.id, k: beforeId };
275
293
 
276
- if (record.tag == 'update') {
294
+ if (this.skipExistingRows) {
295
+ if (record.tag == SaveOperationTag.INSERT) {
296
+ if (current_data != null) {
297
+ // Initial replication, and we already have the record.
298
+ // This may be a different version of the record, but streaming replication
299
+ // will take care of that.
300
+ // Skip the insert here.
301
+ return null;
302
+ }
303
+ } else {
304
+ throw new Error(`${record.tag} not supported with skipExistingRows: true`);
305
+ }
306
+ }
307
+
308
+ if (record.tag == SaveOperationTag.UPDATE) {
277
309
  const result = current_data;
278
310
  if (result == null) {
279
311
  // Not an error if we re-apply a transaction
@@ -293,7 +325,7 @@ export class MongoBucketBatch extends DisposableObserver<BucketBatchStorageListe
293
325
  after = mergeToast(after!, data);
294
326
  }
295
327
  }
296
- } else if (record.tag == 'delete') {
328
+ } else if (record.tag == SaveOperationTag.DELETE) {
297
329
  const result = current_data;
298
330
  if (result == null) {
299
331
  // Not an error if we re-apply a transaction
@@ -494,7 +526,7 @@ export class MongoBucketBatch extends DisposableObserver<BucketBatchStorageListe
494
526
  } else {
495
527
  logger.warn('Transaction error', e as Error);
496
528
  }
497
- await new Promise((resolve) => setTimeout(resolve, Math.random() * 50));
529
+ await timers.setTimeout(Math.random() * 50);
498
530
  throw e;
499
531
  }
500
532
  },
@@ -587,7 +619,28 @@ export class MongoBucketBatch extends DisposableObserver<BucketBatchStorageListe
587
619
  return false;
588
620
  }
589
621
  if (lsn < this.no_checkpoint_before_lsn) {
590
- logger.info(`Waiting until ${this.no_checkpoint_before_lsn} before creating checkpoint, currently at ${lsn}`);
622
+ logger.info(
623
+ `Waiting until ${this.no_checkpoint_before_lsn} before creating checkpoint, currently at ${lsn}. Persisted op: ${this.persisted_op}`
624
+ );
625
+
626
+ // Edge case: During initial replication, we have a no_checkpoint_before_lsn set,
627
+ // and don't actually commit the snapshot.
628
+ // The first commit can happen from an implicit keepalive message.
629
+ // That needs the persisted_op to get an accurate checkpoint, so
630
+ // we persist that in keepalive_op.
631
+
632
+ await this.db.sync_rules.updateOne(
633
+ {
634
+ _id: this.group_id
635
+ },
636
+ {
637
+ $set: {
638
+ keepalive_op: this.persisted_op == null ? null : String(this.persisted_op)
639
+ }
640
+ },
641
+ { session: this.session }
642
+ );
643
+
591
644
  return false;
592
645
  }
593
646
 
@@ -597,7 +650,8 @@ export class MongoBucketBatch extends DisposableObserver<BucketBatchStorageListe
597
650
  last_checkpoint_ts: now,
598
651
  last_keepalive_ts: now,
599
652
  snapshot_done: true,
600
- last_fatal_error: null
653
+ last_fatal_error: null,
654
+ keepalive_op: null
601
655
  };
602
656
 
603
657
  if (this.persisted_op != null) {
@@ -631,6 +685,7 @@ export class MongoBucketBatch extends DisposableObserver<BucketBatchStorageListe
631
685
  if (this.persisted_op != null) {
632
686
  // The commit may have been skipped due to "no_checkpoint_before_lsn".
633
687
  // Apply it now if relevant
688
+ logger.info(`Commit due to keepalive at ${lsn} / ${this.persisted_op}`);
634
689
  return await this.commit(lsn);
635
690
  }
636
691
 
@@ -684,9 +739,8 @@ export class MongoBucketBatch extends DisposableObserver<BucketBatchStorageListe
684
739
 
685
740
  if (this.batch.shouldFlush()) {
686
741
  const r = await this.flush();
687
- // HACK: Give other streams a chance to also flush
688
- const t = 150;
689
- await new Promise((resolve) => setTimeout(resolve, t));
742
+ // HACK: Give other streams a chance to also flush
743
+ await timers.setTimeout(5);
690
744
  return r;
691
745
  }
692
746
  return null;
@@ -137,7 +137,7 @@ export class MongoSyncBucketStorage
137
137
  {
138
138
  _id: this.group_id
139
139
  },
140
- { projection: { last_checkpoint_lsn: 1, no_checkpoint_before: 1 } }
140
+ { projection: { last_checkpoint_lsn: 1, no_checkpoint_before: 1, keepalive_op: 1 } }
141
141
  );
142
142
  const checkpoint_lsn = doc?.last_checkpoint_lsn ?? null;
143
143
 
@@ -148,7 +148,9 @@ export class MongoSyncBucketStorage
148
148
  slotName: this.slot_name,
149
149
  lastCheckpointLsn: checkpoint_lsn,
150
150
  noCheckpointBeforeLsn: doc?.no_checkpoint_before ?? options.zeroLSN,
151
- storeCurrentData: options.storeCurrentData
151
+ keepaliveOp: doc?.keepalive_op ?? null,
152
+ storeCurrentData: options.storeCurrentData,
153
+ skipExistingRows: options.skipExistingRows ?? false
152
154
  });
153
155
  this.iterateListeners((cb) => cb.batchStarted?.(batch));
154
156
 
@@ -135,6 +135,14 @@ export interface SyncRuleDocument {
135
135
  */
136
136
  no_checkpoint_before: string | null;
137
137
 
138
+ /**
139
+ * Goes together with no_checkpoint_before.
140
+ *
141
+ * If a keepalive is triggered that creates the checkpoint > no_checkpoint_before,
142
+ * then the checkpoint must be equal to this keepalive_op.
143
+ */
144
+ keepalive_op: string | null;
145
+
138
146
  slot_name: string | null;
139
147
 
140
148
  /**
package/src/util/utils.ts CHANGED
@@ -2,7 +2,7 @@ import * as sync_rules from '@powersync/service-sync-rules';
2
2
  import * as bson from 'bson';
3
3
  import crypto from 'crypto';
4
4
  import * as uuid from 'uuid';
5
- import { BucketChecksum, OpId } from './protocol-types.js';
5
+ import { BucketChecksum, OpId, OplogEntry } from './protocol-types.js';
6
6
 
7
7
  import * as storage from '../storage/storage-index.js';
8
8
 
@@ -144,3 +144,61 @@ export function checkpointUserId(user_id: string | undefined, client_id: string
144
144
  }
145
145
  return `${user_id}/${client_id}`;
146
146
  }
147
+
148
+ /**
149
+ * Reduce a bucket to the final state as stored on the client.
150
+ *
151
+ * This keeps the final state for each row as a PUT operation.
152
+ *
153
+ * All other operations are replaced with a single CLEAR operation,
154
+ * summing their checksums, and using a 0 as an op_id.
155
+ *
156
+ * This is the function $r(B)$, as described in /docs/bucket-properties.md.
157
+ *
158
+ * Used for tests.
159
+ */
160
+ export function reduceBucket(operations: OplogEntry[]) {
161
+ let rowState = new Map<string, OplogEntry>();
162
+ let otherChecksum = 0;
163
+
164
+ for (let op of operations) {
165
+ const key = rowKey(op);
166
+ if (op.op == 'PUT') {
167
+ const existing = rowState.get(key);
168
+ if (existing) {
169
+ otherChecksum = addChecksums(otherChecksum, existing.checksum as number);
170
+ }
171
+ rowState.set(key, op);
172
+ } else if (op.op == 'REMOVE') {
173
+ const existing = rowState.get(key);
174
+ if (existing) {
175
+ otherChecksum = addChecksums(otherChecksum, existing.checksum as number);
176
+ }
177
+ rowState.delete(key);
178
+ otherChecksum = addChecksums(otherChecksum, op.checksum as number);
179
+ } else if (op.op == 'CLEAR') {
180
+ rowState.clear();
181
+ otherChecksum = op.checksum as number;
182
+ } else if (op.op == 'MOVE') {
183
+ otherChecksum = addChecksums(otherChecksum, op.checksum as number);
184
+ } else {
185
+ throw new Error(`Unknown operation ${op.op}`);
186
+ }
187
+ }
188
+
189
+ const puts = [...rowState.values()].sort((a, b) => {
190
+ return Number(BigInt(a.op_id) - BigInt(b.op_id));
191
+ });
192
+
193
+ let finalState: OplogEntry[] = [
194
+ // Special operation to indiciate the checksum remainder
195
+ { op_id: '0', op: 'CLEAR', checksum: otherChecksum },
196
+ ...puts
197
+ ];
198
+
199
+ return finalState;
200
+ }
201
+
202
+ function rowKey(entry: OplogEntry) {
203
+ return `${entry.object_type}/${entry.object_id}/${entry.subkey}`;
204
+ }
@@ -1,6 +1,7 @@
1
1
  import { OplogEntry } from '@/util/protocol-types.js';
2
2
  import { describe, expect, test } from 'vitest';
3
- import { reduceBucket, validateBucket } from './bucket_validation.js';
3
+ import { validateBucket } from './bucket_validation.js';
4
+ import { reduceBucket } from '@/index.js';
4
5
 
5
6
  // This tests the reduceBucket function.
6
7
  // While this function is not used directly in the service implementation,
@@ -1,63 +1,7 @@
1
1
  import { OplogEntry } from '@/util/protocol-types.js';
2
- import { addChecksums } from '@/util/utils.js';
2
+ import { reduceBucket } from '@/util/utils.js';
3
3
  import { expect } from 'vitest';
4
4
 
5
- /**
6
- * Reduce a bucket to the final state as stored on the client.
7
- *
8
- * This keeps the final state for each row as a PUT operation.
9
- *
10
- * All other operations are replaced with a single CLEAR operation,
11
- * summing their checksums, and using a 0 as an op_id.
12
- *
13
- * This is the function $r(B)$, as described in /docs/bucket-properties.md.
14
- */
15
- export function reduceBucket(operations: OplogEntry[]) {
16
- let rowState = new Map<string, OplogEntry>();
17
- let otherChecksum = 0;
18
-
19
- for (let op of operations) {
20
- const key = rowKey(op);
21
- if (op.op == 'PUT') {
22
- const existing = rowState.get(key);
23
- if (existing) {
24
- otherChecksum = addChecksums(otherChecksum, existing.checksum as number);
25
- }
26
- rowState.set(key, op);
27
- } else if (op.op == 'REMOVE') {
28
- const existing = rowState.get(key);
29
- if (existing) {
30
- otherChecksum = addChecksums(otherChecksum, existing.checksum as number);
31
- }
32
- rowState.delete(key);
33
- otherChecksum = addChecksums(otherChecksum, op.checksum as number);
34
- } else if (op.op == 'CLEAR') {
35
- rowState.clear();
36
- otherChecksum = op.checksum as number;
37
- } else if (op.op == 'MOVE') {
38
- otherChecksum = addChecksums(otherChecksum, op.checksum as number);
39
- } else {
40
- throw new Error(`Unknown operation ${op.op}`);
41
- }
42
- }
43
-
44
- const puts = [...rowState.values()].sort((a, b) => {
45
- return Number(BigInt(a.op_id) - BigInt(b.op_id));
46
- });
47
-
48
- let finalState: OplogEntry[] = [
49
- // Special operation to indiciate the checksum remainder
50
- { op_id: '0', op: 'CLEAR', checksum: otherChecksum },
51
- ...puts
52
- ];
53
-
54
- return finalState;
55
- }
56
-
57
- function rowKey(entry: OplogEntry) {
58
- return `${entry.object_type}/${entry.object_id}/${entry.subkey}`;
59
- }
60
-
61
5
  /**
62
6
  * Validate this property, as described in /docs/bucket-properties.md:
63
7
  *
package/test/src/util.ts CHANGED
@@ -30,6 +30,8 @@ export interface StorageOptions {
30
30
  * Setting this to true will drop the collections completely.
31
31
  */
32
32
  dropAll?: boolean;
33
+
34
+ doNotClear?: boolean;
33
35
  }
34
36
  export type StorageFactory = (options?: StorageOptions) => Promise<BucketStorageFactory>;
35
37
 
@@ -37,7 +39,7 @@ export const MONGO_STORAGE_FACTORY: StorageFactory = async (options?: StorageOpt
37
39
  const db = await connectMongo();
38
40
  if (options?.dropAll) {
39
41
  await db.drop();
40
- } else {
42
+ } else if (!options?.doNotClear) {
41
43
  await db.clear();
42
44
  }
43
45
  return new MongoBucketStorage(db, { slot_name_prefix: 'test_' });