@powersync/service-module-mongodb-storage 0.9.5 → 0.10.1
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/CHANGELOG.md +31 -0
- package/dist/migrations/db/migrations/1749720702136-checkpoint-events.d.ts +3 -0
- package/dist/migrations/db/migrations/1749720702136-checkpoint-events.js +34 -0
- package/dist/migrations/db/migrations/1749720702136-checkpoint-events.js.map +1 -0
- package/dist/storage/MongoBucketStorage.js +5 -0
- package/dist/storage/MongoBucketStorage.js.map +1 -1
- package/dist/storage/implementation/MongoBucketBatch.d.ts +9 -3
- package/dist/storage/implementation/MongoBucketBatch.js +116 -36
- package/dist/storage/implementation/MongoBucketBatch.js.map +1 -1
- package/dist/storage/implementation/MongoCompactor.js +2 -2
- package/dist/storage/implementation/MongoCompactor.js.map +1 -1
- package/dist/storage/implementation/MongoPersistedSyncRulesContent.d.ts +1 -0
- package/dist/storage/implementation/MongoPersistedSyncRulesContent.js +2 -0
- package/dist/storage/implementation/MongoPersistedSyncRulesContent.js.map +1 -1
- package/dist/storage/implementation/MongoStorageProvider.js +23 -1
- package/dist/storage/implementation/MongoStorageProvider.js.map +1 -1
- package/dist/storage/implementation/MongoSyncBucketStorage.d.ts +14 -5
- package/dist/storage/implementation/MongoSyncBucketStorage.js +165 -160
- package/dist/storage/implementation/MongoSyncBucketStorage.js.map +1 -1
- package/dist/storage/implementation/MongoTestStorageFactoryGenerator.js +2 -0
- package/dist/storage/implementation/MongoTestStorageFactoryGenerator.js.map +1 -1
- package/dist/storage/implementation/MongoWriteCheckpointAPI.d.ts +9 -15
- package/dist/storage/implementation/MongoWriteCheckpointAPI.js +55 -191
- package/dist/storage/implementation/MongoWriteCheckpointAPI.js.map +1 -1
- package/dist/storage/implementation/PersistedBatch.d.ts +6 -2
- package/dist/storage/implementation/PersistedBatch.js +40 -8
- package/dist/storage/implementation/PersistedBatch.js.map +1 -1
- package/dist/storage/implementation/db.d.ts +12 -1
- package/dist/storage/implementation/db.js +39 -0
- package/dist/storage/implementation/db.js.map +1 -1
- package/dist/storage/implementation/models.d.ts +30 -2
- package/package.json +6 -6
- package/src/migrations/db/migrations/1749720702136-checkpoint-events.ts +50 -0
- package/src/storage/MongoBucketStorage.ts +5 -0
- package/src/storage/implementation/MongoBucketBatch.ts +159 -48
- package/src/storage/implementation/MongoCompactor.ts +2 -2
- package/src/storage/implementation/MongoPersistedSyncRulesContent.ts +2 -0
- package/src/storage/implementation/MongoStorageProvider.ts +27 -1
- package/src/storage/implementation/MongoSyncBucketStorage.ts +191 -201
- package/src/storage/implementation/MongoTestStorageFactoryGenerator.ts +3 -0
- package/src/storage/implementation/MongoWriteCheckpointAPI.ts +66 -255
- package/src/storage/implementation/PersistedBatch.ts +51 -12
- package/src/storage/implementation/db.ts +42 -0
- package/src/storage/implementation/models.ts +33 -2
- package/test/src/storage_sync.test.ts +7 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -1,14 +1,7 @@
|
|
|
1
1
|
import { mongo } from '@powersync/lib-service-mongodb';
|
|
2
2
|
import * as framework from '@powersync/lib-services-framework';
|
|
3
|
-
import {
|
|
4
|
-
Demultiplexer,
|
|
5
|
-
DemultiplexerValue,
|
|
6
|
-
storage,
|
|
7
|
-
WatchUserWriteCheckpointOptions,
|
|
8
|
-
WriteCheckpointResult
|
|
9
|
-
} from '@powersync/service-core';
|
|
3
|
+
import { GetCheckpointChangesOptions, InternalOpId, storage } from '@powersync/service-core';
|
|
10
4
|
import { PowerSyncMongo } from './db.js';
|
|
11
|
-
import { CustomWriteCheckpointDocument, WriteCheckpointDocument } from './models.js';
|
|
12
5
|
|
|
13
6
|
export type MongoCheckpointAPIOptions = {
|
|
14
7
|
db: PowerSyncMongo;
|
|
@@ -35,13 +28,9 @@ export class MongoWriteCheckpointAPI implements storage.WriteCheckpointAPI {
|
|
|
35
28
|
this._mode = mode;
|
|
36
29
|
}
|
|
37
30
|
|
|
38
|
-
async batchCreateCustomWriteCheckpoints(checkpoints: storage.CustomWriteCheckpointOptions[]): Promise<void> {
|
|
39
|
-
return batchCreateCustomWriteCheckpoints(this.db, checkpoints);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
31
|
async createManagedWriteCheckpoint(checkpoint: storage.ManagedWriteCheckpointOptions): Promise<bigint> {
|
|
43
32
|
if (this.writeCheckpointMode !== storage.WriteCheckpointMode.MANAGED) {
|
|
44
|
-
throw new framework.
|
|
33
|
+
throw new framework.ServiceAssertionError(
|
|
45
34
|
`Attempting to create a managed Write Checkpoint when the current Write Checkpoint mode is set to "${this.writeCheckpointMode}"`
|
|
46
35
|
);
|
|
47
36
|
}
|
|
@@ -53,7 +42,8 @@ export class MongoWriteCheckpointAPI implements storage.WriteCheckpointAPI {
|
|
|
53
42
|
},
|
|
54
43
|
{
|
|
55
44
|
$set: {
|
|
56
|
-
lsns
|
|
45
|
+
lsns,
|
|
46
|
+
processed_at_lsn: null
|
|
57
47
|
},
|
|
58
48
|
$inc: {
|
|
59
49
|
client_id: 1n
|
|
@@ -68,12 +58,12 @@ export class MongoWriteCheckpointAPI implements storage.WriteCheckpointAPI {
|
|
|
68
58
|
switch (this.writeCheckpointMode) {
|
|
69
59
|
case storage.WriteCheckpointMode.CUSTOM:
|
|
70
60
|
if (false == 'sync_rules_id' in filters) {
|
|
71
|
-
throw new framework.
|
|
61
|
+
throw new framework.ServiceAssertionError(`Sync rules ID is required for custom Write Checkpoint filtering`);
|
|
72
62
|
}
|
|
73
63
|
return this.lastCustomWriteCheckpoint(filters);
|
|
74
64
|
case storage.WriteCheckpointMode.MANAGED:
|
|
75
65
|
if (false == 'heads' in filters) {
|
|
76
|
-
throw new framework.
|
|
66
|
+
throw new framework.ServiceAssertionError(
|
|
77
67
|
`Replication HEAD is required for managed Write Checkpoint filtering`
|
|
78
68
|
);
|
|
79
69
|
}
|
|
@@ -81,231 +71,15 @@ export class MongoWriteCheckpointAPI implements storage.WriteCheckpointAPI {
|
|
|
81
71
|
}
|
|
82
72
|
}
|
|
83
73
|
|
|
84
|
-
|
|
74
|
+
async getWriteCheckpointChanges(options: GetCheckpointChangesOptions) {
|
|
85
75
|
switch (this.writeCheckpointMode) {
|
|
86
76
|
case storage.WriteCheckpointMode.CUSTOM:
|
|
87
|
-
return this.
|
|
77
|
+
return this.getCustomWriteCheckpointChanges(options);
|
|
88
78
|
case storage.WriteCheckpointMode.MANAGED:
|
|
89
|
-
return this.
|
|
90
|
-
default:
|
|
91
|
-
throw new Error('Invalid write checkpoint mode');
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
private sharedManagedIter = new Demultiplexer<WriteCheckpointResult>((signal) => {
|
|
96
|
-
const clusterTimePromise = this.getClusterTime();
|
|
97
|
-
|
|
98
|
-
return {
|
|
99
|
-
iterator: this.watchAllManagedWriteCheckpoints(clusterTimePromise, signal),
|
|
100
|
-
getFirstValue: async (user_id: string) => {
|
|
101
|
-
// Potential race conditions we cater for:
|
|
102
|
-
|
|
103
|
-
// Case 1: changestream is behind.
|
|
104
|
-
// We get a doc now, then the same or older doc again later.
|
|
105
|
-
// No problem!
|
|
106
|
-
|
|
107
|
-
// Case 2: Query is behind. I.e. doc has been created, and emitted on the changestream, but the query doesn't see it yet.
|
|
108
|
-
// Not possible luckily, but can we make sure?
|
|
109
|
-
|
|
110
|
-
// Case 3: changestream delays openeing. A doc is created after our query here, but before the changestream is opened.
|
|
111
|
-
// Awaiting clusterTimePromise should be sufficient here, but as a sanity check we also confirm that our query
|
|
112
|
-
// timestamp is > the startClusterTime.
|
|
113
|
-
|
|
114
|
-
const changeStreamStart = await clusterTimePromise;
|
|
115
|
-
|
|
116
|
-
let doc = null as WriteCheckpointDocument | null;
|
|
117
|
-
let clusterTime = null as mongo.Timestamp | null;
|
|
118
|
-
|
|
119
|
-
await this.db.client.withSession(async (session) => {
|
|
120
|
-
doc = await this.db.write_checkpoints.findOne(
|
|
121
|
-
{
|
|
122
|
-
user_id: user_id
|
|
123
|
-
},
|
|
124
|
-
{
|
|
125
|
-
session
|
|
126
|
-
}
|
|
127
|
-
);
|
|
128
|
-
const time = session.clusterTime?.clusterTime ?? null;
|
|
129
|
-
clusterTime = time;
|
|
130
|
-
});
|
|
131
|
-
if (clusterTime == null) {
|
|
132
|
-
throw new framework.ServiceAssertionError('Could not get clusterTime for write checkpoint');
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
if (clusterTime.lessThan(changeStreamStart)) {
|
|
136
|
-
throw new framework.ServiceAssertionError(
|
|
137
|
-
'clusterTime for write checkpoint is older than changestream start'
|
|
138
|
-
);
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
if (doc == null) {
|
|
142
|
-
return {
|
|
143
|
-
id: null,
|
|
144
|
-
lsn: null
|
|
145
|
-
};
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
return {
|
|
149
|
-
id: doc.client_id,
|
|
150
|
-
lsn: doc.lsns['1']
|
|
151
|
-
};
|
|
152
|
-
}
|
|
153
|
-
};
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
private async *watchAllManagedWriteCheckpoints(
|
|
157
|
-
clusterTimePromise: Promise<mongo.BSON.Timestamp>,
|
|
158
|
-
signal: AbortSignal
|
|
159
|
-
): AsyncGenerator<DemultiplexerValue<WriteCheckpointResult>> {
|
|
160
|
-
const clusterTime = await clusterTimePromise;
|
|
161
|
-
|
|
162
|
-
const stream = this.db.write_checkpoints.watch(
|
|
163
|
-
[{ $match: { operationType: { $in: ['insert', 'update', 'replace'] } } }],
|
|
164
|
-
{
|
|
165
|
-
fullDocument: 'updateLookup',
|
|
166
|
-
startAtOperationTime: clusterTime
|
|
167
|
-
}
|
|
168
|
-
);
|
|
169
|
-
|
|
170
|
-
signal.onabort = () => {
|
|
171
|
-
stream.close();
|
|
172
|
-
};
|
|
173
|
-
|
|
174
|
-
if (signal.aborted) {
|
|
175
|
-
stream.close();
|
|
176
|
-
return;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
for await (let event of stream) {
|
|
180
|
-
if (!('fullDocument' in event) || event.fullDocument == null) {
|
|
181
|
-
continue;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
const user_id = event.fullDocument.user_id;
|
|
185
|
-
yield {
|
|
186
|
-
key: user_id,
|
|
187
|
-
value: {
|
|
188
|
-
id: event.fullDocument.client_id,
|
|
189
|
-
lsn: event.fullDocument.lsns['1']
|
|
190
|
-
}
|
|
191
|
-
};
|
|
79
|
+
return this.getManagedWriteCheckpointChanges(options);
|
|
192
80
|
}
|
|
193
81
|
}
|
|
194
82
|
|
|
195
|
-
watchManagedWriteCheckpoint(options: WatchUserWriteCheckpointOptions): AsyncIterable<storage.WriteCheckpointResult> {
|
|
196
|
-
const stream = this.sharedManagedIter.subscribe(options.user_id, options.signal);
|
|
197
|
-
return this.orderedStream(stream);
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
private sharedCustomIter = new Demultiplexer<WriteCheckpointResult>((signal) => {
|
|
201
|
-
const clusterTimePromise = this.getClusterTime();
|
|
202
|
-
|
|
203
|
-
return {
|
|
204
|
-
iterator: this.watchAllCustomWriteCheckpoints(clusterTimePromise, signal),
|
|
205
|
-
getFirstValue: async (user_id: string) => {
|
|
206
|
-
// We cater for the same potential race conditions as for managed write checkpoints.
|
|
207
|
-
|
|
208
|
-
const changeStreamStart = await clusterTimePromise;
|
|
209
|
-
|
|
210
|
-
let doc = null as CustomWriteCheckpointDocument | null;
|
|
211
|
-
let clusterTime = null as mongo.Timestamp | null;
|
|
212
|
-
|
|
213
|
-
await this.db.client.withSession(async (session) => {
|
|
214
|
-
doc = await this.db.custom_write_checkpoints.findOne(
|
|
215
|
-
{
|
|
216
|
-
user_id: user_id,
|
|
217
|
-
sync_rules_id: this.sync_rules_id
|
|
218
|
-
},
|
|
219
|
-
{
|
|
220
|
-
session
|
|
221
|
-
}
|
|
222
|
-
);
|
|
223
|
-
const time = session.clusterTime?.clusterTime ?? null;
|
|
224
|
-
clusterTime = time;
|
|
225
|
-
});
|
|
226
|
-
if (clusterTime == null) {
|
|
227
|
-
throw new framework.ServiceAssertionError('Could not get clusterTime for write checkpoint');
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
if (clusterTime.lessThan(changeStreamStart)) {
|
|
231
|
-
throw new framework.ServiceAssertionError(
|
|
232
|
-
'clusterTime for write checkpoint is older than changestream start'
|
|
233
|
-
);
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
if (doc == null) {
|
|
237
|
-
// No write checkpoint, but we still need to return a result
|
|
238
|
-
return {
|
|
239
|
-
id: null,
|
|
240
|
-
lsn: null
|
|
241
|
-
};
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
return {
|
|
245
|
-
id: doc.checkpoint,
|
|
246
|
-
// custom write checkpoints are not tied to a LSN
|
|
247
|
-
lsn: null
|
|
248
|
-
};
|
|
249
|
-
}
|
|
250
|
-
};
|
|
251
|
-
});
|
|
252
|
-
|
|
253
|
-
private async *watchAllCustomWriteCheckpoints(
|
|
254
|
-
clusterTimePromise: Promise<mongo.BSON.Timestamp>,
|
|
255
|
-
signal: AbortSignal
|
|
256
|
-
): AsyncGenerator<DemultiplexerValue<WriteCheckpointResult>> {
|
|
257
|
-
const clusterTime = await clusterTimePromise;
|
|
258
|
-
|
|
259
|
-
const stream = this.db.custom_write_checkpoints.watch(
|
|
260
|
-
[
|
|
261
|
-
{
|
|
262
|
-
$match: {
|
|
263
|
-
'fullDocument.sync_rules_id': this.sync_rules_id,
|
|
264
|
-
operationType: { $in: ['insert', 'update', 'replace'] }
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
],
|
|
268
|
-
{
|
|
269
|
-
fullDocument: 'updateLookup',
|
|
270
|
-
startAtOperationTime: clusterTime
|
|
271
|
-
}
|
|
272
|
-
);
|
|
273
|
-
|
|
274
|
-
signal.onabort = () => {
|
|
275
|
-
stream.close();
|
|
276
|
-
};
|
|
277
|
-
|
|
278
|
-
if (signal.aborted) {
|
|
279
|
-
stream.close();
|
|
280
|
-
return;
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
for await (let event of stream) {
|
|
284
|
-
if (!('fullDocument' in event) || event.fullDocument == null) {
|
|
285
|
-
continue;
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
const user_id = event.fullDocument.user_id;
|
|
289
|
-
yield {
|
|
290
|
-
key: user_id,
|
|
291
|
-
value: {
|
|
292
|
-
id: event.fullDocument.checkpoint,
|
|
293
|
-
// Custom write checkpoints are not tied to a specific LSN
|
|
294
|
-
lsn: null
|
|
295
|
-
}
|
|
296
|
-
};
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
watchCustomWriteCheckpoint(options: WatchUserWriteCheckpointOptions): AsyncIterable<storage.WriteCheckpointResult> {
|
|
301
|
-
if (options.sync_rules_id != this.sync_rules_id) {
|
|
302
|
-
throw new framework.ServiceAssertionError('sync_rules_id does not match');
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
const stream = this.sharedCustomIter.subscribe(options.user_id, options.signal);
|
|
306
|
-
return this.orderedStream(stream);
|
|
307
|
-
}
|
|
308
|
-
|
|
309
83
|
protected async lastCustomWriteCheckpoint(filters: storage.CustomWriteCheckpointFilters) {
|
|
310
84
|
const { user_id, sync_rules_id } = filters;
|
|
311
85
|
const lastWriteCheckpoint = await this.db.custom_write_checkpoints.findOne({
|
|
@@ -330,34 +104,70 @@ export class MongoWriteCheckpointAPI implements storage.WriteCheckpointAPI {
|
|
|
330
104
|
return lastWriteCheckpoint?.client_id ?? null;
|
|
331
105
|
}
|
|
332
106
|
|
|
333
|
-
private async
|
|
334
|
-
const
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
107
|
+
private async getManagedWriteCheckpointChanges(options: GetCheckpointChangesOptions) {
|
|
108
|
+
const limit = 1000;
|
|
109
|
+
const changes = await this.db.write_checkpoints
|
|
110
|
+
.find(
|
|
111
|
+
{
|
|
112
|
+
processed_at_lsn: { $gt: options.lastCheckpoint.lsn, $lte: options.nextCheckpoint.lsn }
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
limit: limit + 1,
|
|
116
|
+
batchSize: limit + 1,
|
|
117
|
+
singleBatch: true
|
|
118
|
+
}
|
|
119
|
+
)
|
|
120
|
+
.toArray();
|
|
121
|
+
const invalidate = changes.length > limit;
|
|
122
|
+
|
|
123
|
+
const updatedWriteCheckpoints = new Map<string, bigint>();
|
|
124
|
+
if (!invalidate) {
|
|
125
|
+
for (let c of changes) {
|
|
126
|
+
updatedWriteCheckpoints.set(c.user_id, c.client_id);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
339
129
|
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
130
|
+
return {
|
|
131
|
+
invalidateWriteCheckpoints: invalidate,
|
|
132
|
+
updatedWriteCheckpoints
|
|
133
|
+
};
|
|
134
|
+
}
|
|
345
135
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
136
|
+
private async getCustomWriteCheckpointChanges(options: GetCheckpointChangesOptions) {
|
|
137
|
+
const limit = 1000;
|
|
138
|
+
const changes = await this.db.custom_write_checkpoints
|
|
139
|
+
.find(
|
|
140
|
+
{
|
|
141
|
+
op_id: { $gt: options.lastCheckpoint.checkpoint, $lte: options.nextCheckpoint.checkpoint }
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
limit: limit + 1,
|
|
145
|
+
batchSize: limit + 1,
|
|
146
|
+
singleBatch: true
|
|
352
147
|
}
|
|
148
|
+
)
|
|
149
|
+
.toArray();
|
|
150
|
+
const invalidate = changes.length > limit;
|
|
151
|
+
|
|
152
|
+
const updatedWriteCheckpoints = new Map<string, bigint>();
|
|
153
|
+
if (!invalidate) {
|
|
154
|
+
for (let c of changes) {
|
|
155
|
+
updatedWriteCheckpoints.set(c.user_id, c.checkpoint);
|
|
353
156
|
}
|
|
354
157
|
}
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
invalidateWriteCheckpoints: invalidate,
|
|
161
|
+
updatedWriteCheckpoints
|
|
162
|
+
};
|
|
355
163
|
}
|
|
356
164
|
}
|
|
357
165
|
|
|
358
166
|
export async function batchCreateCustomWriteCheckpoints(
|
|
359
167
|
db: PowerSyncMongo,
|
|
360
|
-
|
|
168
|
+
session: mongo.ClientSession,
|
|
169
|
+
checkpoints: storage.CustomWriteCheckpointOptions[],
|
|
170
|
+
opId: InternalOpId
|
|
361
171
|
): Promise<void> {
|
|
362
172
|
if (checkpoints.length == 0) {
|
|
363
173
|
return;
|
|
@@ -370,12 +180,13 @@ export async function batchCreateCustomWriteCheckpoints(
|
|
|
370
180
|
update: {
|
|
371
181
|
$set: {
|
|
372
182
|
checkpoint: checkpointOptions.checkpoint,
|
|
373
|
-
sync_rules_id: checkpointOptions.sync_rules_id
|
|
183
|
+
sync_rules_id: checkpointOptions.sync_rules_id,
|
|
184
|
+
op_id: opId
|
|
374
185
|
}
|
|
375
186
|
},
|
|
376
187
|
upsert: true
|
|
377
188
|
}
|
|
378
189
|
})),
|
|
379
|
-
{}
|
|
190
|
+
{ session }
|
|
380
191
|
);
|
|
381
192
|
}
|
|
@@ -3,7 +3,7 @@ import { JSONBig } from '@powersync/service-jsonbig';
|
|
|
3
3
|
import { EvaluatedParameters, EvaluatedRow } from '@powersync/service-sync-rules';
|
|
4
4
|
import * as bson from 'bson';
|
|
5
5
|
|
|
6
|
-
import { logger } from '@powersync/lib-services-framework';
|
|
6
|
+
import { Logger, logger as defaultLogger } from '@powersync/lib-services-framework';
|
|
7
7
|
import { InternalOpId, storage, utils } from '@powersync/service-core';
|
|
8
8
|
import { currentBucketKey, MAX_ROW_SIZE } from './MongoBucketBatch.js';
|
|
9
9
|
import { MongoIdSequence } from './MongoIdSequence.js';
|
|
@@ -46,6 +46,7 @@ const MAX_TRANSACTION_DOC_COUNT = 2_000;
|
|
|
46
46
|
* multiple transactions.
|
|
47
47
|
*/
|
|
48
48
|
export class PersistedBatch {
|
|
49
|
+
logger: Logger;
|
|
49
50
|
bucketData: mongo.AnyBulkWriteOperation<BucketDataDocument>[] = [];
|
|
50
51
|
bucketParameters: mongo.AnyBulkWriteOperation<BucketParameterDocument>[] = [];
|
|
51
52
|
currentData: mongo.AnyBulkWriteOperation<CurrentDataDocument>[] = [];
|
|
@@ -63,9 +64,11 @@ export class PersistedBatch {
|
|
|
63
64
|
|
|
64
65
|
constructor(
|
|
65
66
|
private group_id: number,
|
|
66
|
-
writtenSize: number
|
|
67
|
+
writtenSize: number,
|
|
68
|
+
options?: { logger?: Logger }
|
|
67
69
|
) {
|
|
68
70
|
this.currentSize = writtenSize;
|
|
71
|
+
this.logger = options?.logger ?? defaultLogger;
|
|
69
72
|
}
|
|
70
73
|
|
|
71
74
|
private incrementBucket(bucket: string, op_id: InternalOpId) {
|
|
@@ -94,7 +97,7 @@ export class PersistedBatch {
|
|
|
94
97
|
remaining_buckets.set(key, b);
|
|
95
98
|
}
|
|
96
99
|
|
|
97
|
-
const dchecksum = utils.hashDelete(replicaIdToSubkey(options.table.id, options.sourceKey));
|
|
100
|
+
const dchecksum = BigInt(utils.hashDelete(replicaIdToSubkey(options.table.id, options.sourceKey)));
|
|
98
101
|
|
|
99
102
|
for (const k of options.evaluated) {
|
|
100
103
|
const key = currentBucketKey(k);
|
|
@@ -107,7 +110,7 @@ export class PersistedBatch {
|
|
|
107
110
|
// the BSON size is small enough, but the JSON size is too large.
|
|
108
111
|
// In these cases, we can't store the data, so we skip it, or generate a REMOVE operation if the row
|
|
109
112
|
// was synced previously.
|
|
110
|
-
logger.error(`
|
|
113
|
+
this.logger.error(`Row ${key} too large: ${recordData.length} bytes. Removing.`);
|
|
111
114
|
continue;
|
|
112
115
|
}
|
|
113
116
|
|
|
@@ -130,7 +133,7 @@ export class PersistedBatch {
|
|
|
130
133
|
source_key: options.sourceKey,
|
|
131
134
|
table: k.table,
|
|
132
135
|
row_id: k.id,
|
|
133
|
-
checksum: checksum,
|
|
136
|
+
checksum: BigInt(checksum),
|
|
134
137
|
data: recordData
|
|
135
138
|
}
|
|
136
139
|
}
|
|
@@ -270,9 +273,11 @@ export class PersistedBatch {
|
|
|
270
273
|
);
|
|
271
274
|
}
|
|
272
275
|
|
|
273
|
-
async flush(db: PowerSyncMongo, session: mongo.ClientSession) {
|
|
276
|
+
async flush(db: PowerSyncMongo, session: mongo.ClientSession, options?: storage.BucketBatchCommitOptions) {
|
|
274
277
|
const startAt = performance.now();
|
|
278
|
+
let flushedSomething = false;
|
|
275
279
|
if (this.bucketData.length > 0) {
|
|
280
|
+
flushedSomething = true;
|
|
276
281
|
await db.bucket_data.bulkWrite(this.bucketData, {
|
|
277
282
|
session,
|
|
278
283
|
// inserts only - order doesn't matter
|
|
@@ -280,6 +285,7 @@ export class PersistedBatch {
|
|
|
280
285
|
});
|
|
281
286
|
}
|
|
282
287
|
if (this.bucketParameters.length > 0) {
|
|
288
|
+
flushedSomething = true;
|
|
283
289
|
await db.bucket_parameters.bulkWrite(this.bucketParameters, {
|
|
284
290
|
session,
|
|
285
291
|
// inserts only - order doesn't matter
|
|
@@ -287,6 +293,7 @@ export class PersistedBatch {
|
|
|
287
293
|
});
|
|
288
294
|
}
|
|
289
295
|
if (this.currentData.length > 0) {
|
|
296
|
+
flushedSomething = true;
|
|
290
297
|
await db.current_data.bulkWrite(this.currentData, {
|
|
291
298
|
session,
|
|
292
299
|
// may update and delete data within the same batch - order matters
|
|
@@ -295,6 +302,7 @@ export class PersistedBatch {
|
|
|
295
302
|
}
|
|
296
303
|
|
|
297
304
|
if (this.bucketStates.size > 0) {
|
|
305
|
+
flushedSomething = true;
|
|
298
306
|
await db.bucket_state.bulkWrite(this.getBucketStateUpdates(), {
|
|
299
307
|
session,
|
|
300
308
|
// Per-bucket operation - order doesn't matter
|
|
@@ -302,12 +310,43 @@ export class PersistedBatch {
|
|
|
302
310
|
});
|
|
303
311
|
}
|
|
304
312
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
313
|
+
if (flushedSomething) {
|
|
314
|
+
const duration = Math.round(performance.now() - startAt);
|
|
315
|
+
if (options?.oldestUncommittedChange != null) {
|
|
316
|
+
const replicationLag = Math.round((Date.now() - options.oldestUncommittedChange.getTime()) / 1000);
|
|
317
|
+
|
|
318
|
+
this.logger.info(
|
|
319
|
+
`Flushed ${this.bucketData.length} + ${this.bucketParameters.length} + ${
|
|
320
|
+
this.currentData.length
|
|
321
|
+
} updates, ${Math.round(this.currentSize / 1024)}kb in ${duration}ms. Last op_id: ${this.debugLastOpId}. Replication lag: ${replicationLag}s`,
|
|
322
|
+
{
|
|
323
|
+
flushed: {
|
|
324
|
+
duration: duration,
|
|
325
|
+
size: this.currentSize,
|
|
326
|
+
bucket_data_count: this.bucketData.length,
|
|
327
|
+
parameter_data_count: this.bucketParameters.length,
|
|
328
|
+
current_data_count: this.currentData.length,
|
|
329
|
+
replication_lag_seconds: replicationLag
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
);
|
|
333
|
+
} else {
|
|
334
|
+
this.logger.info(
|
|
335
|
+
`Flushed ${this.bucketData.length} + ${this.bucketParameters.length} + ${
|
|
336
|
+
this.currentData.length
|
|
337
|
+
} updates, ${Math.round(this.currentSize / 1024)}kb in ${duration}ms. Last op_id: ${this.debugLastOpId}`,
|
|
338
|
+
{
|
|
339
|
+
flushed: {
|
|
340
|
+
duration: duration,
|
|
341
|
+
size: this.currentSize,
|
|
342
|
+
bucket_data_count: this.bucketData.length,
|
|
343
|
+
parameter_data_count: this.bucketParameters.length,
|
|
344
|
+
current_data_count: this.currentData.length
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
311
350
|
|
|
312
351
|
this.bucketData = [];
|
|
313
352
|
this.bucketParameters = [];
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
BucketDataDocument,
|
|
8
8
|
BucketParameterDocument,
|
|
9
9
|
BucketStateDocument,
|
|
10
|
+
CheckpointEventDocument,
|
|
10
11
|
CurrentDataDocument,
|
|
11
12
|
CustomWriteCheckpointDocument,
|
|
12
13
|
IdSequenceDocument,
|
|
@@ -35,6 +36,7 @@ export class PowerSyncMongo {
|
|
|
35
36
|
readonly instance: mongo.Collection<InstanceDocument>;
|
|
36
37
|
readonly locks: mongo.Collection<lib_mongo.locks.Lock>;
|
|
37
38
|
readonly bucket_state: mongo.Collection<BucketStateDocument>;
|
|
39
|
+
readonly checkpoint_events: mongo.Collection<CheckpointEventDocument>;
|
|
38
40
|
|
|
39
41
|
readonly client: mongo.MongoClient;
|
|
40
42
|
readonly db: mongo.Db;
|
|
@@ -58,6 +60,7 @@ export class PowerSyncMongo {
|
|
|
58
60
|
this.instance = db.collection('instance');
|
|
59
61
|
this.locks = this.db.collection('locks');
|
|
60
62
|
this.bucket_state = this.db.collection('bucket_state');
|
|
63
|
+
this.checkpoint_events = this.db.collection('checkpoint_events');
|
|
61
64
|
}
|
|
62
65
|
|
|
63
66
|
/**
|
|
@@ -85,6 +88,45 @@ export class PowerSyncMongo {
|
|
|
85
88
|
async drop() {
|
|
86
89
|
await this.db.dropDatabase();
|
|
87
90
|
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Call this after every checkpoint or sync rules status update. Rather call too often than too rarely.
|
|
94
|
+
*
|
|
95
|
+
* This is used in a similar way to the Postgres NOTIFY functionality.
|
|
96
|
+
*/
|
|
97
|
+
async notifyCheckpoint() {
|
|
98
|
+
await this.checkpoint_events.insertOne({} as any, { forceServerObjectId: true });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Only use in migrations and tests.
|
|
103
|
+
*/
|
|
104
|
+
async createCheckpointEventsCollection() {
|
|
105
|
+
// We cover the case where the replication process was started before running this migration.
|
|
106
|
+
const existingCollections = await this.db
|
|
107
|
+
.listCollections({ name: 'checkpoint_events' }, { nameOnly: false })
|
|
108
|
+
.toArray();
|
|
109
|
+
const collection = existingCollections[0];
|
|
110
|
+
if (collection != null) {
|
|
111
|
+
if (!collection.options?.capped) {
|
|
112
|
+
// Collection was auto-created but not capped, so we need to drop it
|
|
113
|
+
await this.db.dropCollection('checkpoint_events');
|
|
114
|
+
} else {
|
|
115
|
+
// Collection previously created somehow - ignore
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
await this.db.createCollection('checkpoint_events', {
|
|
121
|
+
capped: true,
|
|
122
|
+
// We want a small size, since opening a tailable cursor scans this entire collection.
|
|
123
|
+
// On the other hand, if we fill this up faster than a process can read it, it will
|
|
124
|
+
// invalidate the cursor. We do handle cursor invalidation events, but don't want
|
|
125
|
+
// that to happen too often.
|
|
126
|
+
size: 50 * 1024, // size in bytes
|
|
127
|
+
max: 50 // max number of documents
|
|
128
|
+
});
|
|
129
|
+
}
|
|
88
130
|
}
|
|
89
131
|
|
|
90
132
|
export function createPowerSyncMongo(config: MongoStorageConfig, options?: lib_mongo.MongoConnectionOptions) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { storage } from '@powersync/service-core';
|
|
1
|
+
import { InternalOpId, storage } from '@powersync/service-core';
|
|
2
2
|
import { SqliteJsonValue } from '@powersync/service-sync-rules';
|
|
3
3
|
import * as bson from 'bson';
|
|
4
4
|
|
|
@@ -56,7 +56,7 @@ export interface BucketDataDocument {
|
|
|
56
56
|
source_key?: ReplicaId;
|
|
57
57
|
table?: string;
|
|
58
58
|
row_id?: string;
|
|
59
|
-
checksum:
|
|
59
|
+
checksum: bigint;
|
|
60
60
|
data: string | null;
|
|
61
61
|
target_op?: bigint | null;
|
|
62
62
|
}
|
|
@@ -73,6 +73,13 @@ export interface SourceTableDocument {
|
|
|
73
73
|
replica_id_columns: string[] | null;
|
|
74
74
|
replica_id_columns2: { name: string; type_oid?: number; type?: string }[] | undefined;
|
|
75
75
|
snapshot_done: boolean | undefined;
|
|
76
|
+
snapshot_status: SourceTableDocumentSnapshotStatus | undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface SourceTableDocumentSnapshotStatus {
|
|
80
|
+
total_estimated_count: number;
|
|
81
|
+
replicated_count: number;
|
|
82
|
+
last_key: bson.Binary | null;
|
|
76
83
|
}
|
|
77
84
|
|
|
78
85
|
/**
|
|
@@ -110,6 +117,13 @@ export interface SyncRuleDocument {
|
|
|
110
117
|
*/
|
|
111
118
|
snapshot_done: boolean;
|
|
112
119
|
|
|
120
|
+
/**
|
|
121
|
+
* If snapshot_done = false, this may be the lsn at which we started the snapshot.
|
|
122
|
+
*
|
|
123
|
+
* This can be used for resuming the snapshot after a restart.
|
|
124
|
+
*/
|
|
125
|
+
snapshot_lsn: string | undefined;
|
|
126
|
+
|
|
113
127
|
/**
|
|
114
128
|
* The last consistent checkpoint.
|
|
115
129
|
*
|
|
@@ -159,6 +173,10 @@ export interface SyncRuleDocument {
|
|
|
159
173
|
content: string;
|
|
160
174
|
}
|
|
161
175
|
|
|
176
|
+
export interface CheckpointEventDocument {
|
|
177
|
+
_id: bson.ObjectId;
|
|
178
|
+
}
|
|
179
|
+
|
|
162
180
|
export type SyncRuleCheckpointState = Pick<
|
|
163
181
|
SyncRuleDocument,
|
|
164
182
|
'last_checkpoint' | 'last_checkpoint_lsn' | '_id' | 'state'
|
|
@@ -169,6 +187,13 @@ export interface CustomWriteCheckpointDocument {
|
|
|
169
187
|
user_id: string;
|
|
170
188
|
checkpoint: bigint;
|
|
171
189
|
sync_rules_id: number;
|
|
190
|
+
/**
|
|
191
|
+
* Unlike managed write checkpoints, custom write checkpoints are flushed together with
|
|
192
|
+
* normal ops. This means we can assign an op_id for ordering / correlating with read checkpoints.
|
|
193
|
+
*
|
|
194
|
+
* This is not unique - multiple write checkpoints can have the same op_id.
|
|
195
|
+
*/
|
|
196
|
+
op_id?: InternalOpId;
|
|
172
197
|
}
|
|
173
198
|
|
|
174
199
|
export interface WriteCheckpointDocument {
|
|
@@ -176,6 +201,12 @@ export interface WriteCheckpointDocument {
|
|
|
176
201
|
user_id: string;
|
|
177
202
|
lsns: Record<string, string>;
|
|
178
203
|
client_id: bigint;
|
|
204
|
+
/**
|
|
205
|
+
* This is set to the checkpoint lsn when the checkpoint lsn >= this lsn.
|
|
206
|
+
* This is used to make it easier to determine what write checkpoints have been processed
|
|
207
|
+
* between two checkpoints.
|
|
208
|
+
*/
|
|
209
|
+
processed_at_lsn: string | null;
|
|
179
210
|
}
|
|
180
211
|
|
|
181
212
|
export interface InstanceDocument {
|
|
@@ -117,5 +117,12 @@ describe('sync - mongodb', () => {
|
|
|
117
117
|
has_more: false,
|
|
118
118
|
next_after: '4'
|
|
119
119
|
});
|
|
120
|
+
|
|
121
|
+
// Test that the checksum type is correct.
|
|
122
|
+
// Specifically, test that it never persisted as double.
|
|
123
|
+
const checksumTypes = await factory.db.bucket_data
|
|
124
|
+
.aggregate([{ $group: { _id: { $type: '$checksum' }, count: { $sum: 1 } } }])
|
|
125
|
+
.toArray();
|
|
126
|
+
expect(checksumTypes).toEqual([{ _id: 'long', count: 4 }]);
|
|
120
127
|
});
|
|
121
128
|
});
|