@powersync/service-module-mongodb 0.18.1 → 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 +34 -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 +9 -9
- 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 +103 -68
- package/test/src/change_stream_utils.ts +85 -9
- 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
|
@@ -0,0 +1,1040 @@
|
|
|
1
|
+
import { setTimeout } from 'node:timers/promises';
|
|
2
|
+
import { describe, expect, test, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { createWriteCheckpoint, WriteCheckpointBatcher } from '@powersync/service-core';
|
|
5
|
+
import { test_utils } from '@powersync/service-core-tests';
|
|
6
|
+
|
|
7
|
+
import { MongoRouteAPIAdapter } from '@module/api/MongoRouteAPIAdapter.js';
|
|
8
|
+
import { SentinelLSN } from '@module/common/SentinelLSN.js';
|
|
9
|
+
import { createSentinelCheckpointLsn, SENTINEL_CHECKPOINT_ID } from '@module/replication/MongoRelation.js';
|
|
10
|
+
import { CHECKPOINTS_COLLECTION } from '@module/replication/replication-utils.js';
|
|
11
|
+
import { mongo } from '@powersync/lib-service-mongodb';
|
|
12
|
+
import { ChangeStreamTestContext } from './change_stream_utils.js';
|
|
13
|
+
import { DATABASE_TYPE, DatabaseType } from './DatabaseType.js';
|
|
14
|
+
import { env } from './env.js';
|
|
15
|
+
import { testTimeout } from './test-timeouts.js';
|
|
16
|
+
import { connectMongoData, describeWithStorage, StorageVersionTestContext, TEST_CONNECTION_OPTIONS } from './util.js';
|
|
17
|
+
|
|
18
|
+
const BASIC_SYNC_RULES = `
|
|
19
|
+
bucket_definitions:
|
|
20
|
+
global:
|
|
21
|
+
data:
|
|
22
|
+
- SELECT _id as id, description FROM "test_data"
|
|
23
|
+
`;
|
|
24
|
+
|
|
25
|
+
const DOCUMENTDB_BUCKET_DATA_TIMEOUT = testTimeout(20_000, { cloudOverride: 120_000 });
|
|
26
|
+
const DOCUMENTDB_LARGE_BUCKET_DATA_TIMEOUT = testTimeout(60_000, { cloudOverride: 180_000 });
|
|
27
|
+
const DOCUMENTDB_LARGE_DOCUMENT_TIMEOUT = testTimeout(120_000, { cloudOverride: 360_000 });
|
|
28
|
+
const DOCUMENTDB_LARGE_DOCUMENT_MAX_AWAIT_TIME_MS = testTimeout(10_000, { cloudOverride: 30_000 });
|
|
29
|
+
const DOCUMENTDB_RESTART_TIMEOUT = testTimeout(50_000, { cloudOverride: 120_000 });
|
|
30
|
+
|
|
31
|
+
// These tests require a real DocumentDB cluster. See test/DOCUMENTDB_TESTING.md for setup.
|
|
32
|
+
//
|
|
33
|
+
// Why these can't run against standard MongoDB: the DocumentDB workarounds involve
|
|
34
|
+
// different change stream initialization ordering (lazy ChangeStream + no
|
|
35
|
+
// startAtOperationTime) and wall-clock LSN precision (increment 0 instead of
|
|
36
|
+
// operationTime's real increments). These produce LSN comparison failures when
|
|
37
|
+
// mixed with standard MongoDB's operationTime-based checkpoints. A test flag that
|
|
38
|
+
// partially simulates DocumentDB creates more problems than it solves — see the
|
|
39
|
+
// commit history on the cosmos branch for the full investigation.
|
|
40
|
+
describe.skipIf(DATABASE_TYPE != DatabaseType.DOCUMENTDB)('documentDbMode', () => {
|
|
41
|
+
test('prints hello response and detects DocumentDB', async () => {
|
|
42
|
+
const { client, db } = await connectMongoData();
|
|
43
|
+
try {
|
|
44
|
+
const hello = await db.command({ hello: 1 });
|
|
45
|
+
console.dir({ hello }, { depth: null });
|
|
46
|
+
expect(hello.internal?.documentdb_versions != null).toBe(true);
|
|
47
|
+
} finally {
|
|
48
|
+
await client.close();
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Verifies a core assumption of the DocumentDB sentinel LSN design: that
|
|
53
|
+
// `fullDocument` on update events is the write-time post-image, not a
|
|
54
|
+
// read-time lookup of the document's current state.
|
|
55
|
+
//
|
|
56
|
+
// SentinelCheckpointImplementation tracks its position from `fullDocument.i` on
|
|
57
|
+
// updates to the sentinel checkpoint document, and commits LSNs based on
|
|
58
|
+
// that value. With read-time (updateLookup) semantics, a backlogged stream
|
|
59
|
+
// reading an old event would see a *future* counter value, letting the
|
|
60
|
+
// committed LSN run ahead of unprocessed data events — which would allow
|
|
61
|
+
// write checkpoints to resolve before the caller's write has replicated.
|
|
62
|
+
//
|
|
63
|
+
// The test bursts increments while no cursor is reading, then replays the
|
|
64
|
+
// events. Write-time semantics yield one event per increment, each carrying
|
|
65
|
+
// its own value. Read-time semantics yield events all reporting the final
|
|
66
|
+
// value. Collapsed/missing events would indicate DocumentDB coalesces rapid
|
|
67
|
+
// same-document updates, which would be its own significant finding.
|
|
68
|
+
test('fullDocument on update events is the write-time post-image', { timeout: 120_000 }, async () => {
|
|
69
|
+
const { client, db } = await connectMongoData();
|
|
70
|
+
try {
|
|
71
|
+
const collection = db.collection<{ _id: string; i: number }>(CHECKPOINTS_COLLECTION);
|
|
72
|
+
const docId = '_test_fulldoc_semantics';
|
|
73
|
+
|
|
74
|
+
const pipeline = [
|
|
75
|
+
{
|
|
76
|
+
$match: {
|
|
77
|
+
operationType: 'update',
|
|
78
|
+
'ns.db': db.databaseName,
|
|
79
|
+
'ns.coll': CHECKPOINTS_COLLECTION,
|
|
80
|
+
'documentKey._id': docId
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
// Establish a resume position past the priming writes, so the replay
|
|
86
|
+
// below starts cleanly before the burst.
|
|
87
|
+
//
|
|
88
|
+
// The stream only delivers events written after the cursor is
|
|
89
|
+
// established (DocumentDB has no startAtOperationTime), and the driver opens
|
|
90
|
+
// the cursor lazily on the first read. So keep writing priming
|
|
91
|
+
// increments until the first event comes through — the same approach the
|
|
92
|
+
// production retry loop uses during initial LSN acquisition.
|
|
93
|
+
let resumeToken: unknown;
|
|
94
|
+
const before = client.watch(pipeline, { fullDocument: 'updateLookup', maxAwaitTimeMS: 500 });
|
|
95
|
+
try {
|
|
96
|
+
const deadline = Date.now() + 60_000;
|
|
97
|
+
let lastPrimedI = 0;
|
|
98
|
+
let drainedTo = -1;
|
|
99
|
+
while (drainedTo < 0 && Date.now() < deadline) {
|
|
100
|
+
const result = await collection.findOneAndUpdate(
|
|
101
|
+
{ _id: docId },
|
|
102
|
+
{ $inc: { i: 1 } },
|
|
103
|
+
{ upsert: true, returnDocument: 'after' }
|
|
104
|
+
);
|
|
105
|
+
lastPrimedI = result!.i;
|
|
106
|
+
const event = await before.tryNext();
|
|
107
|
+
if (event != null && 'fullDocument' in event) {
|
|
108
|
+
drainedTo = event.fullDocument?.i ?? -1;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
expect(drainedTo, 'change stream never delivered the priming events').toBeGreaterThan(0);
|
|
112
|
+
|
|
113
|
+
// Drain the remaining priming events so they don't leak into the
|
|
114
|
+
// replay below.
|
|
115
|
+
while (drainedTo < lastPrimedI && Date.now() < deadline) {
|
|
116
|
+
const event = await before.tryNext();
|
|
117
|
+
if (event != null && 'fullDocument' in event) {
|
|
118
|
+
drainedTo = event.fullDocument?.i ?? drainedTo;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
expect(drainedTo, 'timed out draining priming events').toEqual(lastPrimedI);
|
|
122
|
+
|
|
123
|
+
resumeToken = before.resumeToken;
|
|
124
|
+
expect(resumeToken).toBeTruthy();
|
|
125
|
+
} finally {
|
|
126
|
+
await before.close();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Burst increments with no cursor reading. All writes complete before
|
|
130
|
+
// any event is fetched, so a read-time lookup cannot accidentally
|
|
131
|
+
// return event-time values.
|
|
132
|
+
const expected: number[] = [];
|
|
133
|
+
for (let k = 0; k < 5; k++) {
|
|
134
|
+
const result = await collection.findOneAndUpdate(
|
|
135
|
+
{ _id: docId },
|
|
136
|
+
{ $inc: { i: 1 } },
|
|
137
|
+
{ upsert: true, returnDocument: 'after' }
|
|
138
|
+
);
|
|
139
|
+
expected.push(result!.i);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Replay the burst and record the value each event carries.
|
|
143
|
+
const seen: number[] = [];
|
|
144
|
+
const after = client.watch(pipeline, {
|
|
145
|
+
fullDocument: 'updateLookup',
|
|
146
|
+
maxAwaitTimeMS: 500,
|
|
147
|
+
resumeAfter: resumeToken
|
|
148
|
+
});
|
|
149
|
+
try {
|
|
150
|
+
const start = Date.now();
|
|
151
|
+
const deadline = start + 60_000;
|
|
152
|
+
while (seen.length < expected.length && Date.now() < deadline) {
|
|
153
|
+
const event = await after.tryNext();
|
|
154
|
+
if (event != null && 'fullDocument' in event) {
|
|
155
|
+
seen.push(event.fullDocument?.i);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
} finally {
|
|
159
|
+
await after.close();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
expect(seen).toEqual(expected);
|
|
163
|
+
} finally {
|
|
164
|
+
await client.close();
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// Empirically probe whether DocumentDB delivers change events for *different*
|
|
169
|
+
// documents in the order they were written.
|
|
170
|
+
//
|
|
171
|
+
// DocumentDB only guarantees change order per shard key (RU docs) / nothing
|
|
172
|
+
// documented (vCore) — not globally. The sentinel write-checkpoint design
|
|
173
|
+
// assumes no cross-document order: a write checkpoint head is the
|
|
174
|
+
// `_sentinel_checkpoint` counter, and the caller's data write lives in a
|
|
175
|
+
// different document. If the sentinel event can be delivered *before* the
|
|
176
|
+
// data write that preceded it, a write checkpoint can resolve before its data
|
|
177
|
+
// has replicated (see docs/documentdb/documentdb-lsn-sentinel-checkpoints.md#change-ordering).
|
|
178
|
+
//
|
|
179
|
+
// This reproduces that exact scenario using the real checkpointing write path
|
|
180
|
+
// (createSentinelCheckpointLsn): each round writes a data document, then bumps
|
|
181
|
+
// the sentinel counter, and we record the delivery order off a single
|
|
182
|
+
// cluster-level change stream.
|
|
183
|
+
//
|
|
184
|
+
// We do NOT assert on cross-document order (delivery order is not contractual
|
|
185
|
+
// — a single-shard cluster may happen to preserve it); the finding is logged
|
|
186
|
+
// so we can document the cluster's observed behaviour. We DO assert that
|
|
187
|
+
// same-document order is preserved, since sentinel `i` matching relies on it.
|
|
188
|
+
test.skipIf(!env.SLOW_TESTS)('change stream delivery order across documents', { timeout: 30_000 }, async () => {
|
|
189
|
+
const { client, db } = await connectMongoData();
|
|
190
|
+
const ROUNDS = 20;
|
|
191
|
+
const dataColl = 'order_probe_data';
|
|
192
|
+
const dataId = '_order_probe_doc';
|
|
193
|
+
try {
|
|
194
|
+
const data = db.collection<{ _id: string; seq: number }>(dataColl);
|
|
195
|
+
await data.deleteMany({});
|
|
196
|
+
|
|
197
|
+
// Watch the whole database; classify events by documentKey in code.
|
|
198
|
+
const pipeline = [
|
|
199
|
+
{
|
|
200
|
+
$match: {
|
|
201
|
+
operationType: { $in: ['insert', 'update', 'replace'] },
|
|
202
|
+
'ns.db': db.databaseName
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
];
|
|
206
|
+
|
|
207
|
+
const cursor = client.watch(pipeline, { fullDocument: 'updateLookup', maxAwaitTimeMS: 500 });
|
|
208
|
+
try {
|
|
209
|
+
// Prime the cursor (DocumentDB has no startAtOperationTime, and the driver
|
|
210
|
+
// opens the cursor lazily) by writing the data doc with descending
|
|
211
|
+
// negative seqs until an event comes through, then drain the backlog.
|
|
212
|
+
const primeDeadline = Date.now() + 60_000;
|
|
213
|
+
let primed = false;
|
|
214
|
+
let primeSeq = 0;
|
|
215
|
+
while (!primed && Date.now() < primeDeadline) {
|
|
216
|
+
primeSeq -= 1;
|
|
217
|
+
await data.updateOne({ _id: dataId }, { $set: { seq: primeSeq } }, { upsert: true });
|
|
218
|
+
if ((await cursor.tryNext()) != null) {
|
|
219
|
+
primed = true;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
expect(primed, 'change stream cursor never went live').toBe(true);
|
|
223
|
+
while ((await cursor.tryNext()) != null) {
|
|
224
|
+
// drain remaining priming events
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Issue interleaved writes: data(round) then sentinel bump, per round.
|
|
228
|
+
type Issued = { label: string; kind: 'data' | 'sentinel'; round: number; sentinel?: bigint };
|
|
229
|
+
const issued: Issued[] = [];
|
|
230
|
+
const sentinelValueToRound = new Map<string, number>();
|
|
231
|
+
for (let round = 1; round <= ROUNDS; round++) {
|
|
232
|
+
await data.updateOne({ _id: dataId }, { $set: { seq: round } });
|
|
233
|
+
issued.push({ label: `D${round}`, kind: 'data', round });
|
|
234
|
+
|
|
235
|
+
const sentinel = SentinelLSN.fromSerialized(await createSentinelCheckpointLsn(client, db)).sentinel;
|
|
236
|
+
issued.push({ label: `S${round}`, kind: 'sentinel', round, sentinel });
|
|
237
|
+
sentinelValueToRound.set(sentinel.toString(), round);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Collect the delivered events in arrival order.
|
|
241
|
+
const arrivals: { label: string; kind: 'data' | 'sentinel'; value: number | bigint }[] = [];
|
|
242
|
+
const collectDeadline = Date.now() + 90_000;
|
|
243
|
+
while (arrivals.length < issued.length && Date.now() < collectDeadline) {
|
|
244
|
+
const ev = await cursor.tryNext();
|
|
245
|
+
if (ev == null || !('documentKey' in ev) || !('fullDocument' in ev) || ev.fullDocument == null) {
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
const id = String(ev.documentKey._id);
|
|
249
|
+
if (id === dataId) {
|
|
250
|
+
const seq = (ev.fullDocument as any).seq as number;
|
|
251
|
+
if (seq >= 1) {
|
|
252
|
+
arrivals.push({ label: `D${seq}`, kind: 'data', value: seq });
|
|
253
|
+
}
|
|
254
|
+
} else if (id === SENTINEL_CHECKPOINT_ID) {
|
|
255
|
+
const value = BigInt((ev.fullDocument as any).i);
|
|
256
|
+
const round = sentinelValueToRound.get(value.toString());
|
|
257
|
+
if (round != null) {
|
|
258
|
+
arrivals.push({ label: `S${round}`, kind: 'sentinel', value });
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// --- Analysis ---
|
|
264
|
+
const dataArrivals = arrivals.filter((a) => a.kind === 'data').map((a) => a.value as number);
|
|
265
|
+
const sentinelArrivals = arrivals.filter((a) => a.kind === 'sentinel').map((a) => a.value as bigint);
|
|
266
|
+
const dataOrderPreserved = dataArrivals.every((v, i) => i === 0 || v > dataArrivals[i - 1]);
|
|
267
|
+
const sentinelOrderPreserved = sentinelArrivals.every((v, i) => i === 0 || v > sentinelArrivals[i - 1]);
|
|
268
|
+
|
|
269
|
+
// General cross-document inversions: any event delivered out of global issue order.
|
|
270
|
+
const issueIndex = new Map(issued.map((it, i) => [it.label, i] as const));
|
|
271
|
+
const crossDocumentInversions: { afterDelivering: string; received: string }[] = [];
|
|
272
|
+
for (let i = 1; i < arrivals.length; i++) {
|
|
273
|
+
if (issueIndex.get(arrivals[i].label)! < issueIndex.get(arrivals[i - 1].label)!) {
|
|
274
|
+
crossDocumentInversions.push({ afterDelivering: arrivals[i - 1].label, received: arrivals[i].label });
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Bug-specific signal: a sentinel bump overtaking the data write that
|
|
279
|
+
// preceded it in the same round (write checkpoint resolves too early).
|
|
280
|
+
const sentinelOvertakingItsDataWrite: number[] = [];
|
|
281
|
+
for (let round = 1; round <= ROUNDS; round++) {
|
|
282
|
+
const dIdx = arrivals.findIndex((a) => a.label === `D${round}`);
|
|
283
|
+
const sIdx = arrivals.findIndex((a) => a.label === `S${round}`);
|
|
284
|
+
if (dIdx >= 0 && sIdx >= 0 && sIdx < dIdx) {
|
|
285
|
+
sentinelOvertakingItsDataWrite.push(round);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
console.dir(
|
|
290
|
+
{
|
|
291
|
+
crossDocumentOrderingProbe: {
|
|
292
|
+
rounds: ROUNDS,
|
|
293
|
+
issued: issued.length,
|
|
294
|
+
arrived: arrivals.length,
|
|
295
|
+
deliveryOrder: arrivals.map((a) => a.label),
|
|
296
|
+
dataOrderPreserved,
|
|
297
|
+
sentinelOrderPreserved,
|
|
298
|
+
crossDocumentInversions,
|
|
299
|
+
sentinelOvertakingItsDataWrite
|
|
300
|
+
}
|
|
301
|
+
},
|
|
302
|
+
{ depth: null }
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
// We expect to receive every issued event within the deadline.
|
|
306
|
+
expect(arrivals.length, 'did not receive all issued events before the deadline').toBe(issued.length);
|
|
307
|
+
// Per-document ordering is the one guarantee DocumentDB documents — assert it.
|
|
308
|
+
expect(dataOrderPreserved, 'data-document events arrived out of order').toBe(true);
|
|
309
|
+
expect(sentinelOrderPreserved, 'sentinel-document events arrived out of order').toBe(true);
|
|
310
|
+
// Cross-document order is intentionally NOT asserted; inversions (if any)
|
|
311
|
+
// are logged above as the finding.
|
|
312
|
+
} finally {
|
|
313
|
+
await cursor.close();
|
|
314
|
+
}
|
|
315
|
+
await data.deleteMany({});
|
|
316
|
+
} finally {
|
|
317
|
+
await client.close();
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// Follow-up to the probe above. The sequential probe preserves order on a
|
|
322
|
+
// single-shard cluster, where there is effectively one ordered feed. This
|
|
323
|
+
// variant adds a concurrent write storm across many other documents and a much
|
|
324
|
+
// higher round count, to test whether a single-shard cluster has *internal*
|
|
325
|
+
// sub-partitions whose feeds can interleave out of order under load.
|
|
326
|
+
//
|
|
327
|
+
// The measured D→S pairs are still issued strictly sequentially (so they keep a
|
|
328
|
+
// well-defined happens-before order to violate); the background writers only
|
|
329
|
+
// generate feed pressure. Same reporting; cross-document order is logged, not
|
|
330
|
+
// asserted.
|
|
331
|
+
test.skipIf(!env.SLOW_TESTS)(
|
|
332
|
+
'change stream delivery order across documents — under concurrent load',
|
|
333
|
+
{ timeout: 240_000 },
|
|
334
|
+
async () => {
|
|
335
|
+
const { client, db } = await connectMongoData();
|
|
336
|
+
const ROUNDS = 100;
|
|
337
|
+
const BACKGROUND_WRITERS = 6;
|
|
338
|
+
const dataColl = 'order_probe_data';
|
|
339
|
+
const dataId = '_order_probe_doc';
|
|
340
|
+
const backgroundCollections = Array.from({ length: BACKGROUND_WRITERS }, (_, w) => `order_probe_bg_${w}`);
|
|
341
|
+
try {
|
|
342
|
+
const data = db.collection<{ _id: string; seq: number }>(dataColl);
|
|
343
|
+
await data.deleteMany({});
|
|
344
|
+
|
|
345
|
+
// Only deliver the two measured namespaces, so background churn does not
|
|
346
|
+
// flood the cursor we are measuring.
|
|
347
|
+
const pipeline = [
|
|
348
|
+
{
|
|
349
|
+
$match: {
|
|
350
|
+
operationType: { $in: ['insert', 'update', 'replace'] },
|
|
351
|
+
'ns.db': db.databaseName,
|
|
352
|
+
'ns.coll': { $in: [dataColl, CHECKPOINTS_COLLECTION] }
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
];
|
|
356
|
+
const cursor = client.watch(pipeline, { fullDocument: 'updateLookup', maxAwaitTimeMS: 500 });
|
|
357
|
+
|
|
358
|
+
// Start the background write storm: 6 loops, each with one write in flight,
|
|
359
|
+
// churning 100 rotating documents per collection.
|
|
360
|
+
let stopBackground = false;
|
|
361
|
+
const background = backgroundCollections.map((name) =>
|
|
362
|
+
(async () => {
|
|
363
|
+
const coll = db.collection<{ _id: string; n: number }>(name);
|
|
364
|
+
let n = 0;
|
|
365
|
+
while (!stopBackground) {
|
|
366
|
+
n += 1;
|
|
367
|
+
await coll.updateOne({ _id: `bg_${n % 100}` }, { $set: { n } }, { upsert: true }).catch(() => {});
|
|
368
|
+
}
|
|
369
|
+
})()
|
|
370
|
+
);
|
|
371
|
+
|
|
372
|
+
try {
|
|
373
|
+
// Prime the cursor.
|
|
374
|
+
const primeDeadline = Date.now() + 60_000;
|
|
375
|
+
let primed = false;
|
|
376
|
+
let primeSeq = 0;
|
|
377
|
+
while (!primed && Date.now() < primeDeadline) {
|
|
378
|
+
primeSeq -= 1;
|
|
379
|
+
await data.updateOne({ _id: dataId }, { $set: { seq: primeSeq } }, { upsert: true });
|
|
380
|
+
if ((await cursor.tryNext()) != null) {
|
|
381
|
+
primed = true;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
expect(primed, 'change stream cursor never went live').toBe(true);
|
|
385
|
+
while ((await cursor.tryNext()) != null) {
|
|
386
|
+
// drain priming events
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Measured sequential D→S pairs.
|
|
390
|
+
const issued: { label: string; round: number }[] = [];
|
|
391
|
+
const sentinelValueToRound = new Map<string, number>();
|
|
392
|
+
for (let round = 1; round <= ROUNDS; round++) {
|
|
393
|
+
await data.updateOne({ _id: dataId }, { $set: { seq: round } });
|
|
394
|
+
issued.push({ label: `D${round}`, round });
|
|
395
|
+
const sentinel = SentinelLSN.fromSerialized(await createSentinelCheckpointLsn(client, db)).sentinel;
|
|
396
|
+
issued.push({ label: `S${round}`, round });
|
|
397
|
+
sentinelValueToRound.set(sentinel.toString(), round);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Collect.
|
|
401
|
+
const arrivals: string[] = [];
|
|
402
|
+
const expectedLabels = new Set(issued.map((i) => i.label));
|
|
403
|
+
const collectDeadline = Date.now() + 120_000;
|
|
404
|
+
while (arrivals.length < issued.length && Date.now() < collectDeadline) {
|
|
405
|
+
const ev = await cursor.tryNext();
|
|
406
|
+
if (ev == null || !('documentKey' in ev) || !('fullDocument' in ev) || ev.fullDocument == null) {
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
const id = String(ev.documentKey._id);
|
|
410
|
+
let label: string | null = null;
|
|
411
|
+
if (id === dataId) {
|
|
412
|
+
const seq = (ev.fullDocument as any).seq as number;
|
|
413
|
+
if (seq >= 1) {
|
|
414
|
+
label = `D${seq}`;
|
|
415
|
+
}
|
|
416
|
+
} else if (id === SENTINEL_CHECKPOINT_ID) {
|
|
417
|
+
const round = sentinelValueToRound.get(BigInt((ev.fullDocument as any).i).toString());
|
|
418
|
+
if (round != null) {
|
|
419
|
+
label = `S${round}`;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
if (label != null && expectedLabels.has(label)) {
|
|
423
|
+
arrivals.push(label);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Analysis (same as the sequential probe).
|
|
428
|
+
const issueIndex = new Map(issued.map((it, i) => [it.label, i] as const));
|
|
429
|
+
const crossDocumentInversions: { afterDelivering: string; received: string }[] = [];
|
|
430
|
+
for (let i = 1; i < arrivals.length; i++) {
|
|
431
|
+
if (issueIndex.get(arrivals[i])! < issueIndex.get(arrivals[i - 1])!) {
|
|
432
|
+
crossDocumentInversions.push({ afterDelivering: arrivals[i - 1], received: arrivals[i] });
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
const sentinelOvertakingItsDataWrite: number[] = [];
|
|
436
|
+
for (let round = 1; round <= ROUNDS; round++) {
|
|
437
|
+
const dIdx = arrivals.indexOf(`D${round}`);
|
|
438
|
+
const sIdx = arrivals.indexOf(`S${round}`);
|
|
439
|
+
if (dIdx >= 0 && sIdx >= 0 && sIdx < dIdx) {
|
|
440
|
+
sentinelOvertakingItsDataWrite.push(round);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
console.dir(
|
|
445
|
+
{
|
|
446
|
+
crossDocumentOrderingUnderLoad: {
|
|
447
|
+
rounds: ROUNDS,
|
|
448
|
+
backgroundWriters: BACKGROUND_WRITERS,
|
|
449
|
+
issued: issued.length,
|
|
450
|
+
arrived: arrivals.length,
|
|
451
|
+
crossDocumentInversions,
|
|
452
|
+
sentinelOvertakingItsDataWrite
|
|
453
|
+
}
|
|
454
|
+
},
|
|
455
|
+
{ depth: null }
|
|
456
|
+
);
|
|
457
|
+
|
|
458
|
+
expect(arrivals.length, 'did not receive all issued events before the deadline').toBe(issued.length);
|
|
459
|
+
} finally {
|
|
460
|
+
stopBackground = true;
|
|
461
|
+
await Promise.allSettled(background);
|
|
462
|
+
await cursor.close();
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
await data.deleteMany({});
|
|
466
|
+
for (const name of backgroundCollections) {
|
|
467
|
+
await db
|
|
468
|
+
.collection(name)
|
|
469
|
+
.drop()
|
|
470
|
+
.catch(() => {});
|
|
471
|
+
}
|
|
472
|
+
} finally {
|
|
473
|
+
await client.close();
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
);
|
|
477
|
+
|
|
478
|
+
// Characterize whether DocumentDB's change stream reports collection `drop` and
|
|
479
|
+
// `rename` DDL events. PowerSync does not currently replicate these on
|
|
480
|
+
// DocumentDB (see docs/documentdb/documentdb-limitations.md, "Collection drop
|
|
481
|
+
// and rename are not replicated") — they are delivered differently, or not at
|
|
482
|
+
// all, through the cluster-level change stream.
|
|
483
|
+
//
|
|
484
|
+
// After the DDL, a normal insert (the "marker") is written and we wait for its
|
|
485
|
+
// event to arrive. That proves the stream is live and has caught up past the
|
|
486
|
+
// DDL, so the absence of `drop`/`rename` events is genuine, not just
|
|
487
|
+
// not-waited-long-enough. The test then asserts those events are NOT delivered,
|
|
488
|
+
// documenting the current limitation. If a future DocumentDB engine starts
|
|
489
|
+
// delivering them, this test fails — a signal to add real drop/rename support.
|
|
490
|
+
test('does not report collection drop and rename events', { timeout: 120_000 }, async () => {
|
|
491
|
+
const { client, db } = await connectMongoData();
|
|
492
|
+
const dropColl = 'ddl_probe_drop';
|
|
493
|
+
const renameSrc = 'ddl_probe_rename_src';
|
|
494
|
+
const renameDst = 'ddl_probe_rename_dst';
|
|
495
|
+
const markerColl = 'ddl_probe_marker';
|
|
496
|
+
const markerId = 'ddl_probe_marker_doc';
|
|
497
|
+
const cleanup = async () => {
|
|
498
|
+
for (const c of [dropColl, renameSrc, renameDst, markerColl]) {
|
|
499
|
+
await db
|
|
500
|
+
.collection(c)
|
|
501
|
+
.drop()
|
|
502
|
+
.catch(() => {});
|
|
503
|
+
}
|
|
504
|
+
};
|
|
505
|
+
try {
|
|
506
|
+
await cleanup();
|
|
507
|
+
|
|
508
|
+
// Cluster-level stream (DocumentDB only supports cluster-level), watching
|
|
509
|
+
// the whole database so DDL events on any collection are visible.
|
|
510
|
+
const pipeline = [{ $match: { 'ns.db': db.databaseName } }];
|
|
511
|
+
const cursor = client.watch(pipeline, { fullDocument: 'updateLookup', maxAwaitTimeMS: 500 });
|
|
512
|
+
try {
|
|
513
|
+
// Prime the cursor so it is live before the DDL operations.
|
|
514
|
+
const primeDeadline = Date.now() + 60_000;
|
|
515
|
+
let primed = false;
|
|
516
|
+
let n = 0;
|
|
517
|
+
while (!primed && Date.now() < primeDeadline) {
|
|
518
|
+
n += 1;
|
|
519
|
+
await db.collection(renameSrc).updateOne({ _id: 'prime' as any }, { $set: { n } }, { upsert: true });
|
|
520
|
+
if ((await cursor.tryNext()) != null) {
|
|
521
|
+
primed = true;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
expect(primed, 'change stream cursor never went live').toBe(true);
|
|
525
|
+
while ((await cursor.tryNext()) != null) {
|
|
526
|
+
// drain priming events
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Perform the DDL: drop one collection, rename another.
|
|
530
|
+
await db.collection(dropColl).insertOne({ x: 1 });
|
|
531
|
+
await db.collection(dropColl).drop();
|
|
532
|
+
await client.db('admin').command({
|
|
533
|
+
renameCollection: `${db.databaseName}.${renameSrc}`,
|
|
534
|
+
to: `${db.databaseName}.${renameDst}`
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
// Marker: a normal insert issued *after* the DDL. When its event is
|
|
538
|
+
// delivered, the stream has provably caught up past the DDL operations.
|
|
539
|
+
await db.collection(markerColl).insertOne({ _id: markerId as any });
|
|
540
|
+
|
|
541
|
+
// Collect events until the marker event arrives (the "caught up" signal).
|
|
542
|
+
const seen: string[] = [];
|
|
543
|
+
let markerSeen = false;
|
|
544
|
+
const deadline = Date.now() + 30_000;
|
|
545
|
+
while (!markerSeen && Date.now() < deadline) {
|
|
546
|
+
const ev = await cursor.tryNext();
|
|
547
|
+
if (ev == null) {
|
|
548
|
+
continue;
|
|
549
|
+
}
|
|
550
|
+
if (ev.operationType === 'insert' && 'documentKey' in ev && String(ev.documentKey._id) === markerId) {
|
|
551
|
+
markerSeen = true;
|
|
552
|
+
break;
|
|
553
|
+
}
|
|
554
|
+
seen.push(ev.operationType);
|
|
555
|
+
}
|
|
556
|
+
// Drain anything already buffered after the marker, in case a DDL event
|
|
557
|
+
// was delivered slightly out of order behind it.
|
|
558
|
+
let trailing: Awaited<ReturnType<typeof cursor.tryNext>>;
|
|
559
|
+
while ((trailing = await cursor.tryNext()) != null) {
|
|
560
|
+
seen.push(trailing.operationType);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
console.dir({ ddlEventProbe: { observedOperationTypes: [...new Set(seen)], markerSeen } }, { depth: null });
|
|
564
|
+
|
|
565
|
+
// The marker proves the stream is live and has reached past the DDL, so
|
|
566
|
+
// the absence of DDL events below is meaningful.
|
|
567
|
+
expect(markerSeen, 'post-DDL marker event was never delivered — the change stream stalled').toBe(true);
|
|
568
|
+
// Documents the current limitation. If either fails, DocumentDB has
|
|
569
|
+
// started delivering DDL events — add drop/rename support and flip these.
|
|
570
|
+
expect(seen, '`drop` event was delivered — DocumentDB may now support DDL events').not.toContain('drop');
|
|
571
|
+
expect(seen, '`rename` event was delivered — DocumentDB may now support DDL events').not.toContain('rename');
|
|
572
|
+
} finally {
|
|
573
|
+
await cursor.close();
|
|
574
|
+
}
|
|
575
|
+
} finally {
|
|
576
|
+
await cleanup();
|
|
577
|
+
await client.close();
|
|
578
|
+
}
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
// Remote DocumentDB clusters can have 10-30s latency spikes for change
|
|
582
|
+
// stream delivery. Tests that poll for data need headroom.
|
|
583
|
+
describeWithStorage({ timeout: testTimeout(120_000, { cloudOverride: 180_000 }) }, defineDocumentDBDbModeTests);
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
function defineDocumentDBDbModeTests({ factory, storageVersion }: StorageVersionTestContext) {
|
|
587
|
+
const openContext = (options?: Parameters<typeof ChangeStreamTestContext.open>[1]) => {
|
|
588
|
+
return ChangeStreamTestContext.open(factory, {
|
|
589
|
+
...options,
|
|
590
|
+
storageVersion,
|
|
591
|
+
streamOptions: {
|
|
592
|
+
...options?.streamOptions
|
|
593
|
+
}
|
|
594
|
+
});
|
|
595
|
+
};
|
|
596
|
+
|
|
597
|
+
test('basic replication in documentDbMode', async () => {
|
|
598
|
+
await using context = await openContext();
|
|
599
|
+
const { db } = context;
|
|
600
|
+
await context.updateSyncRules(`
|
|
601
|
+
bucket_definitions:
|
|
602
|
+
global:
|
|
603
|
+
data:
|
|
604
|
+
- SELECT _id as id, description FROM "test_data"`);
|
|
605
|
+
|
|
606
|
+
await db.createCollection('test_data');
|
|
607
|
+
const collection = db.collection('test_data');
|
|
608
|
+
|
|
609
|
+
await context.replicateSnapshot();
|
|
610
|
+
context.startStreaming();
|
|
611
|
+
|
|
612
|
+
const result = await collection.insertOne({ description: 'test1' });
|
|
613
|
+
const test_id = result.insertedId;
|
|
614
|
+
await collection.updateOne({ _id: test_id }, { $set: { description: 'test2' } });
|
|
615
|
+
await collection.deleteOne({ _id: test_id });
|
|
616
|
+
|
|
617
|
+
const data = await context.getBucketData('global[]', undefined, { timeout: DOCUMENTDB_BUCKET_DATA_TIMEOUT });
|
|
618
|
+
|
|
619
|
+
expect(data).toMatchObject([
|
|
620
|
+
test_utils.putOp('test_data', { id: test_id.toHexString(), description: 'test1' }),
|
|
621
|
+
test_utils.putOp('test_data', { id: test_id.toHexString(), description: 'test2' }),
|
|
622
|
+
test_utils.removeOp('test_data', test_id.toHexString())
|
|
623
|
+
]);
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
test('sentinel checkpoint resolution', async () => {
|
|
627
|
+
await using context = await openContext();
|
|
628
|
+
const { db } = context;
|
|
629
|
+
await context.updateSyncRules(`
|
|
630
|
+
bucket_definitions:
|
|
631
|
+
global:
|
|
632
|
+
data:
|
|
633
|
+
- SELECT _id as id, description FROM "test_data"`);
|
|
634
|
+
|
|
635
|
+
await db.createCollection('test_data');
|
|
636
|
+
const collection = db.collection('test_data');
|
|
637
|
+
|
|
638
|
+
await context.replicateSnapshot();
|
|
639
|
+
context.startStreaming();
|
|
640
|
+
|
|
641
|
+
const insertResult = await collection.insertOne({ description: 'sentinel_test' });
|
|
642
|
+
const insertedId = insertResult.insertedId.toHexString();
|
|
643
|
+
|
|
644
|
+
// getCheckpoint() internally calls createCheckpoint, which should return a sentinel
|
|
645
|
+
// format on DocumentDB. The streaming loop must resolve it by matching the sentinel event.
|
|
646
|
+
const checkpoint = await context.getCheckpoint();
|
|
647
|
+
expect(checkpoint).toBeTruthy();
|
|
648
|
+
|
|
649
|
+
const data = await context.getBucketData('global[]', undefined, { timeout: DOCUMENTDB_BUCKET_DATA_TIMEOUT });
|
|
650
|
+
expect(data).toMatchObject([test_utils.putOp('test_data', { id: insertedId, description: 'sentinel_test' })]);
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
// DocumentDB does not support $changeStreamSplitLargeEvent, so large change
|
|
654
|
+
// events cannot be split into fragments the way the standard MongoDB path
|
|
655
|
+
// does. This verifies that a large document still replicates end-to-end —
|
|
656
|
+
// both on insert (the event carries the full document) and on update
|
|
657
|
+
// (updateLookup fetches the full document into the event) — and that the
|
|
658
|
+
// large value is persisted through to bucket storage, not just delivered on
|
|
659
|
+
// the change stream.
|
|
660
|
+
//
|
|
661
|
+
// The payload is sized just under PowerSync's MAX_ROW_SIZE (15 MiB). Rows at
|
|
662
|
+
// or above that limit are dropped by bucket storage regardless of source DB
|
|
663
|
+
// ("Row too large ... Removing"), so the persisted value cannot approach the
|
|
664
|
+
// 16 MiB BSON document limit. 14 MiB still forces a large change event
|
|
665
|
+
// through the DocumentDB stream while keeping the projected row under the limit.
|
|
666
|
+
// Assertions check the payload length rather than inlining a 14MB string.
|
|
667
|
+
// Note: DocumentDB delivers large change events very slowly (observed ~18s to fetch a
|
|
668
|
+
// single ~14 MiB event), so this test allows generous checkpoint timeouts. See
|
|
669
|
+
// docs/documentdb/documentdb-limitations.md.
|
|
670
|
+
test(
|
|
671
|
+
'replicates a large document near the row size limit',
|
|
672
|
+
// Fetching the large row takes very long in DocumentDB cloud.
|
|
673
|
+
{ timeout: DOCUMENTDB_LARGE_DOCUMENT_TIMEOUT },
|
|
674
|
+
async () => {
|
|
675
|
+
await using context = await openContext({
|
|
676
|
+
streamOptions: {
|
|
677
|
+
// The test context normally uses 200ms so local MongoDB streams abort
|
|
678
|
+
// quickly, but DocumentDB needs a realistic getMore maxTimeMS for
|
|
679
|
+
// intentionally large change events.
|
|
680
|
+
maxAwaitTimeMS: DOCUMENTDB_LARGE_DOCUMENT_MAX_AWAIT_TIME_MS
|
|
681
|
+
}
|
|
682
|
+
});
|
|
683
|
+
const { db } = context;
|
|
684
|
+
await context.updateSyncRules(`
|
|
685
|
+
bucket_definitions:
|
|
686
|
+
global:
|
|
687
|
+
data:
|
|
688
|
+
- SELECT _id as id, marker, payload FROM "test_data"`);
|
|
689
|
+
|
|
690
|
+
await db.createCollection('test_data');
|
|
691
|
+
const collection = db.collection('test_data');
|
|
692
|
+
|
|
693
|
+
await context.replicateSnapshot();
|
|
694
|
+
context.startStreaming();
|
|
695
|
+
|
|
696
|
+
// 14 MiB payload — large enough to exercise the large-event path, but the
|
|
697
|
+
// projected row (payload + small envelope) stays under MAX_ROW_SIZE so it
|
|
698
|
+
// is not dropped by bucket storage.
|
|
699
|
+
const largePayload = 'x'.repeat(14 * 1024 * 1024);
|
|
700
|
+
|
|
701
|
+
const insertResult = await collection.insertOne({ marker: 'big_insert', payload: largePayload });
|
|
702
|
+
const id = insertResult.insertedId.toHexString();
|
|
703
|
+
|
|
704
|
+
// Large events are slow to fetch on DocumentDB, so allow well beyond the 15s default.
|
|
705
|
+
const afterInsert = await context.getBucketData('global[]', undefined, {
|
|
706
|
+
timeout: DOCUMENTDB_LARGE_BUCKET_DATA_TIMEOUT
|
|
707
|
+
});
|
|
708
|
+
expect(afterInsert.length).toEqual(1);
|
|
709
|
+
const insertData = JSON.parse(afterInsert[0].data as string);
|
|
710
|
+
expect(insertData).toMatchObject({ id, marker: 'big_insert' });
|
|
711
|
+
expect(insertData.payload.length).toEqual(largePayload.length);
|
|
712
|
+
|
|
713
|
+
// Update a small field; the payload stays large, so the updateLookup
|
|
714
|
+
// fullDocument on the change event is still ~15MB.
|
|
715
|
+
await collection.updateOne({ _id: insertResult.insertedId }, { $set: { marker: 'big_update' } });
|
|
716
|
+
|
|
717
|
+
// Fetching the large row takes very long in DocumentDB cloud.
|
|
718
|
+
const afterUpdate = await context.getBucketData('global[]', undefined, {
|
|
719
|
+
timeout: DOCUMENTDB_LARGE_BUCKET_DATA_TIMEOUT
|
|
720
|
+
});
|
|
721
|
+
expect(afterUpdate.length).toEqual(2);
|
|
722
|
+
const updateData = JSON.parse(afterUpdate[1].data as string);
|
|
723
|
+
expect(updateData).toMatchObject({ id, marker: 'big_update' });
|
|
724
|
+
expect(updateData.payload.length).toEqual(largePayload.length);
|
|
725
|
+
}
|
|
726
|
+
);
|
|
727
|
+
|
|
728
|
+
test('keepalive in documentDbMode', async () => {
|
|
729
|
+
await using context = await openContext({
|
|
730
|
+
streamOptions: {
|
|
731
|
+
keepaliveIntervalMs: 2000
|
|
732
|
+
}
|
|
733
|
+
});
|
|
734
|
+
const { db } = context;
|
|
735
|
+
await context.updateSyncRules(BASIC_SYNC_RULES);
|
|
736
|
+
|
|
737
|
+
await db.createCollection('test_data');
|
|
738
|
+
|
|
739
|
+
await context.replicateSnapshot();
|
|
740
|
+
context.startStreaming();
|
|
741
|
+
|
|
742
|
+
// Wait for the initial checkpoint to be processed
|
|
743
|
+
await context.getCheckpoint({ timeout: DOCUMENTDB_BUCKET_DATA_TIMEOUT });
|
|
744
|
+
|
|
745
|
+
// Wait past the keepalive interval so the idle keepalive path fires.
|
|
746
|
+
// On DocumentDB, this must NOT crash from parseResumeTokenTimestamp
|
|
747
|
+
// (DocumentDB resume tokens are base64, not hex).
|
|
748
|
+
await setTimeout(3000);
|
|
749
|
+
|
|
750
|
+
// Insert data after the keepalive interval to verify the stream is still alive
|
|
751
|
+
const collection = db.collection('test_data');
|
|
752
|
+
await collection.insertOne({ description: 'after_keepalive' });
|
|
753
|
+
|
|
754
|
+
const data = await context.getBucketData('global[]', undefined, {
|
|
755
|
+
timeout: DOCUMENTDB_BUCKET_DATA_TIMEOUT
|
|
756
|
+
});
|
|
757
|
+
expect(data.length).toBeGreaterThanOrEqual(1);
|
|
758
|
+
const lastOp = data[data.length - 1];
|
|
759
|
+
expect(JSON.parse(lastOp.data as string)).toMatchObject({ description: 'after_keepalive' });
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
// Skipped until Azure DocumentDB ships the server-side getMore maxAwaitTimeMS fix.
|
|
763
|
+
test.skip('respects maxAwaitTimeMS for idle getMore calls in documentDbMode', async () => {
|
|
764
|
+
const maxAwaitTimeMS = 2_000;
|
|
765
|
+
|
|
766
|
+
await using context = await openContext({
|
|
767
|
+
streamOptions: {
|
|
768
|
+
maxAwaitTimeMS
|
|
769
|
+
}
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
// DocumentDB uses a cluster-level change stream through client.db('admin'), so
|
|
773
|
+
// spying on only context.db.command would miss the getMore calls. Spying on
|
|
774
|
+
// the Db prototype captures command calls from both the test DB and admin DB
|
|
775
|
+
// while still delegating to the real MongoDB driver implementation.
|
|
776
|
+
const dbPrototype = Object.getPrototypeOf(context.db);
|
|
777
|
+
const originalCommand = dbPrototype.command;
|
|
778
|
+
const getMoreTimings: {
|
|
779
|
+
collection: unknown;
|
|
780
|
+
maxTimeMS: unknown;
|
|
781
|
+
durationMS: number;
|
|
782
|
+
nextBatchLength: number | undefined;
|
|
783
|
+
}[] = [];
|
|
784
|
+
const commandSpy = vi.spyOn(dbPrototype, 'command').mockImplementation(async function (
|
|
785
|
+
this: unknown,
|
|
786
|
+
command: any,
|
|
787
|
+
options?: any
|
|
788
|
+
) {
|
|
789
|
+
if (command?.getMore == null) {
|
|
790
|
+
return originalCommand.call(this, command, options);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// Measure the actual round-trip duration of the driver's getMore command.
|
|
794
|
+
// This verifies the server waits for maxTimeMS when the change stream is
|
|
795
|
+
// idle, rather than returning empty batches immediately.
|
|
796
|
+
const start = performance.now();
|
|
797
|
+
let result: any;
|
|
798
|
+
try {
|
|
799
|
+
result = await originalCommand.call(this, command, options);
|
|
800
|
+
return result;
|
|
801
|
+
} finally {
|
|
802
|
+
const cursor = Buffer.isBuffer(result?.cursor)
|
|
803
|
+
? mongo.BSON.deserialize(result.cursor, {
|
|
804
|
+
useBigInt64: true,
|
|
805
|
+
fieldsAsRaw: { nextBatch: true },
|
|
806
|
+
validation: { utf8: false }
|
|
807
|
+
})
|
|
808
|
+
: result?.cursor;
|
|
809
|
+
|
|
810
|
+
getMoreTimings.push({
|
|
811
|
+
collection: command.collection,
|
|
812
|
+
maxTimeMS: command.maxTimeMS,
|
|
813
|
+
durationMS: Math.round(performance.now() - start),
|
|
814
|
+
nextBatchLength: cursor?.nextBatch?.length
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
try {
|
|
820
|
+
const { db } = context;
|
|
821
|
+
await context.updateSyncRules(BASIC_SYNC_RULES);
|
|
822
|
+
|
|
823
|
+
await db.createCollection('test_data');
|
|
824
|
+
const collection = db.collection('test_data');
|
|
825
|
+
|
|
826
|
+
await context.replicateSnapshot();
|
|
827
|
+
context.startStreaming();
|
|
828
|
+
|
|
829
|
+
const result = await collection.insertOne({ description: 'maxAwaitTimeMS_test' });
|
|
830
|
+
await context.getBucketData('global[]');
|
|
831
|
+
|
|
832
|
+
// Once the stream has caught up, the latest getMore call should eventually
|
|
833
|
+
// be an idle poll. That idle poll should wait for maxTimeMS instead of
|
|
834
|
+
// returning immediately, and the response should contain an empty batch.
|
|
835
|
+
await vi.waitFor(
|
|
836
|
+
() => {
|
|
837
|
+
const lastGetMore = getMoreTimings.at(-1);
|
|
838
|
+
expect(lastGetMore?.durationMS).toBeGreaterThanOrEqual(maxAwaitTimeMS);
|
|
839
|
+
expect(lastGetMore?.nextBatchLength).toEqual(0);
|
|
840
|
+
},
|
|
841
|
+
{
|
|
842
|
+
timeout: maxAwaitTimeMS + 2_000,
|
|
843
|
+
interval: 100
|
|
844
|
+
}
|
|
845
|
+
);
|
|
846
|
+
|
|
847
|
+
console.dir({ getMoreMaxAwaitTimeMSTimings: getMoreTimings }, { depth: null });
|
|
848
|
+
|
|
849
|
+
expect(result.insertedId).toBeTruthy();
|
|
850
|
+
expect(getMoreTimings.length).toBeGreaterThan(0);
|
|
851
|
+
} finally {
|
|
852
|
+
commandSpy.mockRestore();
|
|
853
|
+
}
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
test('write checkpoint flow in documentDbMode', async () => {
|
|
857
|
+
await using context = await openContext();
|
|
858
|
+
const { db } = context;
|
|
859
|
+
await context.updateSyncRules(BASIC_SYNC_RULES);
|
|
860
|
+
|
|
861
|
+
await db.createCollection('test_data');
|
|
862
|
+
const collection = db.collection('test_data');
|
|
863
|
+
|
|
864
|
+
await context.replicateSnapshot();
|
|
865
|
+
await context.markSnapshotConsistent();
|
|
866
|
+
|
|
867
|
+
await using api = new MongoRouteAPIAdapter({
|
|
868
|
+
type: 'mongodb',
|
|
869
|
+
...TEST_CONNECTION_OPTIONS
|
|
870
|
+
});
|
|
871
|
+
|
|
872
|
+
context.startStreaming();
|
|
873
|
+
|
|
874
|
+
// Wait until stream is active
|
|
875
|
+
await context.getCheckpoint();
|
|
876
|
+
|
|
877
|
+
// Insert data so the stream has something to process
|
|
878
|
+
await collection.insertOne({ description: 'write_cp_test' });
|
|
879
|
+
|
|
880
|
+
const writeCheckpointBatcher = new WriteCheckpointBatcher(
|
|
881
|
+
() => api,
|
|
882
|
+
() => context.factory
|
|
883
|
+
);
|
|
884
|
+
|
|
885
|
+
// Exercise the write checkpoint flow. On DocumentDB, createReplicationHead
|
|
886
|
+
// advances the sentinel counter with a standalone (null stream_id) bump and
|
|
887
|
+
// uses the resulting sentinel-based LSN as the source-side head. The sentinel
|
|
888
|
+
// write also nudges replication forward on an idle stream.
|
|
889
|
+
const result = await createWriteCheckpoint({
|
|
890
|
+
userId: 'test_user',
|
|
891
|
+
clientId: 'test_client',
|
|
892
|
+
batcher: writeCheckpointBatcher
|
|
893
|
+
});
|
|
894
|
+
|
|
895
|
+
// The write checkpoint should resolve with a valid result
|
|
896
|
+
expect(result).toBeTruthy();
|
|
897
|
+
expect(result.writeCheckpoint).toBeTruthy();
|
|
898
|
+
expect(result.replicationHead).toBeTruthy();
|
|
899
|
+
});
|
|
900
|
+
|
|
901
|
+
test('data events not dropped after restart (lte guard)', async () => {
|
|
902
|
+
// Verifies that the .lte() dedup guard in streamChangesInternal does NOT
|
|
903
|
+
// drop data events on DocumentDB after restart. On DocumentDB, wallTime has
|
|
904
|
+
// second precision (increment 0). Without the isDocumentDb guard, events
|
|
905
|
+
// within the same wall-clock second as the last checkpoint would be silently
|
|
906
|
+
// dropped — causing data loss.
|
|
907
|
+
//
|
|
908
|
+
// This test avoids getClientCheckpoint (which has its own timing issues)
|
|
909
|
+
// and instead polls the storage directly until the data appears.
|
|
910
|
+
|
|
911
|
+
// Phase 1: initial sync + streaming + checkpoint
|
|
912
|
+
await using context = await openContext();
|
|
913
|
+
const { db } = context;
|
|
914
|
+
await context.updateSyncRules(BASIC_SYNC_RULES);
|
|
915
|
+
|
|
916
|
+
await db.createCollection('test_data');
|
|
917
|
+
const collection = db.collection('test_data');
|
|
918
|
+
|
|
919
|
+
await context.replicateSnapshot();
|
|
920
|
+
context.startStreaming();
|
|
921
|
+
|
|
922
|
+
await collection.insertOne({ description: 'phase1_data' });
|
|
923
|
+
await context.getCheckpoint();
|
|
924
|
+
|
|
925
|
+
// Stop streaming
|
|
926
|
+
context.abort();
|
|
927
|
+
await context.dispose();
|
|
928
|
+
|
|
929
|
+
// Phase 2: restart and insert immediately (same second as last checkpoint)
|
|
930
|
+
await using context2 = await openContext({ doNotClear: true });
|
|
931
|
+
const db2 = context2.db;
|
|
932
|
+
|
|
933
|
+
await context2.loadActiveSyncRules();
|
|
934
|
+
|
|
935
|
+
context2.startStreaming();
|
|
936
|
+
|
|
937
|
+
// Wait for the stream to be fully initialized — the streaming loop must
|
|
938
|
+
// process its initial checkpoint before we insert test data. Without this,
|
|
939
|
+
// the insert can happen before the ChangeStream's lazy aggregate command
|
|
940
|
+
// is sent, causing the event to be missed entirely (not a .lte() issue).
|
|
941
|
+
await context2.getCheckpoint({ timeout: DOCUMENTDB_RESTART_TIMEOUT });
|
|
942
|
+
|
|
943
|
+
// Insert — if .lte() drops same-second events, this data will never appear.
|
|
944
|
+
const collection2 = db2.collection('test_data');
|
|
945
|
+
const result2 = await collection2.insertOne({ description: 'post_restart_data' });
|
|
946
|
+
const id2 = result2.insertedId;
|
|
947
|
+
|
|
948
|
+
// Poll for the data by repeatedly calling getBucketData with a longer timeout.
|
|
949
|
+
// We bypass the flaky getClientCheckpoint timing by polling until the data appears
|
|
950
|
+
// or the timeout expires. If the .lte() guard drops same-second events, the data
|
|
951
|
+
// will never appear — deterministic failure.
|
|
952
|
+
const deadline = Date.now() + DOCUMENTDB_RESTART_TIMEOUT;
|
|
953
|
+
let found = false;
|
|
954
|
+
while (Date.now() < deadline) {
|
|
955
|
+
try {
|
|
956
|
+
const data = await context2.getBucketData('global[]', undefined, { timeout: 5_000 });
|
|
957
|
+
const match = data.find((op) => op.object_id === id2.toHexString() && op.op === 'PUT');
|
|
958
|
+
if (match) {
|
|
959
|
+
const parsed = JSON.parse(match.data as string);
|
|
960
|
+
expect(parsed).toMatchObject({ description: 'post_restart_data' });
|
|
961
|
+
found = true;
|
|
962
|
+
break;
|
|
963
|
+
}
|
|
964
|
+
} catch {
|
|
965
|
+
// getCheckpoint may timeout on first attempts — retry
|
|
966
|
+
}
|
|
967
|
+
await setTimeout(200);
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
expect(
|
|
971
|
+
found,
|
|
972
|
+
'Data event after restart was dropped — .lte() guard may be incorrectly filtering same-second events'
|
|
973
|
+
).toBe(true);
|
|
974
|
+
});
|
|
975
|
+
|
|
976
|
+
test('resume after restart in documentDbMode', async () => {
|
|
977
|
+
// Phase 1: replicate some data, then stop
|
|
978
|
+
await using context = await openContext();
|
|
979
|
+
const { db } = context;
|
|
980
|
+
await context.updateSyncRules(`
|
|
981
|
+
bucket_definitions:
|
|
982
|
+
global:
|
|
983
|
+
data:
|
|
984
|
+
- SELECT _id as id, description FROM "test_data"`);
|
|
985
|
+
|
|
986
|
+
await db.createCollection('test_data');
|
|
987
|
+
const collection = db.collection('test_data');
|
|
988
|
+
|
|
989
|
+
await context.replicateSnapshot();
|
|
990
|
+
context.startStreaming();
|
|
991
|
+
|
|
992
|
+
const result1 = await collection.insertOne({ description: 'before_restart' });
|
|
993
|
+
const id1 = result1.insertedId;
|
|
994
|
+
|
|
995
|
+
// Wait for the data to be replicated and checkpoint to advance
|
|
996
|
+
await context.getCheckpoint({ timeout: DOCUMENTDB_BUCKET_DATA_TIMEOUT });
|
|
997
|
+
|
|
998
|
+
const dataBefore = await context.getBucketDataAtLatestCheckpoint('global[]');
|
|
999
|
+
expect(dataBefore).toMatchObject([
|
|
1000
|
+
test_utils.putOp('test_data', { id: id1.toHexString(), description: 'before_restart' })
|
|
1001
|
+
]);
|
|
1002
|
+
|
|
1003
|
+
// Stop streaming (simulates a restart)
|
|
1004
|
+
context.abort();
|
|
1005
|
+
await context.dispose();
|
|
1006
|
+
|
|
1007
|
+
// Phase 2: reopen without clearing and resume
|
|
1008
|
+
await using context2 = await openContext({ doNotClear: true });
|
|
1009
|
+
const db2 = context2.db;
|
|
1010
|
+
|
|
1011
|
+
await context2.loadActiveSyncRules();
|
|
1012
|
+
|
|
1013
|
+
context2.startStreaming();
|
|
1014
|
+
|
|
1015
|
+
// Wait for the stream to fully initialize and process the initial checkpoint
|
|
1016
|
+
// before inserting new data.
|
|
1017
|
+
await context2.getCheckpoint({ timeout: DOCUMENTDB_RESTART_TIMEOUT });
|
|
1018
|
+
|
|
1019
|
+
const collection2 = db2.collection('test_data');
|
|
1020
|
+
const result2 = await collection2.insertOne({ description: 'after_restart' });
|
|
1021
|
+
const id2 = result2.insertedId;
|
|
1022
|
+
|
|
1023
|
+
// On DocumentDB, wall-clock LSNs have second precision and stored LSNs may
|
|
1024
|
+
// include resume-token suffixes. Avoid creating a fresh sentinel checkpoint
|
|
1025
|
+
// on every poll; read at the latest persisted checkpoint instead.
|
|
1026
|
+
const deadline = Date.now() + DOCUMENTDB_RESTART_TIMEOUT;
|
|
1027
|
+
let found = false;
|
|
1028
|
+
while (Date.now() < deadline) {
|
|
1029
|
+
const dataAfter = await context2.getBucketDataAtLatestCheckpoint('global[]');
|
|
1030
|
+
const afterRestartOps = dataAfter.filter((op) => op.object_id === id2.toHexString() && op.op === 'PUT');
|
|
1031
|
+
if (afterRestartOps.length >= 1) {
|
|
1032
|
+
expect(JSON.parse(afterRestartOps[0].data as string)).toMatchObject({ description: 'after_restart' });
|
|
1033
|
+
found = true;
|
|
1034
|
+
break;
|
|
1035
|
+
}
|
|
1036
|
+
await setTimeout(200);
|
|
1037
|
+
}
|
|
1038
|
+
expect(found, 'Data event after restart was not replicated within timeout').toBe(true);
|
|
1039
|
+
});
|
|
1040
|
+
}
|