Skip to content

Commit 74798d4

Browse files
committed
refactor: remove cosmosDbMode test flag, simplify Cosmos DB detection
Remove the cosmosDbMode / isRealCosmosDb split. isCosmosDb is now set only by server detection (hello.internal.cosmos_versions), never by a test flag. The sentinel approach is a single code path that works on both: The sentinel logic itself is identical on MongoDB and Cosmos DB: write a checkpoint document ({$inc: {i: 1}}), wait for it in the change stream, match by documentKey._id and fullDocument.i, use the event timestamp as the LSN, commit. This passes on both. The branching between MongoDB and Cosmos DB is minimal and environmental, not architectural: - Which stream to open (client.watch vs db.watch) - Which timestamp to use (wallTime vs clusterTime) - Whether to initialize the stream before checkpointing - Whether startAtOperationTime is available These are server-level differences, not sentinel-level. The sentinel matching, the checkpoint resolution chain, and batch.commit are the same code on both backends. Why cosmosDbMode on standard MongoDB does not work: The test flag tried to force the Cosmos DB code path on standard MongoDB. The problems were all about mixing two environments, not about the sentinel approach: 1. Lazy ChangeStream initialization. The MongoDB driver's ChangeStream is lazy — the aggregate command is not sent until the first tryNext()/hasNext() call. On standard MongoDB, startAtOperationTime sets the stream's start position explicitly, so events committed before the first tryNext() are included. On Cosmos DB (no startAtOperationTime), the stream starts from "now" — whenever the first tryNext() runs. This requires opening the stream and forcing initialization BEFORE creating checkpoint documents, reversing the order used on standard MongoDB. 2. Wall-clock LSN precision. Cosmos DB events use wallTime (second precision, increment 0) instead of clusterTime (sub-second, real increment). When cosmosDbMode forces wallTime on standard MongoDB, the createCheckpoint function still has access to operationTime (which has real increments). Mixing the two clock sources causes LSN comparisons to fail when timestamps fall in the same second: wallTime produces increment 0, operationTime produces increment N, and 0 < N means the checkpoint never resolves. 3. Sentinel checkpoint matching. The sentinel flow depends on the stream being initialized before the marker is written. Combined with issue 1, this creates a deadlock on standard MongoDB where tryNext() blocks waiting for events that have not been created yet. These issues compound: fixing one exposes the next. A test flag that partially simulates Cosmos DB creates more problems than it solves because the underlying assumptions about stream initialization and clock sources are fundamentally different between the two servers. Changes: - Remove cosmosDbMode from ChangeStreamOptions - Remove isRealCosmosDb field — isCosmosDb is the only flag - createCheckpoint auto-detects via session.operationTime == null - Integration tests skip unless COSMOS_DB_TEST=true - Unskip write checkpoint test (works against real Cosmos DB) - Fix write checkpoint assertion (returns object, not bigint) - Revert getClientCheckpoint to simple cp.lsn >= lsn comparison Remaining test failures against Cosmos DB (3/26): resume after restart — PSYNC_S1346 Error reading MongoDB ChangeStream The resume test stops streaming, creates a new context, loads active sync rules, and starts streaming again from the stored resume token. On Cosmos DB this fails with a change stream error. Likely causes: - Cosmos DB resume tokens may expire faster than MongoDB (400MB change stream log limit vs time-based oplog retention) - The token format (base64 vs hex) may not round-trip correctly through MongoLSN serialization/deserialization - Cosmos DB may not support resumeAfter with tokens from a previous connection Needs separate investigation of the resume token persistence path in MongoLSN.fromSerialized().
1 parent e62f753 commit 74798d4

5 files changed

Lines changed: 81 additions & 89 deletions

File tree

modules/module-mongodb/src/replication/ChangeStream.ts

Lines changed: 42 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -59,11 +59,6 @@ export interface ChangeStreamOptions {
5959
*/
6060
snapshotChunkLength?: number;
6161

62-
/**
63-
* Force Cosmos DB mode for testing. When set, this.isCosmosDb = true regardless of hello response.
64-
*/
65-
cosmosDbMode?: boolean;
66-
6762
/**
6863
* Override keepalive interval for testing (defaults to 60_000ms).
6964
*/
@@ -131,7 +126,6 @@ export class ChangeStream {
131126
this.connections = options.connections;
132127
this.maxAwaitTimeMS = options.maxAwaitTimeMS ?? 10_000;
133128
this.snapshotChunkLength = options.snapshotChunkLength ?? 6_000;
134-
this.isCosmosDb = options.cosmosDbMode ?? false;
135129
this.keepaliveIntervalMs = options.keepaliveIntervalMs ?? 60_000;
136130
this.client = this.connections.client;
137131
this.defaultDb = this.connections.db;
@@ -152,11 +146,6 @@ export class ChangeStream {
152146
);
153147

154148
this.logger = options.logger ?? defaultLogger;
155-
156-
// Validate early when cosmosDbMode is forced via test option
157-
if (this.isCosmosDb) {
158-
this.validatePostImagesForCosmosDb();
159-
}
160149
}
161150

162151
get stopped() {
@@ -298,14 +287,32 @@ export class ChangeStream {
298287
const LSN_TIMEOUT_SECONDS = 60;
299288
const LSN_CREATE_INTERVAL_SECONDS = 1;
300289

301-
// Create a checkpoint, and open a change stream using startAtOperationTime with the checkpoint's operationTime.
302-
const firstCheckpointLsn = await createCheckpoint(this.client, this.defaultDb, this.checkpointStreamId, {
303-
mode: this.isCosmosDb ? 'sentinel' : 'lsn',
304-
isCosmosDb: this.isCosmosDb
305-
});
306-
// Sentinel LSNs cannot be parsed as MongoLSN — open stream from current position
307-
const streamLsn = firstCheckpointLsn.startsWith('sentinel:') ? null : firstCheckpointLsn;
308-
await using streamManager = this.openChangeStream({ lsn: streamLsn, maxAwaitTimeMs: 0 });
290+
let firstCheckpointLsn: string;
291+
292+
if (this.isCosmosDb) {
293+
// Cosmos DB: no startAtOperationTime available. Open the stream first
294+
// (to establish the start position), then create the checkpoint. The
295+
// checkpoint event will be captured because it happens after the stream
296+
// is already listening.
297+
// Force initialization with tryNext() since ChangeStream is lazy.
298+
// Use a small non-zero maxAwaitTimeMS — with 0, tryNext() always returns
299+
// null because the driver's getMore returns before events are available.
300+
var streamManager = this.openChangeStream({ lsn: null, maxAwaitTimeMs: 100 });
301+
await streamManager.stream.tryNext();
302+
firstCheckpointLsn = await createCheckpoint(this.client, this.defaultDb, this.checkpointStreamId, {
303+
mode: 'sentinel'
304+
});
305+
} else {
306+
// Standard MongoDB: create checkpoint first to get operationTime,
307+
// then open the stream from that point.
308+
firstCheckpointLsn = await createCheckpoint(this.client, this.defaultDb, this.checkpointStreamId);
309+
var streamManager = this.openChangeStream({
310+
lsn: firstCheckpointLsn,
311+
maxAwaitTimeMs: 0
312+
});
313+
}
314+
// @ts-ignore streamManager is always assigned
315+
await using _streamManager = streamManager;
309316

310317
const { stream } = streamManager;
311318
const startTime = performance.now();
@@ -315,8 +322,7 @@ export class ChangeStream {
315322
while (performance.now() - startTime < LSN_TIMEOUT_SECONDS * 1000) {
316323
if (performance.now() - lastCheckpointCreated >= LSN_CREATE_INTERVAL_SECONDS * 1000) {
317324
await createCheckpoint(this.client, this.defaultDb, this.checkpointStreamId, {
318-
mode: this.isCosmosDb ? 'sentinel' : 'lsn',
319-
isCosmosDb: this.isCosmosDb
325+
mode: this.isCosmosDb ? 'sentinel' : 'lsn'
320326
});
321327
lastCheckpointCreated = performance.now();
322328
}
@@ -431,9 +437,7 @@ export class ChangeStream {
431437
// The checkpoint here is a marker - we need to replicate up to at least this
432438
// point before the data can be considered consistent.
433439
// We could do this for each individual table, but may as well just do it once for the entire snapshot.
434-
const checkpoint = await createCheckpoint(this.client, this.defaultDb, STANDALONE_CHECKPOINT_ID, {
435-
isCosmosDb: this.isCosmosDb
436-
});
440+
const checkpoint = await createCheckpoint(this.client, this.defaultDb, STANDALONE_CHECKPOINT_ID);
437441
await batch.markAllSnapshotDone(checkpoint);
438442

439443
// This will not create a consistent checkpoint yet, but will persist the op.
@@ -698,9 +702,7 @@ export class ChangeStream {
698702
await batch.truncate([result.table]);
699703

700704
await this.snapshotTable(batch, result.table);
701-
const no_checkpoint_before_lsn = await createCheckpoint(this.client, this.defaultDb, STANDALONE_CHECKPOINT_ID, {
702-
isCosmosDb: this.isCosmosDb
703-
});
705+
const no_checkpoint_before_lsn = await createCheckpoint(this.client, this.defaultDb, STANDALONE_CHECKPOINT_ID);
704706

705707
const [table] = await batch.markTableSnapshotDone([result.table], no_checkpoint_before_lsn);
706708
return table;
@@ -817,8 +819,7 @@ export class ChangeStream {
817819
if (!this.isCosmosDb && changeDocument.clusterTime) {
818820
return changeDocument.clusterTime;
819821
}
820-
// Cosmos DB (or cosmosDbMode test flag): use wallTime at second precision.
821-
// On standard MongoDB, wallTime is also present — this path works for testing.
822+
// Cosmos DB: use wallTime at second precision (clusterTime is absent).
822823
const wallTime = (changeDocument as any).wallTime as Date | undefined;
823824
if (wallTime) {
824825
return mongo.Timestamp.fromBits(0, Math.floor(wallTime.getTime() / 1000));
@@ -960,14 +961,23 @@ export class ChangeStream {
960961
chunksReplicatedMetric.add(1);
961962
});
962963

964+
if (this.isCosmosDb) {
965+
// Force ChangeStream initialization before creating the checkpoint.
966+
// ChangeStream is lazy — the aggregate command isn't sent until the
967+
// first tryNext()/hasNext() call. Without this, createCheckpoint()
968+
// commits an event before the stream starts listening, and the event
969+
// is missed because it's before the stream's start position.
970+
await stream.tryNext();
971+
}
972+
963973
// Always start with a checkpoint.
964974
// This helps us to clear errors when restarting, even if there is
965975
// no data to replicate.
966976
let waitForCheckpointLsn: string | null = await createCheckpoint(
967977
this.client,
968978
this.defaultDb,
969979
this.checkpointStreamId,
970-
{ mode: this.isCosmosDb ? 'sentinel' : 'lsn', isCosmosDb: this.isCosmosDb }
980+
{ mode: this.isCosmosDb ? 'sentinel' : 'lsn' }
971981
);
972982

973983
let splitDocument: mongo.ChangeStreamDocument | null = null;
@@ -1144,8 +1154,7 @@ export class ChangeStream {
11441154
if (waitForCheckpointLsn != null || this.getBufferedChangeCount(stream) > 0) {
11451155
if (waitForCheckpointLsn == null) {
11461156
waitForCheckpointLsn = await createCheckpoint(this.client, this.defaultDb, this.checkpointStreamId, {
1147-
mode: this.isCosmosDb ? 'sentinel' : 'lsn',
1148-
isCosmosDb: this.isCosmosDb
1157+
mode: this.isCosmosDb ? 'sentinel' : 'lsn'
11491158
});
11501159
}
11511160
continue;
@@ -1204,8 +1213,7 @@ export class ChangeStream {
12041213
) {
12051214
if (waitForCheckpointLsn == null) {
12061215
waitForCheckpointLsn = await createCheckpoint(this.client, this.defaultDb, this.checkpointStreamId, {
1207-
mode: this.isCosmosDb ? 'sentinel' : 'lsn',
1208-
isCosmosDb: this.isCosmosDb
1216+
mode: this.isCosmosDb ? 'sentinel' : 'lsn'
12091217
});
12101218
}
12111219

modules/module-mongodb/src/replication/MongoRelation.ts

Lines changed: 11 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -177,39 +177,23 @@ export const STANDALONE_CHECKPOINT_ID = '_standalone_checkpoint';
177177
* waitForCheckpointLsn, where the loop matches by document content instead
178178
* of comparing LSNs.
179179
*
180-
* @param mode
181-
* 'lsn' (default) — return a real LSN string. Uses operationTime when
182-
* available, falls back to wall clock on Cosmos DB.
183-
* 'sentinel' — return a sentinel marker for event-based matching in the
184-
* streaming loop.
185-
*/
186-
/**
187-
* Create a checkpoint by upserting a document in _powersync_checkpoints.
188-
*
189-
* Returns either:
190-
* - A standard LSN string (from operationTime or wall clock) for storage
191-
* boundaries like no_checkpoint_before, where lexicographic comparison is used.
192-
* - A sentinel string ('sentinel:<id>:<i>') for the streaming loop's
193-
* waitForCheckpointLsn, where the loop matches by document content instead
194-
* of comparing LSNs.
180+
* Cosmos DB is detected automatically: when session.operationTime is null
181+
* (Cosmos DB does not provide it), the function falls back to wall clock
182+
* timestamps or sentinel format depending on the mode.
195183
*
196184
* @param mode
197185
* 'lsn' (default) — return a real LSN string. Uses operationTime when
198186
* available (standard MongoDB), falls back to wall clock (Cosmos DB).
199187
* 'sentinel' — return a sentinel marker for event-based matching in the
200188
* streaming loop.
201-
* 'wallclock-lsn' — always use wall clock, even when operationTime is
202-
* available. Use this when the LSN must be consistent with wallTime-derived
203-
* LSNs from the streaming loop (cosmosDbMode on standard MongoDB).
204189
*/
205190
export async function createCheckpoint(
206191
client: mongo.MongoClient,
207192
db: mongo.Db,
208193
id: mongo.ObjectId | string,
209-
options?: { mode?: 'lsn' | 'sentinel'; isCosmosDb?: boolean }
194+
options?: { mode?: 'lsn' | 'sentinel' }
210195
): Promise<string> {
211196
const mode = options?.mode ?? 'lsn';
212-
const isCosmosDb = options?.isCosmosDb ?? false;
213197
const session = client.startSession();
214198
try {
215199
// We use an unique id per process, and clear documents on startup.
@@ -238,18 +222,15 @@ export async function createCheckpoint(
238222
}
239223

240224
// LSN path: return a real LSN for storage comparison.
241-
// On Cosmos DB (or cosmosDbMode), always use wall clock to be consistent
242-
// with wallTime-derived LSNs from getEventTimestamp(). Mixing operationTime
243-
// (which has real increments) with wallTime (increment 0) causes LSN
244-
// comparison failures when both timestamps fall in the same second.
245-
if (!isCosmosDb) {
246-
const time = session.operationTime;
247-
if (time != null) {
248-
return new MongoLSN({ timestamp: time }).comparable;
249-
}
225+
// Use operationTime when available (standard MongoDB).
226+
const time = session.operationTime;
227+
if (time != null) {
228+
return new MongoLSN({ timestamp: time }).comparable;
250229
}
251230

252-
// Wall clock: consistent with wallTime-derived LSNs (second precision, increment 0).
231+
// Wall clock fallback: Cosmos DB does not provide operationTime.
232+
// Uses second precision with increment 0, consistent with wallTime-derived
233+
// LSNs from getEventTimestamp().
253234
const fallbackTimestamp = mongo.Timestamp.fromBits(0, Math.floor(Date.now() / 1000));
254235
return new MongoLSN({ timestamp: fallbackTimestamp }).comparable;
255236
} finally {

modules/module-mongodb/test/src/change_stream_utils.ts

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,6 @@ export class ChangeStreamTestContext {
146146
// a long time to abort the stream.
147147
maxAwaitTimeMS: this.streamOptions?.maxAwaitTimeMS ?? 200,
148148
snapshotChunkLength: this.streamOptions?.snapshotChunkLength,
149-
cosmosDbMode: this.streamOptions?.cosmosDbMode,
150149
keepaliveIntervalMs: this.streamOptions?.keepaliveIntervalMs
151150
};
152151
this._walStream = new ChangeStream(options);
@@ -180,11 +179,9 @@ export class ChangeStreamTestContext {
180179
}
181180

182181
async getCheckpoint(options?: { timeout?: number }) {
183-
const isCosmosDb = this.streamOptions?.cosmosDbMode ?? false;
184182
let checkpoint = await Promise.race([
185183
getClientCheckpoint(this.client, this.db, this.factory, {
186-
timeout: options?.timeout ?? 15_000,
187-
isCosmosDb
184+
timeout: options?.timeout ?? 15_000
188185
}),
189186
this.streamPromise?.then((e) => {
190187
if (e.status == 'rejected') {
@@ -252,12 +249,11 @@ export async function getClientCheckpoint(
252249
client: mongo.MongoClient,
253250
db: mongo.Db,
254251
storageFactory: BucketStorageFactory,
255-
options?: { timeout?: number; isCosmosDb?: boolean }
252+
options?: { timeout?: number }
256253
): Promise<InternalOpId> {
257254
const start = Date.now();
258-
const lsn = await createCheckpoint(client, db, STANDALONE_CHECKPOINT_ID, {
259-
isCosmosDb: options?.isCosmosDb
260-
});
255+
256+
const lsn = await createCheckpoint(client, db, STANDALONE_CHECKPOINT_ID);
261257
// This old API needs a persisted checkpoint id.
262258
// Since we don't use LSNs anymore, the only way to get that is to wait.
263259

modules/module-mongodb/test/src/cosmosdb_helpers.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,13 @@ describe('Cosmos DB helpers', () => {
4242
});
4343

4444
test('with both + isCosmosDb=true — skips clusterTime, uses wallTime', () => {
45-
// In cosmosDbMode, even if clusterTime is present (as it is on standard MongoDB),
45+
// On Cosmos DB, even if clusterTime were present,
4646
// getEventTimestamp should prefer wallTime to exercise the Cosmos DB code path.
4747
const wallTime = new Date('2024-06-15T12:00:00Z');
4848
const expectedSeconds = Math.floor(wallTime.getTime() / 1000);
4949
const clusterTime = mongo.Timestamp.fromBits(42, 1700000000);
5050

51-
// In cosmosDbMode, the result should use wallTime, not clusterTime
51+
// On Cosmos DB, the result should use wallTime, not clusterTime
5252
const expectedTimestamp = mongo.Timestamp.fromBits(0, expectedSeconds);
5353
expect(expectedTimestamp.getHighBitsUnsigned()).toEqual(expectedSeconds);
5454
expect(expectedTimestamp.getLowBitsUnsigned()).toEqual(0);
@@ -83,12 +83,12 @@ describe('Cosmos DB helpers', () => {
8383
});
8484

8585
describe('sentinel checkpoint format', () => {
86-
test('createCheckpoint returns sentinel format when operationTime is null', async () => {
86+
test('createCheckpoint returns sentinel format when mode is sentinel', async () => {
8787
// When mode is 'sentinel', createCheckpoint returns a sentinel string
8888
// like 'sentinel:<id>:<i>' for event-based matching in the streaming loop.
8989
const { client, db } = await connectMongoData();
9090
try {
91-
const checkpoint = await createCheckpoint(client, db, STANDALONE_CHECKPOINT_ID, { mode: 'sentinel', isCosmosDb: true });
91+
const checkpoint = await createCheckpoint(client, db, STANDALONE_CHECKPOINT_ID, { mode: 'sentinel' });
9292
// The sentinel format should be 'sentinel:<id>:<i>'
9393
expect(checkpoint).toMatch(/^sentinel:/);
9494
const parts = checkpoint.split(':');

modules/module-mongodb/test/src/cosmosdb_mode.test.ts

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,21 @@ bucket_definitions:
1515
- SELECT _id as id, description FROM "test_data"
1616
`;
1717

18-
describe('cosmosDbMode', () => {
18+
// These tests require a real Cosmos DB cluster. On standard MongoDB,
19+
// the Cosmos DB code paths (wallTime timestamps, sentinel checkpoints,
20+
// client.watch()) are not exercised because isCosmosDb is only set
21+
// by server detection. Running these against standard MongoDB would
22+
// test the standard code path, which is already covered by change_stream.test.ts.
23+
//
24+
// Why not a cosmosDbMode test flag? The Cosmos DB workarounds involve
25+
// different change stream initialization ordering (lazy ChangeStream +
26+
// no startAtOperationTime) and wall-clock LSN precision (increment 0
27+
// instead of operationTime's real increments). These produce LSN
28+
// comparison failures when mixed with standard MongoDB's operationTime-based
29+
// checkpoints. A test flag that partially simulates Cosmos DB creates
30+
// more problems than it solves.
31+
const isCosmosDb = process.env.COSMOS_DB_TEST === 'true';
32+
describe.skipIf(!isCosmosDb)('cosmosDbMode', () => {
1933
describeWithStorage({ timeout: 30_000 }, defineCosmosDbModeTests);
2034
});
2135

@@ -25,7 +39,6 @@ function defineCosmosDbModeTests({ factory, storageVersion }: StorageVersionTest
2539
...options,
2640
storageVersion,
2741
streamOptions: {
28-
cosmosDbMode: true,
2942
...options?.streamOptions
3043
}
3144
});
@@ -84,15 +97,12 @@ bucket_definitions:
8497
expect(checkpoint).toBeTruthy();
8598

8699
const data = await context.getBucketData('global[]');
87-
expect(data).toMatchObject([
88-
test_utils.putOp('test_data', { id: insertedId, description: 'sentinel_test' })
89-
]);
100+
expect(data).toMatchObject([test_utils.putOp('test_data', { id: insertedId, description: 'sentinel_test' })]);
90101
});
91102

92103
test('keepalive in cosmosDbMode', async () => {
93104
await using context = await openContext({
94105
streamOptions: {
95-
cosmosDbMode: true,
96106
keepaliveIntervalMs: 2000
97107
}
98108
});
@@ -122,11 +132,7 @@ bucket_definitions:
122132
expect(JSON.parse(lastOp.data as string)).toMatchObject({ description: 'after_keepalive' });
123133
});
124134

125-
// This test requires MongoRouteAPIAdapter to also detect Cosmos DB (via hello),
126-
// which only happens against a real Cosmos DB cluster. Against standard MongoDB,
127-
// createReplicationHead uses clusterTime (real increment) while the streaming loop
128-
// uses wallTime (increment 0), causing a permanent LSN mismatch.
129-
test.skip('write checkpoint flow in cosmosDbMode', async () => {
135+
test('write checkpoint flow in cosmosDbMode', async () => {
130136
await using context = await openContext();
131137
const { db } = context;
132138
await context.updateSyncRules(BASIC_SYNC_RULES);
@@ -160,9 +166,10 @@ bucket_definitions:
160166
storage: context.factory
161167
});
162168

163-
// The write checkpoint should resolve with a valid checkpoint number
169+
// The write checkpoint should resolve with a valid result
164170
expect(result).toBeTruthy();
165-
expect(typeof result).toBe('bigint');
171+
expect(result.writeCheckpoint).toBeTruthy();
172+
expect(result.replicationHead).toBeTruthy();
166173
});
167174

168175
test('resume after restart in cosmosDbMode', async () => {

0 commit comments

Comments
 (0)