@powersync/service-module-mongodb 0.18.2 → 0.19.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.
- package/CHANGELOG.md +16 -0
- package/dist/api/MongoRouteAPIAdapter.d.ts +2 -0
- package/dist/api/MongoRouteAPIAdapter.js +23 -32
- package/dist/api/MongoRouteAPIAdapter.js.map +1 -1
- package/dist/common/SentinelLSN.d.ts +37 -0
- package/dist/common/SentinelLSN.js +59 -0
- package/dist/common/SentinelLSN.js.map +1 -0
- package/dist/replication/ChangeStream.d.ts +15 -0
- package/dist/replication/ChangeStream.js +105 -44
- package/dist/replication/ChangeStream.js.map +1 -1
- package/dist/replication/MongoRelation.d.ts +36 -1
- package/dist/replication/MongoRelation.js +102 -6
- package/dist/replication/MongoRelation.js.map +1 -1
- package/dist/replication/MongoSnapshotter.d.ts +9 -0
- package/dist/replication/MongoSnapshotter.js +108 -42
- package/dist/replication/MongoSnapshotter.js.map +1 -1
- package/dist/replication/RawChangeStream.d.ts +12 -1
- package/dist/replication/RawChangeStream.js +30 -7
- package/dist/replication/RawChangeStream.js.map +1 -1
- package/dist/replication/checkpoints/CheckpointImplementation.d.ts +132 -0
- package/dist/replication/checkpoints/CheckpointImplementation.js +22 -0
- package/dist/replication/checkpoints/CheckpointImplementation.js.map +1 -0
- package/dist/replication/checkpoints/SentinelCheckpointImplementation.d.ts +55 -0
- package/dist/replication/checkpoints/SentinelCheckpointImplementation.js +222 -0
- package/dist/replication/checkpoints/SentinelCheckpointImplementation.js.map +1 -0
- package/dist/replication/checkpoints/TimestampCheckpointImplementation.d.ts +24 -0
- package/dist/replication/checkpoints/TimestampCheckpointImplementation.js +113 -0
- package/dist/replication/checkpoints/TimestampCheckpointImplementation.js.map +1 -0
- package/dist/replication/checkpoints/create-checkpoint-implementation.d.ts +6 -0
- package/dist/replication/checkpoints/create-checkpoint-implementation.js +10 -0
- package/dist/replication/checkpoints/create-checkpoint-implementation.js.map +1 -0
- package/dist/replication/replication-utils.d.ts +6 -0
- package/dist/replication/replication-utils.js +19 -2
- package/dist/replication/replication-utils.js.map +1 -1
- package/package.json +8 -8
- package/src/api/MongoRouteAPIAdapter.ts +26 -37
- package/src/common/SentinelLSN.ts +78 -0
- package/src/replication/ChangeStream.ts +118 -50
- package/src/replication/MongoRelation.ts +124 -14
- package/src/replication/MongoSnapshotter.ts +122 -47
- package/src/replication/RawChangeStream.ts +64 -28
- package/src/replication/checkpoints/CheckpointImplementation.ts +167 -0
- package/src/replication/checkpoints/SentinelCheckpointImplementation.ts +267 -0
- package/src/replication/checkpoints/TimestampCheckpointImplementation.ts +142 -0
- package/src/replication/checkpoints/create-checkpoint-implementation.ts +14 -0
- package/src/replication/replication-utils.ts +23 -2
- package/test/DOCUMENTDB_TESTING.md +115 -0
- package/test/src/DatabaseType.ts +25 -0
- package/test/src/change_stream.test.ts +97 -65
- package/test/src/change_stream_utils.ts +83 -7
- package/test/src/checkpoint_retry.test.ts +5 -2
- package/test/src/documentdb_helpers.test.ts +124 -0
- package/test/src/documentdb_mode.test.ts +1040 -0
- package/test/src/mongo_test.test.ts +15 -5
- package/test/src/raw_change_stream.test.ts +209 -125
- package/test/src/resume_token.test.ts +30 -0
- package/test/src/slow_tests.test.ts +4 -1
- package/test/src/test-timeouts.ts +23 -0
- package/test/src/util.ts +1 -2
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -14,7 +14,9 @@ import {
|
|
|
14
14
|
} from '@powersync/service-sync-rules';
|
|
15
15
|
|
|
16
16
|
import { ErrorCode, logger, ServiceAssertionError, ServiceError } from '@powersync/lib-services-framework';
|
|
17
|
+
import { ObjectId } from 'bson';
|
|
17
18
|
import { MongoLSN } from '../common/MongoLSN.js';
|
|
19
|
+
import { SentinelLSN } from '../common/SentinelLSN.js';
|
|
18
20
|
|
|
19
21
|
export function getMongoRelation(
|
|
20
22
|
source: mongo.ChangeStreamNameSpace,
|
|
@@ -173,15 +175,24 @@ function filterJsonData(data: any, context: CompatibilityContext, depth = 0): an
|
|
|
173
175
|
*/
|
|
174
176
|
export const STANDALONE_CHECKPOINT_ID = '_standalone_checkpoint';
|
|
175
177
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
178
|
+
/**
|
|
179
|
+
* Id for checkpoint records managed by the {@link SentinelCheckpointImplementation} implementation.
|
|
180
|
+
*/
|
|
181
|
+
export const SENTINEL_CHECKPOINT_ID = '_sentinel_checkpoint';
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Create a checkpoint by upserting a document in _powersync_checkpoints, and
|
|
185
|
+
* return a comparable LSN string derived from the write's operationTime.
|
|
186
|
+
*
|
|
187
|
+
* Standard MongoDB only: this requires session.operationTime, which DocumentDB
|
|
188
|
+
* does not provide (it throws PSYNC_S1004 when it is missing). The DocumentDB /
|
|
189
|
+
* sentinel path builds LSNs via {@link createSentinelCheckpointLsn} instead.
|
|
190
|
+
*/
|
|
191
|
+
export async function createCheckpoint(db: mongo.Db, id: mongo.ObjectId | string): Promise<string> {
|
|
181
192
|
const TRIES = 2;
|
|
182
193
|
for (let i = 0; i < TRIES; i++) {
|
|
183
194
|
try {
|
|
184
|
-
return await createCheckpointInner(
|
|
195
|
+
return await createCheckpointInner(db, id);
|
|
185
196
|
} catch (e) {
|
|
186
197
|
if (i < TRIES - 1) {
|
|
187
198
|
logger.warn(`Failed to create checkpoint on attempt ${i + 1}`, e);
|
|
@@ -193,11 +204,7 @@ export async function createCheckpoint(
|
|
|
193
204
|
throw new ServiceAssertionError(`Unreachable code`);
|
|
194
205
|
}
|
|
195
206
|
|
|
196
|
-
async function createCheckpointInner(
|
|
197
|
-
client: mongo.MongoClient,
|
|
198
|
-
db: mongo.Db,
|
|
199
|
-
id: mongo.ObjectId | string
|
|
200
|
-
): Promise<string> {
|
|
207
|
+
async function createCheckpointInner(db: mongo.Db, id: mongo.ObjectId | string): Promise<string> {
|
|
201
208
|
// We use an unique id per process, and clear documents on startup.
|
|
202
209
|
// This is so that we can filter events for our own process only, and ignore
|
|
203
210
|
// events from other processes.
|
|
@@ -208,6 +215,8 @@ async function createCheckpointInner(
|
|
|
208
215
|
// Instead, we do manual retries, which does not have the same write de-duplication logic.
|
|
209
216
|
// A sentinal-based approach would be better here, but that is a much bigger change.
|
|
210
217
|
|
|
218
|
+
const update: mongo.Document = { $inc: { i: 1 } };
|
|
219
|
+
|
|
211
220
|
const response = await db.command({
|
|
212
221
|
findAndModify: '_powersync_checkpoints',
|
|
213
222
|
query: {
|
|
@@ -215,9 +224,7 @@ async function createCheckpointInner(
|
|
|
215
224
|
},
|
|
216
225
|
new: true,
|
|
217
226
|
upsert: true,
|
|
218
|
-
update
|
|
219
|
-
$inc: { i: 1 }
|
|
220
|
-
}
|
|
227
|
+
update
|
|
221
228
|
});
|
|
222
229
|
|
|
223
230
|
const time = response.operationTime as mongo.Timestamp | undefined;
|
|
@@ -227,6 +234,109 @@ async function createCheckpointInner(
|
|
|
227
234
|
return new MongoLSN({ timestamp: time }).comparable;
|
|
228
235
|
}
|
|
229
236
|
|
|
237
|
+
/**
|
|
238
|
+
* Create a DocumentDB comparable LSN by advancing the shared sentinel checkpoint
|
|
239
|
+
* document ({@link SENTINEL_CHECKPOINT_ID}). The returned LSN encodes the
|
|
240
|
+
* checkpoint counter in the same 16-hex shape as a MongoDB timestamp LSN (see
|
|
241
|
+
* {@link SentinelLSN}), so the two formats are directly comparable. The counter is
|
|
242
|
+
* seeded in the epoch-seconds range (see below), so a sentinel LSN always sorts
|
|
243
|
+
* above any real-timestamp LSN issued in the past.
|
|
244
|
+
*
|
|
245
|
+
* This counter is intentionally global to the source database. It is used for
|
|
246
|
+
* storage/client checkpoint comparisons and write checkpoint heads, so it must
|
|
247
|
+
* not reset when a new ChangeStream instance or new sync rules start.
|
|
248
|
+
*
|
|
249
|
+
* The document is a single shared record whose `stream_id` field alternates:
|
|
250
|
+
* batch/keepalive bumps stamp it with the calling stream's id (so a stream can
|
|
251
|
+
* recognise its own private barriers), while standalone bumps (write checkpoint
|
|
252
|
+
* heads, snapshot markers) clear it to null. `i` advances globally regardless.
|
|
253
|
+
*
|
|
254
|
+
* @param changeStreamId
|
|
255
|
+
* When provided, the bump is attributed to this stream (a private barrier).
|
|
256
|
+
* When omitted, it is a standalone bump and stream_id is cleared to null.
|
|
257
|
+
*/
|
|
258
|
+
export async function createSentinelCheckpointLsn(
|
|
259
|
+
client: mongo.MongoClient,
|
|
260
|
+
db: mongo.Db,
|
|
261
|
+
changeStreamId?: ObjectId
|
|
262
|
+
): Promise<string> {
|
|
263
|
+
const session = client.startSession();
|
|
264
|
+
try {
|
|
265
|
+
const collection = db.collection('_powersync_checkpoints');
|
|
266
|
+
|
|
267
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
268
|
+
// Common path: increment the existing counter.
|
|
269
|
+
const result = await collection.findOneAndUpdate(
|
|
270
|
+
{
|
|
271
|
+
_id: SENTINEL_CHECKPOINT_ID as any,
|
|
272
|
+
i: { $exists: true }
|
|
273
|
+
},
|
|
274
|
+
{
|
|
275
|
+
$inc: { i: 1 },
|
|
276
|
+
// Standalone bumps (no changeStreamId) must explicitly clear the
|
|
277
|
+
// stream_id left by a previous batch bump — this is a single shared
|
|
278
|
+
// document whose stream_id alternates. Write null rather than relying
|
|
279
|
+
// on `undefined` being serialized (which depends on the driver's
|
|
280
|
+
// ignoreUndefined option, and collapses to an empty $set if enabled).
|
|
281
|
+
$set: {
|
|
282
|
+
stream_id: changeStreamId ?? null
|
|
283
|
+
}
|
|
284
|
+
},
|
|
285
|
+
{
|
|
286
|
+
returnDocument: 'after',
|
|
287
|
+
session
|
|
288
|
+
}
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
if (result != null) {
|
|
292
|
+
// `i` is a bigint: the client is configured with useBigInt64, and the
|
|
293
|
+
// seed exceeds 2^53 so it could not be safely represented as a number.
|
|
294
|
+
return new SentinelLSN({ sentinel: result.i }).comparable;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// The counter document does not exist: first run, or a consumer deleted
|
|
298
|
+
// it in their source database. Seed the counter in the epoch-SECONDS range
|
|
299
|
+
// (seconds in the high 32 bits, mirroring a MongoDB timestamp) rather than
|
|
300
|
+
// starting at 1. Two properties follow:
|
|
301
|
+
//
|
|
302
|
+
// - A sentinel LSN sorts above any real-timestamp LSN issued in the past,
|
|
303
|
+
// because its high 32 bits are the current epoch seconds.
|
|
304
|
+
// - A re-created counter jumps forward instead of backward across deletion:
|
|
305
|
+
// the seed advances by 2^32 each wall-clock second, while checkpoints add
|
|
306
|
+
// 1 each, so any later re-seed exceeds the previously issued coordinate
|
|
307
|
+
// (keeping the LSN domain monotonic; otherwise new write checkpoint heads
|
|
308
|
+
// could resolve against old, higher committed LSNs).
|
|
309
|
+
//
|
|
310
|
+
// $setOnInsert cannot be combined with $inc on the same field, so this
|
|
311
|
+
// is a separate upsert; the loop then retries the increment. The
|
|
312
|
+
// $setOnInsert is a no-op if another process created the document
|
|
313
|
+
// concurrently.
|
|
314
|
+
await collection.updateOne(
|
|
315
|
+
{
|
|
316
|
+
_id: SENTINEL_CHECKPOINT_ID as any
|
|
317
|
+
},
|
|
318
|
+
{
|
|
319
|
+
$setOnInsert: {
|
|
320
|
+
i: BigInt(Math.floor(Date.now() / 1000)) << 32n,
|
|
321
|
+
stream_id: changeStreamId ?? null
|
|
322
|
+
}
|
|
323
|
+
},
|
|
324
|
+
{
|
|
325
|
+
upsert: true,
|
|
326
|
+
session
|
|
327
|
+
}
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
throw new ServiceError(
|
|
332
|
+
ErrorCode.PSYNC_S1301,
|
|
333
|
+
`Failed to increment the sentinel checkpoint counter - the checkpoint document may be getting deleted concurrently.`
|
|
334
|
+
);
|
|
335
|
+
} finally {
|
|
336
|
+
await session.endSession();
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
230
340
|
const mongoTimeOptions: DateTimeSourceOptions = {
|
|
231
341
|
subSecondPrecision: TimeValuePrecision.milliseconds,
|
|
232
342
|
defaultSubSecondPrecision: TimeValuePrecision.milliseconds
|
|
@@ -14,11 +14,13 @@ import { performance } from 'node:perf_hooks';
|
|
|
14
14
|
import { MongoLSN } from '../common/MongoLSN.js';
|
|
15
15
|
import { PostImagesOption } from '../types/types.js';
|
|
16
16
|
import { escapeRegExp } from '../utils.js';
|
|
17
|
+
import { CheckpointImplementation } from './checkpoints/CheckpointImplementation.js';
|
|
18
|
+
import { createCheckpointImplementation } from './checkpoints/create-checkpoint-implementation.js';
|
|
17
19
|
import { MongoManager } from './MongoManager.js';
|
|
18
|
-
import {
|
|
20
|
+
import { getMongoRelation } from './MongoRelation.js';
|
|
19
21
|
import { ChunkedSnapshotQuery } from './MongoSnapshotQuery.js';
|
|
20
22
|
import { ChangeStreamBatch, parseChangeDocument, rawChangeStream } from './RawChangeStream.js';
|
|
21
|
-
import { CHECKPOINTS_COLLECTION } from './replication-utils.js';
|
|
23
|
+
import { CHECKPOINTS_COLLECTION, detectDocumentDb } from './replication-utils.js';
|
|
22
24
|
import { DirectSourceRowConverter, SourceRowConverter } from './SourceRowConverter.js';
|
|
23
25
|
|
|
24
26
|
export interface MongoSnapshotterOptions {
|
|
@@ -72,6 +74,9 @@ export class MongoSnapshotter {
|
|
|
72
74
|
private nextItemQueued: PromiseWithResolvers<void> | null = null;
|
|
73
75
|
private lastTouchedAt = performance.now();
|
|
74
76
|
|
|
77
|
+
private isDocumentDb = false;
|
|
78
|
+
private _checkpointImplementation: CheckpointImplementation | null = null;
|
|
79
|
+
|
|
75
80
|
constructor(options: MongoSnapshotterOptions) {
|
|
76
81
|
this.storage = options.storage;
|
|
77
82
|
this.metrics = options.metrics;
|
|
@@ -108,6 +113,48 @@ export class MongoSnapshotter {
|
|
|
108
113
|
return this.storage.storageConfig.softDeleteCurrentData;
|
|
109
114
|
}
|
|
110
115
|
|
|
116
|
+
/** The active checkpoint strategy. Only valid after ensureDetected(). */
|
|
117
|
+
private get checkpointImplementation(): CheckpointImplementation {
|
|
118
|
+
if (this._checkpointImplementation == null) {
|
|
119
|
+
throw new ServiceError(
|
|
120
|
+
ErrorCode.PSYNC_S1301,
|
|
121
|
+
'Checkpoint implementation not initialized - call ensureDetected() first'
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
return this._checkpointImplementation;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Detect DocumentDB and select the checkpoint implementation. Idempotent.
|
|
129
|
+
* Also validates server topology (sharded/standalone) for standard MongoDB.
|
|
130
|
+
*/
|
|
131
|
+
private async ensureDetected(): Promise<void> {
|
|
132
|
+
if (this._checkpointImplementation != null) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
this.isDocumentDb = await detectDocumentDb(this.defaultDb);
|
|
136
|
+
if (!this.isDocumentDb) {
|
|
137
|
+
const hello = await this.defaultDb.command({ hello: 1 });
|
|
138
|
+
if (hello.msg == 'isdbgrid') {
|
|
139
|
+
throw new ServiceError(
|
|
140
|
+
ErrorCode.PSYNC_S1341,
|
|
141
|
+
'Sharded MongoDB Clusters are not supported yet (including MongoDB Serverless instances).'
|
|
142
|
+
);
|
|
143
|
+
} else if (hello.setName == null) {
|
|
144
|
+
throw new ServiceError(
|
|
145
|
+
ErrorCode.PSYNC_S1342,
|
|
146
|
+
'Standalone MongoDB instances are not supported - use a replicaset.'
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
this._checkpointImplementation = createCheckpointImplementation(this.isDocumentDb, {
|
|
151
|
+
client: this.client,
|
|
152
|
+
db: this.defaultDb,
|
|
153
|
+
checkpointStreamId: this.checkpointStreamId,
|
|
154
|
+
logger: this.logger
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
111
158
|
async checkSlot(): Promise<InitResult> {
|
|
112
159
|
const status = await this.storage.getStatus();
|
|
113
160
|
if (status.snapshotDone) {
|
|
@@ -119,12 +166,18 @@ export class MongoSnapshotter {
|
|
|
119
166
|
}
|
|
120
167
|
|
|
121
168
|
async setupCheckpointsCollection() {
|
|
169
|
+
await this.ensureDetected();
|
|
122
170
|
const collection = await this.getCollectionInfo(this.defaultDb.databaseName, CHECKPOINTS_COLLECTION);
|
|
123
171
|
if (collection == null) {
|
|
124
172
|
await this.defaultDb.createCollection(CHECKPOINTS_COLLECTION, {
|
|
125
|
-
|
|
173
|
+
// DocumentDB does not support changeStreamPreAndPostImages.
|
|
174
|
+
...(this.isDocumentDb ? {} : { changeStreamPreAndPostImages: { enabled: true } })
|
|
126
175
|
});
|
|
127
|
-
} else if (
|
|
176
|
+
} else if (
|
|
177
|
+
!this.isDocumentDb &&
|
|
178
|
+
this.usePostImages &&
|
|
179
|
+
collection.options?.changeStreamPreAndPostImages?.enabled != true
|
|
180
|
+
) {
|
|
128
181
|
// Drop + create requires less permissions than collMod,
|
|
129
182
|
// and we don't care about the data in this collection.
|
|
130
183
|
await this.defaultDb.dropCollection(CHECKPOINTS_COLLECTION);
|
|
@@ -135,12 +188,22 @@ export class MongoSnapshotter {
|
|
|
135
188
|
// Clear the collection on startup, to keep it clean
|
|
136
189
|
// We never query this collection directly, and don't want to keep the data around.
|
|
137
190
|
// We only use this to get data into the oplog/changestream.
|
|
138
|
-
|
|
191
|
+
//
|
|
192
|
+
// The implementation supplies the filter: the sentinel implementation must
|
|
193
|
+
// preserve the sentinel checkpoint document, since its counter is the
|
|
194
|
+
// globally-ordered component of every committed DocumentDB LSN.
|
|
195
|
+
await this.defaultDb
|
|
196
|
+
.collection(CHECKPOINTS_COLLECTION)
|
|
197
|
+
.deleteMany(this.checkpointImplementation.checkpointClearFilter);
|
|
139
198
|
}
|
|
140
199
|
}
|
|
141
200
|
|
|
142
201
|
async queueSnapshotTables(snapshotLsn: string | null) {
|
|
143
202
|
await this.client.connect();
|
|
203
|
+
// Ensure isDocumentDb is set before any getSourceNamespaceFilters() call below
|
|
204
|
+
// (notably the validateSnapshotLsn resume path, which does not go through
|
|
205
|
+
// getSnapshotLsn). Idempotent.
|
|
206
|
+
await this.ensureDetected();
|
|
144
207
|
await using writer = await this.storage.createWriter({
|
|
145
208
|
zeroLSN: MongoLSN.ZERO.comparable,
|
|
146
209
|
defaultSchema: this.defaultDb.databaseName,
|
|
@@ -256,7 +319,7 @@ export class MongoSnapshotter {
|
|
|
256
319
|
for (const table of tables) {
|
|
257
320
|
await this.snapshotTable(batch, table);
|
|
258
321
|
}
|
|
259
|
-
const noCheckpointBefore = await
|
|
322
|
+
const noCheckpointBefore = await this.checkpointImplementation.createStandaloneCheckpoint();
|
|
260
323
|
|
|
261
324
|
await batch.markTableSnapshotDone(tables, noCheckpointBefore);
|
|
262
325
|
}
|
|
@@ -300,7 +363,7 @@ export class MongoSnapshotter {
|
|
|
300
363
|
|
|
301
364
|
// The checkpoint here is a marker - we need to replicate up to at least this
|
|
302
365
|
// point before the data can be considered consistent.
|
|
303
|
-
const checkpoint = await
|
|
366
|
+
const checkpoint = await this.checkpointImplementation.createStandaloneCheckpoint();
|
|
304
367
|
if (this.queue.size != 0) {
|
|
305
368
|
return;
|
|
306
369
|
}
|
|
@@ -312,7 +375,7 @@ export class MongoSnapshotter {
|
|
|
312
375
|
// KLUDGE: We need to create an extra checkpoint _after_ marking the snapshot done, to fix
|
|
313
376
|
// issues with order of processing commits(). This is picked up by tests on postgres storage,
|
|
314
377
|
// the issue may be specific to that storage engine.
|
|
315
|
-
await
|
|
378
|
+
await this.checkpointImplementation.createStandaloneCheckpoint();
|
|
316
379
|
}
|
|
317
380
|
|
|
318
381
|
private async replicateTable(tableRequest: SourceTable) {
|
|
@@ -336,7 +399,7 @@ export class MongoSnapshotter {
|
|
|
336
399
|
}
|
|
337
400
|
|
|
338
401
|
await this.snapshotTable(writer, table);
|
|
339
|
-
const noCheckpointBefore = await
|
|
402
|
+
const noCheckpointBefore = await this.checkpointImplementation.createStandaloneCheckpoint();
|
|
340
403
|
await writer.markTableSnapshotDone([table], noCheckpointBefore);
|
|
341
404
|
|
|
342
405
|
// This commit durably records the persisted ops, so a later checkpoint covers them.
|
|
@@ -516,19 +579,7 @@ export class MongoSnapshotter {
|
|
|
516
579
|
}
|
|
517
580
|
|
|
518
581
|
private async getSnapshotLsn(): Promise<string> {
|
|
519
|
-
|
|
520
|
-
// Basic sanity check
|
|
521
|
-
if (hello.msg == 'isdbgrid') {
|
|
522
|
-
throw new ServiceError(
|
|
523
|
-
ErrorCode.PSYNC_S1341,
|
|
524
|
-
'Sharded MongoDB Clusters are not supported yet (including MongoDB Serverless instances).'
|
|
525
|
-
);
|
|
526
|
-
} else if (hello.setName == null) {
|
|
527
|
-
throw new ServiceError(
|
|
528
|
-
ErrorCode.PSYNC_S1342,
|
|
529
|
-
'Standalone MongoDB instances are not supported - use a replicaset.'
|
|
530
|
-
);
|
|
531
|
-
}
|
|
582
|
+
await this.ensureDetected();
|
|
532
583
|
|
|
533
584
|
// Open a change stream just to get a resume token for later use.
|
|
534
585
|
// We could use clusterTime from the hello command, but that won't tell us if the
|
|
@@ -541,14 +592,19 @@ export class MongoSnapshotter {
|
|
|
541
592
|
// Instead, we create a new checkpoint document, and wait until we get that document back in the stream.
|
|
542
593
|
// To avoid potential race conditions with the checkpoint creation, we create a new checkpoint document
|
|
543
594
|
// periodically until the timeout is reached.
|
|
595
|
+
//
|
|
596
|
+
// For the sentinel implementation (DocumentDB) there is no
|
|
597
|
+
// startAtOperationTime: the stream opens from "now" (lsn null) and the
|
|
598
|
+
// retry loop below re-creates checkpoints until one is observed.
|
|
544
599
|
|
|
545
600
|
const LSN_TIMEOUT_SECONDS = 60;
|
|
546
601
|
const LSN_CREATE_INTERVAL_SECONDS = 1;
|
|
547
602
|
|
|
548
|
-
|
|
603
|
+
this.checkpointImplementation.seedPosition(null);
|
|
604
|
+
const startStreamFromLsn = await this.checkpointImplementation.createFirstBarrier();
|
|
549
605
|
const filters = this.getSourceNamespaceFilters();
|
|
550
606
|
const iter = this.rawChangeStreamBatches({
|
|
551
|
-
lsn:
|
|
607
|
+
lsn: startStreamFromLsn,
|
|
552
608
|
maxAwaitTimeMS: 0,
|
|
553
609
|
signal: this.abortSignal,
|
|
554
610
|
filters
|
|
@@ -563,7 +619,7 @@ export class MongoSnapshotter {
|
|
|
563
619
|
break;
|
|
564
620
|
}
|
|
565
621
|
if (performance.now() - lastCheckpointCreated >= LSN_CREATE_INTERVAL_SECONDS * 1000) {
|
|
566
|
-
await
|
|
622
|
+
await this.checkpointImplementation.createBatchCheckpoint();
|
|
567
623
|
lastCheckpointCreated = performance.now();
|
|
568
624
|
}
|
|
569
625
|
batchesSeen += 1;
|
|
@@ -573,14 +629,15 @@ export class MongoSnapshotter {
|
|
|
573
629
|
const ns = 'ns' in changeDocument && 'coll' in changeDocument.ns ? changeDocument.ns : undefined;
|
|
574
630
|
|
|
575
631
|
if (ns?.coll == CHECKPOINTS_COLLECTION && 'documentKey' in changeDocument) {
|
|
576
|
-
const
|
|
577
|
-
if (
|
|
632
|
+
const kind = this.checkpointImplementation.event.observe(changeDocument);
|
|
633
|
+
if (kind != 'own-barrier') {
|
|
634
|
+
// Standalone events still feed the implementation's coordinate via
|
|
635
|
+
// event.observe above; we only resolve on our own barrier.
|
|
578
636
|
continue;
|
|
579
637
|
}
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
}).comparable;
|
|
638
|
+
// Observing our own barrier has set the coordinate (the barrier event
|
|
639
|
+
// carries the sentinel counter directly), so the LSN is ready.
|
|
640
|
+
return this.checkpointImplementation.event.lsn(changeDocument);
|
|
584
641
|
}
|
|
585
642
|
|
|
586
643
|
eventsSeen += 1;
|
|
@@ -638,7 +695,12 @@ export class MongoSnapshotter {
|
|
|
638
695
|
}
|
|
639
696
|
}
|
|
640
697
|
|
|
641
|
-
|
|
698
|
+
// DocumentDB always opens a cluster-level change stream, even in single-database
|
|
699
|
+
// mode, so a coll-only filter would match same-named collections in other
|
|
700
|
+
// databases of the cluster. Filter on the full namespace whenever the stream
|
|
701
|
+
// is cluster-scoped. See ChangeStream.getSourceNamespaceFilters for details.
|
|
702
|
+
const useFullNamespaceFilter = this.isDocumentDb || multipleDatabases;
|
|
703
|
+
const nsFilter = useFullNamespaceFilter
|
|
642
704
|
? { ns: { $in: inFilters } }
|
|
643
705
|
: { 'ns.coll': { $in: inFilters.map((ns) => ns.coll) } };
|
|
644
706
|
if (regexFilters.length > 0) {
|
|
@@ -655,12 +717,15 @@ export class MongoSnapshotter {
|
|
|
655
717
|
signal?: AbortSignal;
|
|
656
718
|
tracer?: PerformanceTracer<'changestream'>;
|
|
657
719
|
}): AsyncIterableIterator<ChangeStreamBatch> {
|
|
658
|
-
const
|
|
659
|
-
const startAfter =
|
|
660
|
-
const resumeAfter =
|
|
720
|
+
const position = options.lsn ? this.checkpointImplementation.parseResumePosition(options.lsn) : null;
|
|
721
|
+
const startAfter = position?.startAfter ?? undefined;
|
|
722
|
+
const resumeAfter = position?.resumeAfter ?? undefined;
|
|
661
723
|
|
|
662
724
|
let fullDocument: 'required' | 'updateLookup';
|
|
663
|
-
if (this.
|
|
725
|
+
if (this.isDocumentDb) {
|
|
726
|
+
// DocumentDB does not support changeStreamPreAndPostImages, so 'required' won't work.
|
|
727
|
+
fullDocument = 'updateLookup';
|
|
728
|
+
} else if (this.usePostImages) {
|
|
664
729
|
// 'read_only' or 'auto_configure'
|
|
665
730
|
// Configuration happens during snapshot, or when we see new
|
|
666
731
|
// collections.
|
|
@@ -669,27 +734,32 @@ export class MongoSnapshotter {
|
|
|
669
734
|
fullDocument = 'updateLookup';
|
|
670
735
|
}
|
|
671
736
|
const streamOptions: mongo.ChangeStreamOptions & mongo.Document = {
|
|
672
|
-
showExpandedEvents: true,
|
|
673
737
|
fullDocument
|
|
674
738
|
};
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
];
|
|
739
|
+
if (!this.isDocumentDb) {
|
|
740
|
+
// DocumentDB does not support showExpandedEvents.
|
|
741
|
+
streamOptions.showExpandedEvents = true;
|
|
742
|
+
}
|
|
743
|
+
const pipeline: mongo.Document[] = [{ $changeStream: streamOptions }, { $match: options.filters.$match }];
|
|
744
|
+
if (!this.isDocumentDb) {
|
|
745
|
+
// DocumentDB does not support $changeStreamSplitLargeEvent.
|
|
746
|
+
pipeline.push({ $changeStreamSplitLargeEvent: {} });
|
|
747
|
+
}
|
|
680
748
|
|
|
681
749
|
// Only one of these options can be supplied at a time.
|
|
682
750
|
if (resumeAfter) {
|
|
683
751
|
streamOptions.resumeAfter = resumeAfter;
|
|
684
|
-
} else {
|
|
752
|
+
} else if (startAfter != null) {
|
|
685
753
|
// Legacy: We don't persist lsns without resumeTokens anymore, but we do still handle the
|
|
686
|
-
// case if we have an old one.
|
|
754
|
+
// case if we have an old one. The sentinel implementation never produces a startAfter,
|
|
755
|
+
// and a fresh DocumentDB stream opens from "now" with neither option set.
|
|
687
756
|
streamOptions.startAtOperationTime = startAfter;
|
|
688
757
|
}
|
|
689
758
|
|
|
690
759
|
let watchDb: mongo.Db;
|
|
691
|
-
if (options.filters.multipleDatabases) {
|
|
692
|
-
//
|
|
760
|
+
if (this.isDocumentDb || options.filters.multipleDatabases) {
|
|
761
|
+
// DocumentDB only supports cluster-level change streams.
|
|
762
|
+
// Requires readAnyDatabase@admin on Atlas.
|
|
693
763
|
watchDb = this.client.db('admin');
|
|
694
764
|
streamOptions.allChangesForCluster = true;
|
|
695
765
|
} else {
|
|
@@ -697,9 +767,14 @@ export class MongoSnapshotter {
|
|
|
697
767
|
watchDb = this.defaultDb;
|
|
698
768
|
}
|
|
699
769
|
|
|
770
|
+
const maxAwaitTimeMS = options.maxAwaitTimeMS ?? this.maxAwaitTimeMS;
|
|
771
|
+
|
|
700
772
|
return rawChangeStream(watchDb, pipeline, {
|
|
701
773
|
batchSize: options.batchSize ?? this.snapshotChunkLength,
|
|
702
|
-
maxAwaitTimeMS
|
|
774
|
+
maxAwaitTimeMS,
|
|
775
|
+
// maxAwaitTimeMS can be 0 for probe-style streams that do not want an idle wait.
|
|
776
|
+
// In that case there is no client-side wait to emulate for DocumentDB.
|
|
777
|
+
clientSideMaxAwaitTimeMS: this.isDocumentDb && maxAwaitTimeMS > 0,
|
|
703
778
|
maxTimeMS: this.changeStreamTimeout,
|
|
704
779
|
signal: options.signal,
|
|
705
780
|
logger: this.logger,
|
|
@@ -6,18 +6,36 @@ import {
|
|
|
6
6
|
ReplicationAssertionError
|
|
7
7
|
} from '@powersync/lib-services-framework';
|
|
8
8
|
import { PerformanceTracer } from '@powersync/service-core';
|
|
9
|
+
import { performance } from 'node:perf_hooks';
|
|
10
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
9
11
|
import { ChangeStreamInvalidatedError } from './ChangeStream.js';
|
|
10
12
|
|
|
13
|
+
// Keep the DocumentDB idle-poll workaround from adding the full maxAwaitTimeMS
|
|
14
|
+
// as local latency when an update arrives just after an empty batch.
|
|
15
|
+
const CLIENT_SIDE_MAX_AWAIT_TIME_MS_DELAY_CAP_MS = 1_000;
|
|
16
|
+
|
|
11
17
|
export interface RawChangeStreamOptions {
|
|
12
18
|
signal?: AbortSignal;
|
|
13
19
|
|
|
14
20
|
/**
|
|
15
21
|
* How long to wait for new data per batch (max time for long-polling).
|
|
22
|
+
* This is sent as maxTimeMS for the getMore command.
|
|
16
23
|
*
|
|
17
|
-
*
|
|
24
|
+
* A value of 0 is allowed for probe-style streams that do not want an idle
|
|
25
|
+
* wait; in that case PowerSync also skips the local empty-batch delay.
|
|
18
26
|
*/
|
|
19
27
|
maxAwaitTimeMS: number;
|
|
20
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Also enforce maxAwaitTimeMS on the client for empty getMore batches.
|
|
31
|
+
*
|
|
32
|
+
* Azure DocumentDB currently returns idle getMore calls before maxTimeMS. When
|
|
33
|
+
* this is enabled, empty batches are delayed locally (capped at 1s) to avoid
|
|
34
|
+
* tight polling. We still send maxTimeMS so this remains compatible with
|
|
35
|
+
* servers that handle maxAwaitTimeMS correctly.
|
|
36
|
+
*/
|
|
37
|
+
clientSideMaxAwaitTimeMS?: boolean;
|
|
38
|
+
|
|
21
39
|
/**
|
|
22
40
|
* Timeout for the initial aggregate command.
|
|
23
41
|
*/
|
|
@@ -212,31 +230,32 @@ async function* rawChangeStreamInner(
|
|
|
212
230
|
options.signal?.throwIfAborted();
|
|
213
231
|
|
|
214
232
|
using commandSpan = options.tracer?.span('changestream', 'getmore');
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
throw new ResumableChangeStreamError(e.message, { cause: e });
|
|
233
|
+
const getMoreStartedAt = performance.now();
|
|
234
|
+
const getMoreCommand: mongo.Document = {
|
|
235
|
+
getMore: cursorId,
|
|
236
|
+
collection: nsCollection,
|
|
237
|
+
batchSize: batchSizer.next(),
|
|
238
|
+
maxTimeMS: options.maxAwaitTimeMS
|
|
239
|
+
};
|
|
240
|
+
// Azure DocumentDB currently returns empty getMore batches before
|
|
241
|
+
// maxTimeMS expires. Keep maxTimeMS for forward compatibility with the
|
|
242
|
+
// server-side behavior, and when client-side mode is enabled, enforce the
|
|
243
|
+
// capped idle wait locally for empty batches below.
|
|
244
|
+
const getMoreResult: mongo.Document = await db.command(getMoreCommand, { session, raw: true }).catch((e) => {
|
|
245
|
+
if (isMongoServerError(e) && e.codeName == 'CursorKilled') {
|
|
246
|
+
// This may be due to the killCursors command issued when aborting.
|
|
247
|
+
// In that case, use the abort error instead.
|
|
248
|
+
options.signal?.throwIfAborted();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (isResumableChangeStreamError(e)) {
|
|
252
|
+
if (isTimeoutError(e)) {
|
|
253
|
+
batchSizer.reduceAfterError();
|
|
237
254
|
}
|
|
238
|
-
throw
|
|
239
|
-
}
|
|
255
|
+
throw new ResumableChangeStreamError(e.message, { cause: e });
|
|
256
|
+
}
|
|
257
|
+
throw mapChangeStreamError(e);
|
|
258
|
+
});
|
|
240
259
|
|
|
241
260
|
commandSpan?.end();
|
|
242
261
|
|
|
@@ -249,6 +268,13 @@ async function* rawChangeStreamInner(
|
|
|
249
268
|
// postBatchResumeToken is returned in MongoDB 4.0.7 and later, and we support 6.0+
|
|
250
269
|
throw new ReplicationAssertionError(`postBatchResumeToken from aggregate response`);
|
|
251
270
|
}
|
|
271
|
+
if (options.clientSideMaxAwaitTimeMS && nextBatch.length == 0) {
|
|
272
|
+
const remainingMaxAwaitTimeMS = Math.ceil(options.maxAwaitTimeMS - (performance.now() - getMoreStartedAt));
|
|
273
|
+
if (remainingMaxAwaitTimeMS > 0) {
|
|
274
|
+
const clientSideDelayMs = Math.min(remainingMaxAwaitTimeMS, CLIENT_SIDE_MAX_AWAIT_TIME_MS_DELAY_CAP_MS);
|
|
275
|
+
await delay(clientSideDelayMs, undefined, { signal: options.signal });
|
|
276
|
+
}
|
|
277
|
+
}
|
|
252
278
|
yield {
|
|
253
279
|
events: nextBatch,
|
|
254
280
|
resumeToken: cursor.postBatchResumeToken,
|
|
@@ -400,8 +426,18 @@ export function parseChangeDocument(buffer: Buffer): ProjectedChangeStreamDocume
|
|
|
400
426
|
return doc as any;
|
|
401
427
|
}
|
|
402
428
|
|
|
429
|
+
function isMaxTimeMSExpiredError(e: unknown) {
|
|
430
|
+
return (
|
|
431
|
+
isMongoServerError(e) &&
|
|
432
|
+
(e.codeName == 'MaxTimeMSExpired' ||
|
|
433
|
+
// MongoDB documents code 50 as MaxTimeMSExpired. Azure DocumentDB reports
|
|
434
|
+
// this code with codeName ExceededTimeLimit.
|
|
435
|
+
e.code == 50)
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
|
|
403
439
|
function isTimeoutError(e: unknown) {
|
|
404
|
-
return isMongoNetworkTimeoutError(e) || (
|
|
440
|
+
return isMongoNetworkTimeoutError(e) || isMaxTimeMSExpiredError(e);
|
|
405
441
|
}
|
|
406
442
|
|
|
407
443
|
function isResumableChangeStreamError(e: unknown) {
|
|
@@ -415,8 +451,8 @@ function isResumableChangeStreamError(e: unknown) {
|
|
|
415
451
|
} else if (e.hasErrorLabel('ResumableChangeStreamError')) {
|
|
416
452
|
// For servers with wire version 9 or higher (server version 4.4 or higher), any server error with the ResumableChangeStreamError error label.
|
|
417
453
|
return true;
|
|
418
|
-
} else if (e
|
|
419
|
-
// Our own exception for
|
|
454
|
+
} else if (isMaxTimeMSExpiredError(e)) {
|
|
455
|
+
// Our own exception for maxTimeMS timeouts.
|
|
420
456
|
// This can help us retry faster, with a smaller batch size (if initialBatchSize is set to 1), which should hopefully avoid the timeout.
|
|
421
457
|
return true;
|
|
422
458
|
} else {
|