@powersync/service-module-mongodb 0.0.0-dev-20260909133214 → 0.0.0-dev-20260929095716
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 +38 -6
- package/ci/test-connection.yaml +12 -0
- package/dist/api/MongoRouteAPIAdapter.d.ts +2 -2
- package/dist/api/MongoRouteAPIAdapter.js +73 -143
- package/dist/api/MongoRouteAPIAdapter.js.map +1 -1
- package/dist/api/infer-collection-schema.d.ts +12 -0
- package/dist/api/infer-collection-schema.js +142 -0
- package/dist/api/infer-collection-schema.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/module/MongoModule.d.ts +1 -2
- package/dist/module/MongoModule.js +0 -1
- package/dist/module/MongoModule.js.map +1 -1
- package/dist/replication/ChangeStream.d.ts +10 -2
- package/dist/replication/ChangeStream.js +228 -317
- package/dist/replication/ChangeStream.js.map +1 -1
- package/dist/replication/ChangeStreamReplicationJob.d.ts +4 -1
- package/dist/replication/ChangeStreamReplicationJob.js +4 -1
- package/dist/replication/ChangeStreamReplicationJob.js.map +1 -1
- package/dist/replication/ChangeStreamReplicator.d.ts +3 -0
- package/dist/replication/ChangeStreamReplicator.js +3 -0
- package/dist/replication/ChangeStreamReplicator.js.map +1 -1
- package/dist/replication/MongoRelation.js +5 -2
- package/dist/replication/MongoRelation.js.map +1 -1
- package/dist/replication/MongoReplicationQueryProvider.d.ts +87 -0
- package/dist/replication/MongoReplicationQueryProvider.js +23 -0
- package/dist/replication/MongoReplicationQueryProvider.js.map +1 -0
- package/dist/replication/MongoReplicationStream.d.ts +85 -0
- package/dist/replication/MongoReplicationStream.js +101 -0
- package/dist/replication/MongoReplicationStream.js.map +1 -0
- package/dist/replication/MongoSnapshotQuery.d.ts +2 -0
- package/dist/replication/MongoSnapshotQuery.js +12 -2
- package/dist/replication/MongoSnapshotQuery.js.map +1 -1
- package/dist/replication/MongoSnapshotter.d.ts +7 -2
- package/dist/replication/MongoSnapshotter.js +58 -85
- package/dist/replication/MongoSnapshotter.js.map +1 -1
- package/dist/replication/RawChangeStream.d.ts +1 -1
- package/dist/replication/RawChangeStream.js +1 -1
- package/dist/replication/RawChangeStream.js.map +1 -1
- package/dist/replication/SourceRowConverter.d.ts +1 -0
- package/dist/replication/SourceRowConverter.js +4 -2
- package/dist/replication/SourceRowConverter.js.map +1 -1
- package/dist/replication/bufferToSqlite.d.ts +3 -1
- package/dist/replication/bufferToSqlite.js +35 -23
- package/dist/replication/bufferToSqlite.js.map +1 -1
- package/dist/replication/replication-index.d.ts +6 -0
- package/dist/replication/replication-index.js +6 -0
- package/dist/replication/replication-index.js.map +1 -1
- package/dist/test-utils/ChangeStreamTestContext.d.ts +131 -0
- package/dist/test-utils/ChangeStreamTestContext.js +391 -0
- package/dist/test-utils/ChangeStreamTestContext.js.map +1 -0
- package/dist/test-utils/test-utils-index.d.ts +1 -0
- package/dist/test-utils/test-utils-index.js +2 -0
- package/dist/test-utils/test-utils-index.js.map +1 -0
- package/package.json +8 -8
- package/src/api/MongoRouteAPIAdapter.ts +79 -127
- package/src/api/infer-collection-schema.ts +153 -0
- package/src/index.ts +1 -0
- package/src/module/MongoModule.ts +1 -3
- package/src/replication/ChangeStream.ts +253 -359
- package/src/replication/ChangeStreamReplicationJob.ts +7 -2
- package/src/replication/ChangeStreamReplicator.ts +5 -0
- package/src/replication/MongoRelation.ts +8 -1
- package/src/replication/MongoReplicationQueryProvider.ts +108 -0
- package/src/replication/MongoReplicationStream.ts +201 -0
- package/src/replication/MongoSnapshotQuery.ts +19 -3
- package/src/replication/MongoSnapshotter.ts +70 -92
- package/src/replication/RawChangeStream.ts +2 -2
- package/src/replication/SourceRowConverter.ts +4 -2
- package/src/replication/bufferToSqlite.ts +43 -23
- package/src/replication/replication-index.ts +6 -0
- package/src/test-utils/ChangeStreamTestContext.ts +520 -0
- package/src/test-utils/test-utils-index.ts +1 -0
- package/test/src/buffer_to_sqlite.test.ts +9 -0
- package/test/src/change_stream.test.ts +3 -3
- package/test/src/change_stream_test_setup.ts +38 -0
- package/test/src/chunked_snapshot.test.ts +3 -3
- package/test/src/documentdb_mode.test.ts +3 -3
- package/test/src/replication_stream.test.ts +311 -0
- package/test/src/resume.test.ts +10 -4
- package/test/src/resuming_snapshots.test.ts +3 -3
- package/test/src/schema.test.ts +223 -0
- package/test/src/slow_tests.test.ts +3 -3
- package/test/src/snapshot_query_provider.test.ts +95 -0
- package/test/src/stream_progress.test.ts +312 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/test/src/change_stream_utils.ts +0 -375
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { DEFAULT_MONGO_REPLICATION_QUERY_PROVIDER } from '@module/replication/MongoReplicationQueryProvider.js';
|
|
2
|
+
import { describe, expect, test, vi } from 'vitest';
|
|
3
|
+
import { openChangeStreamTestContext } from './change_stream_test_setup.js';
|
|
4
|
+
import { DATABASE_TYPE, DatabaseType } from './DatabaseType.js';
|
|
5
|
+
import { describeWithStorage } from './util.js';
|
|
6
|
+
|
|
7
|
+
describe.skipIf(DATABASE_TYPE == DatabaseType.DOCUMENTDB)('MongoDB snapshot query provider', () => {
|
|
8
|
+
describeWithStorage({ timeout: 30_000 }, ({ factory, storageVersion }) => {
|
|
9
|
+
test.each([false, true])('pages snapshots with an expression filter: %s', async (filtered) => {
|
|
10
|
+
// Run the same snapshot with and without a provider predicate. The filtered case selects even
|
|
11
|
+
// ids between 2 and 12 whose label is exactly 'live'; null must preserve the full snapshot.
|
|
12
|
+
const getSnapshotFilter = vi.fn(() =>
|
|
13
|
+
filtered
|
|
14
|
+
? {
|
|
15
|
+
$expr: {
|
|
16
|
+
$and: [
|
|
17
|
+
{ $gt: ['$_id', 2] },
|
|
18
|
+
{ $lt: ['$_id', 12] },
|
|
19
|
+
{ $eq: [{ $mod: ['$_id', 2] }, 0] },
|
|
20
|
+
{ $eq: ['$label', 'live'] }
|
|
21
|
+
]
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
: null
|
|
25
|
+
);
|
|
26
|
+
await using context = await openChangeStreamTestContext(factory, {
|
|
27
|
+
storageVersion,
|
|
28
|
+
streamOptions: {
|
|
29
|
+
snapshotChunkLength: 2,
|
|
30
|
+
createReplicationQueryProvider: ({ connectionTag, defaultSchema, syncConfig }) => {
|
|
31
|
+
// Providers need the actual source namespace and parsed connection options to build their
|
|
32
|
+
// predicates. This sync config has no connection-specific options, so those are empty.
|
|
33
|
+
expect(connectionTag).toBe(context.connectionTag);
|
|
34
|
+
expect(defaultSchema).toBe(context.db.databaseName);
|
|
35
|
+
expect(syncConfig.connectionConfig).toEqual({});
|
|
36
|
+
return { ...DEFAULT_MONGO_REPLICATION_QUERY_PROVIDER, getSnapshotFilter };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
await context.updateSyncRules(/* yaml */ ` bucket_definitions:
|
|
41
|
+
global:
|
|
42
|
+
data:
|
|
43
|
+
- SELECT _id AS id, label FROM documents `);
|
|
44
|
+
// The collection treats 'LIVE' and 'live' as equal by default. Provider filters use simple
|
|
45
|
+
// collation, so id 6 must be excluded for its 'LIVE' label even though it passes the id conditions.
|
|
46
|
+
const collection = await context.db.createCollection<{ _id: number; label: string }>('documents', {
|
|
47
|
+
collation: { locale: 'en', strength: 2 }
|
|
48
|
+
});
|
|
49
|
+
await collection.insertMany(
|
|
50
|
+
Array.from({ length: 14 }, (_, i) => ({ _id: i + 1, label: (i + 1) % 3 == 0 ? 'LIVE' : 'live' }))
|
|
51
|
+
);
|
|
52
|
+
await context.replicateSnapshot();
|
|
53
|
+
const rows = (await context.getBucketData('global[]')).filter((op) => op.op == 'PUT');
|
|
54
|
+
// Matching ids 4, 8 and 10 require two pages at a page size of two. After the first page ends
|
|
55
|
+
// at id 8, pagination adds its own $expr requiring _id > 8. It must combine that condition
|
|
56
|
+
// with the provider's $expr: id 10 is included, while ids 9 and 11 through 14 remain excluded.
|
|
57
|
+
// Replacing the provider predicate with the continuation predicate would admit those extra rows.
|
|
58
|
+
// With no provider filter, pagination must return all 14 documents instead.
|
|
59
|
+
expect(rows.map((op) => op.object_id).sort()).toEqual(
|
|
60
|
+
(filtered ? [4, 8, 10] : Array.from({ length: 14 }, (_, i) => i + 1)).map(String).sort()
|
|
61
|
+
);
|
|
62
|
+
expect(getSnapshotFilter).toHaveBeenCalledWith(
|
|
63
|
+
expect.objectContaining({ schema: context.db.databaseName, name: 'documents' })
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('rejects unsupported sources before opening snapshot or streaming queries', async () => {
|
|
68
|
+
// Simulate a provider requiring a capability the source does not support. Replication must
|
|
69
|
+
// surface that validation error before asking the provider to open a stream or select snapshot rows.
|
|
70
|
+
const validateSource = vi.fn(async () => {
|
|
71
|
+
throw new Error('unsupported test source');
|
|
72
|
+
});
|
|
73
|
+
const openChangeStream = vi.fn(DEFAULT_MONGO_REPLICATION_QUERY_PROVIDER.openChangeStream);
|
|
74
|
+
const getSnapshotFilter = vi.fn(DEFAULT_MONGO_REPLICATION_QUERY_PROVIDER.getSnapshotFilter);
|
|
75
|
+
await using context = await openChangeStreamTestContext(factory, {
|
|
76
|
+
storageVersion,
|
|
77
|
+
streamOptions: {
|
|
78
|
+
createReplicationQueryProvider: () => ({ validateSource, openChangeStream, getSnapshotFilter })
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
await context.updateSyncRules(/* yaml */ ` bucket_definitions:
|
|
82
|
+
global:
|
|
83
|
+
data:
|
|
84
|
+
- SELECT _id AS id FROM documents `);
|
|
85
|
+
await expect(context.replicateSnapshot()).rejects.toThrow('unsupported test source');
|
|
86
|
+
expect(validateSource).toHaveBeenCalledWith({
|
|
87
|
+
connectionManager: context.connectionManager,
|
|
88
|
+
isDocumentDb: false
|
|
89
|
+
});
|
|
90
|
+
// Spies delegate to the real default hooks if invoked, but validation should prevent either call.
|
|
91
|
+
expect(openChangeStream).not.toHaveBeenCalled();
|
|
92
|
+
expect(getSnapshotFilter).not.toHaveBeenCalled();
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { DEFAULT_MONGO_REPLICATION_QUERY_PROVIDER } from '@module/replication/MongoReplicationQueryProvider.js';
|
|
2
|
+
import { MongoReplicationStreamItem } from '@module/replication/MongoReplicationStream.js';
|
|
3
|
+
import { describe, expect, test, vi } from 'vitest';
|
|
4
|
+
import { openChangeStreamTestContext } from './change_stream_test_setup.js';
|
|
5
|
+
import { DATABASE_TYPE, DatabaseType } from './DatabaseType.js';
|
|
6
|
+
import { describeWithStorage } from './util.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Use real source changes and bucket storage to distinguish saved recovery positions from published
|
|
10
|
+
* checkpoints. Reopening with doNotClear preserves both databases so recovery exercises persisted state.
|
|
11
|
+
*/
|
|
12
|
+
describe.skipIf(DATABASE_TYPE == DatabaseType.DOCUMENTDB)('MongoDB durable adapter progress', () => {
|
|
13
|
+
describeWithStorage({ timeout: 30_000 }, ({ factory, storageVersion }) => {
|
|
14
|
+
test('does not acknowledge retained rows when flushing a later filtered boundary fails', async () => {
|
|
15
|
+
// Finish an empty snapshot and stop replication before inserting data. The next attempt must
|
|
16
|
+
// read these inserts from the change stream, starting at the recorded durable resume position.
|
|
17
|
+
let originalLsn: string;
|
|
18
|
+
{
|
|
19
|
+
await using context = await openChangeStreamTestContext(factory, { storageVersion });
|
|
20
|
+
await context.updateSyncRules(/* yaml */ ` bucket_definitions:
|
|
21
|
+
global:
|
|
22
|
+
data:
|
|
23
|
+
- SELECT _id AS id FROM documents WHERE _id = 1 `);
|
|
24
|
+
await context.db.createCollection('documents');
|
|
25
|
+
await context.replicateSnapshot();
|
|
26
|
+
await context.getCheckpoint();
|
|
27
|
+
await context.stop();
|
|
28
|
+
originalLsn = (await context.storage!.getStatus()).resumeLsn!;
|
|
29
|
+
await context.db
|
|
30
|
+
.collection<{ _id: number }>('documents')
|
|
31
|
+
.insertMany(Array.from({ length: 20 }, (_, i) => ({ _id: i + 1 })));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Keep document 1 and discard the remaining documents in the provider. This leaves a real row
|
|
35
|
+
// write pending when the provider forwards progress covering both retained and excluded changes.
|
|
36
|
+
let failFlush = false;
|
|
37
|
+
const provider = {
|
|
38
|
+
...DEFAULT_MONGO_REPLICATION_QUERY_PROVIDER,
|
|
39
|
+
openChangeStream: ({ open }: Parameters<typeof DEFAULT_MONGO_REPLICATION_QUERY_PROVIDER.openChangeStream>[0]) =>
|
|
40
|
+
(async function* (): AsyncGenerator<MongoReplicationStreamItem> {
|
|
41
|
+
let retained = false;
|
|
42
|
+
let filtered = 0;
|
|
43
|
+
for await (const item of open({})) {
|
|
44
|
+
if (item.type == 'change' && 'documentKey' in item.event && item.event.ns.coll == 'documents') {
|
|
45
|
+
if (String(item.event.documentKey._id) != '1') {
|
|
46
|
+
filtered++;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
retained = true;
|
|
50
|
+
} else if (item.type == 'progress') {
|
|
51
|
+
// open({}) uses the shared reader, which emits progress with the batch's resume token
|
|
52
|
+
// after consuming a MongoDB batch, provided no split event is incomplete. This is a
|
|
53
|
+
// reader-generated boundary, not a MongoDB change event or a result of filtering.
|
|
54
|
+
// If a boundary arrives after retaining document 1 but before excluding any later
|
|
55
|
+
// documents, suppress it until a boundary also covers excluded changes. Forwarding
|
|
56
|
+
// that boundary triggers the injected flush failure with document 1 still pending,
|
|
57
|
+
// verifying that its write must be durable before the resume token can be saved.
|
|
58
|
+
if (retained && filtered == 0) continue;
|
|
59
|
+
yield { ...item, filteredCount: filtered };
|
|
60
|
+
retained = false;
|
|
61
|
+
filtered = 0;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
yield item;
|
|
65
|
+
}
|
|
66
|
+
})()
|
|
67
|
+
};
|
|
68
|
+
{
|
|
69
|
+
const save = vi.fn();
|
|
70
|
+
const setResumeLsn = vi.fn();
|
|
71
|
+
await using context = await openChangeStreamTestContext(factory, {
|
|
72
|
+
storageVersion,
|
|
73
|
+
doNotClear: true,
|
|
74
|
+
streamOptions: {
|
|
75
|
+
snapshotChunkLength: 5,
|
|
76
|
+
createReplicationQueryProvider: () => provider,
|
|
77
|
+
storageHooks: {
|
|
78
|
+
beforeBatchFlush: async () => {
|
|
79
|
+
if (failFlush) throw new Error('injected retained-row flush failure');
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
await context.loadActiveSyncRules();
|
|
85
|
+
context.storage!.registerListener({
|
|
86
|
+
batchStarted: (batch) => {
|
|
87
|
+
const originalSave = batch.save.bind(batch);
|
|
88
|
+
vi.spyOn(batch, 'save').mockImplementation((...args) => {
|
|
89
|
+
// Arm the failure only once a retained row reaches storage; setup flushes may succeed.
|
|
90
|
+
save();
|
|
91
|
+
failFlush = true;
|
|
92
|
+
return originalSave(...args);
|
|
93
|
+
});
|
|
94
|
+
const originalSetResumeLsn = batch.setResumeLsn.bind(batch);
|
|
95
|
+
vi.spyOn(batch, 'setResumeLsn').mockImplementation((...args) => {
|
|
96
|
+
setResumeLsn();
|
|
97
|
+
return originalSetResumeLsn(...args);
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
const result = await context.startStreaming();
|
|
102
|
+
expect(result).toMatchObject({ status: 'rejected', reason: expect.any(Error) });
|
|
103
|
+
if (result.status == 'rejected') expect(String(result.reason)).toContain('retained-row flush failure');
|
|
104
|
+
// Saving the later token despite the failed flush would skip document 1 on restart.
|
|
105
|
+
expect(save).toHaveBeenCalledOnce();
|
|
106
|
+
expect(setResumeLsn).not.toHaveBeenCalled();
|
|
107
|
+
expect((await context.storage!.getStatus()).resumeLsn).toBe(originalLsn);
|
|
108
|
+
}
|
|
109
|
+
// Reopen from the last durable token with the same selection. The retained row must be replayed.
|
|
110
|
+
await using context = await openChangeStreamTestContext(factory, {
|
|
111
|
+
storageVersion,
|
|
112
|
+
doNotClear: true,
|
|
113
|
+
streamOptions: { createReplicationQueryProvider: () => provider }
|
|
114
|
+
});
|
|
115
|
+
await context.loadActiveSyncRules();
|
|
116
|
+
context.startStreaming();
|
|
117
|
+
expect(
|
|
118
|
+
(await context.getBucketData('global[]')).filter((op) => op.op == 'PUT').map((op) => op.object_id)
|
|
119
|
+
).toEqual(['1']);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('resumes ordinary changes from a token inside a transaction', async () => {
|
|
123
|
+
// Insert one transaction after stopping replication. Its 50 events share a clusterTime but span
|
|
124
|
+
// multiple small cursor batches, allowing recovery to stop partway through the transaction.
|
|
125
|
+
{
|
|
126
|
+
await using context = await openChangeStreamTestContext(factory, { storageVersion });
|
|
127
|
+
await context.updateSyncRules(
|
|
128
|
+
'bucket_definitions:\n global:\n data:\n - SELECT _id AS id FROM documents'
|
|
129
|
+
);
|
|
130
|
+
await context.db.createCollection('documents');
|
|
131
|
+
await context.replicateSnapshot();
|
|
132
|
+
await context.getCheckpoint();
|
|
133
|
+
await context.stop();
|
|
134
|
+
const session = context.client.startSession();
|
|
135
|
+
await using sessionDisposer = { [Symbol.asyncDispose]: () => session.endSession() };
|
|
136
|
+
await session.withTransaction(async () => {
|
|
137
|
+
await context.db.collection<{ _id: string }>('documents').insertMany(
|
|
138
|
+
Array.from({ length: 50 }, (_, index) => ({ _id: String(index) })),
|
|
139
|
+
{ session }
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
{
|
|
144
|
+
let progressCount = 0;
|
|
145
|
+
await using context = await openChangeStreamTestContext(factory, {
|
|
146
|
+
storageVersion,
|
|
147
|
+
doNotClear: true,
|
|
148
|
+
streamOptions: { snapshotChunkLength: 5 }
|
|
149
|
+
});
|
|
150
|
+
await context.loadActiveSyncRules();
|
|
151
|
+
context.storage!.registerListener({
|
|
152
|
+
batchStarted: (batch) => {
|
|
153
|
+
const setResumeLsn = batch.setResumeLsn.bind(batch);
|
|
154
|
+
vi.spyOn(batch, 'setResumeLsn').mockImplementation(async (lsn) => {
|
|
155
|
+
await setResumeLsn(lsn);
|
|
156
|
+
// Abort only after the third position is persisted, so reopening uses an exact token
|
|
157
|
+
// inside the transaction rather than the position recorded before any of its inserts.
|
|
158
|
+
if (++progressCount == 3) context.abort(new Error('stop inside transaction'));
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
await context.startStreaming();
|
|
163
|
+
expect(progressCount).toBe(3);
|
|
164
|
+
}
|
|
165
|
+
// Resume with the ordinary provider. Already flushed rows and the replayed suffix must together
|
|
166
|
+
// produce all 50 inserts; resuming must not discard later events with the same timestamp.
|
|
167
|
+
await using context = await openChangeStreamTestContext(factory, { storageVersion, doNotClear: true });
|
|
168
|
+
await context.loadActiveSyncRules();
|
|
169
|
+
context.startStreaming();
|
|
170
|
+
await context.getCheckpoint();
|
|
171
|
+
const operations = await context.getBucketData('global[]');
|
|
172
|
+
// Every insert in this transaction shares a clusterTime, but each has its own resume token.
|
|
173
|
+
// A timestamp dedupe guard here would lose the unprocessed suffix after the third batch.
|
|
174
|
+
expect(
|
|
175
|
+
operations
|
|
176
|
+
.filter((op) => op.op == 'PUT')
|
|
177
|
+
.map((op) => op.object_id)
|
|
178
|
+
.sort()
|
|
179
|
+
).toEqual(Array.from({ length: 50 }, (_, index) => String(index)).sort());
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test.each(['progress', 'flush failure', 'adapter failure'] as const)(
|
|
183
|
+
'%s before the pending checkpoint arrives',
|
|
184
|
+
async (scenario) => {
|
|
185
|
+
// Exclude every document change and stop before the startup checkpoint marker is consumed.
|
|
186
|
+
// Successful boundaries should advance recovery without publishing a checkpoint; failures
|
|
187
|
+
// either before yielding progress or while flushing it must preserve the previous position.
|
|
188
|
+
let originalLsn: string;
|
|
189
|
+
{
|
|
190
|
+
await using context = await openChangeStreamTestContext(factory, { storageVersion });
|
|
191
|
+
await context.updateSyncRules(
|
|
192
|
+
'bucket_definitions:\n global:\n data:\n - SELECT _id AS id FROM documents'
|
|
193
|
+
);
|
|
194
|
+
await context.db.createCollection('documents');
|
|
195
|
+
await context.replicateSnapshot();
|
|
196
|
+
await context.getCheckpoint();
|
|
197
|
+
await context.stop();
|
|
198
|
+
originalLsn = (await context.storage!.getStatus()).resumeLsn!;
|
|
199
|
+
// All changes precede the barrier which the next replication attempt creates on startup.
|
|
200
|
+
await context.db
|
|
201
|
+
.collection<{ _id: number }>('documents')
|
|
202
|
+
.insertMany(Array.from({ length: 100 }, (_, _id) => ({ _id })));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const savedPositions: string[] = [];
|
|
206
|
+
let filtered = 0;
|
|
207
|
+
let beforeFailure: string | undefined;
|
|
208
|
+
let failFlush = false;
|
|
209
|
+
const save = vi.fn();
|
|
210
|
+
const resolve = vi.fn();
|
|
211
|
+
const commit = vi.fn();
|
|
212
|
+
await using context = await openChangeStreamTestContext(factory, {
|
|
213
|
+
storageVersion,
|
|
214
|
+
doNotClear: true,
|
|
215
|
+
streamOptions: {
|
|
216
|
+
snapshotChunkLength: 5,
|
|
217
|
+
// Keep the test inside the idle keepalive interval: filtered activity must still save
|
|
218
|
+
// progress while the checkpoint barrier is pending, rather than being treated as idle.
|
|
219
|
+
keepaliveIntervalMs: 60_000,
|
|
220
|
+
createReplicationQueryProvider: () => ({
|
|
221
|
+
...DEFAULT_MONGO_REPLICATION_QUERY_PROVIDER,
|
|
222
|
+
openChangeStream({ open }) {
|
|
223
|
+
return (async function* (): AsyncGenerator<MongoReplicationStreamItem> {
|
|
224
|
+
let count = 0;
|
|
225
|
+
for await (const item of open({})) {
|
|
226
|
+
// Drop data events but keep the shared reader's batch boundaries and any control
|
|
227
|
+
// events. Report how many complete changes each forwarded boundary excludes.
|
|
228
|
+
if (item.type == 'change' && 'ns' in item.event && item.event.ns.coll == 'documents') {
|
|
229
|
+
count++;
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
if (item.type == 'progress' && count > 0) {
|
|
233
|
+
filtered += count;
|
|
234
|
+
beforeFailure = (await context.storage!.getStatus()).resumeLsn!;
|
|
235
|
+
// The adapter error prevents delivery of the boundary. The flush error allows
|
|
236
|
+
// delivery but fails storage processing before its resume token can be saved.
|
|
237
|
+
if (scenario == 'adapter failure') throw new Error('injected adapter failure');
|
|
238
|
+
failFlush = scenario == 'flush failure';
|
|
239
|
+
yield { ...item, filteredCount: count };
|
|
240
|
+
count = 0;
|
|
241
|
+
// next() is requested only after this module has handled the progress item. Inspect actual
|
|
242
|
+
// persisted state here, without sleeps or waiting for a later checkpoint event.
|
|
243
|
+
savedPositions.push((await context.storage!.getStatus()).resumeLsn!);
|
|
244
|
+
// End the successful case deliberately while the later checkpoint marker is
|
|
245
|
+
// still pending, so assertions observe progress independently of a commit.
|
|
246
|
+
if (savedPositions.length == 3) throw new Error('stopped after three safe boundaries');
|
|
247
|
+
} else {
|
|
248
|
+
yield item;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
})();
|
|
252
|
+
}
|
|
253
|
+
})
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
await context.loadActiveSyncRules();
|
|
257
|
+
const checkpointBefore = await context.storage!.getCheckpoint();
|
|
258
|
+
context.storage!.registerListener({
|
|
259
|
+
batchStarted: (batch) => {
|
|
260
|
+
// All rows are filtered out, so storage may skip beforeBatchFlush for an empty batch.
|
|
261
|
+
// Inject at the public flush boundary to test flush-before-resume ordering on every backend.
|
|
262
|
+
const originalFlush = batch.flush.bind(batch);
|
|
263
|
+
vi.spyOn(batch, 'flush').mockImplementation(async (...args) => {
|
|
264
|
+
if (failFlush) throw new Error('injected flush failure');
|
|
265
|
+
return originalFlush(...args);
|
|
266
|
+
});
|
|
267
|
+
const originalSave = batch.save.bind(batch);
|
|
268
|
+
vi.spyOn(batch, 'save').mockImplementation((...args) => {
|
|
269
|
+
save();
|
|
270
|
+
return originalSave(...args);
|
|
271
|
+
});
|
|
272
|
+
// Observe calls while retaining the real relation resolver and commit behavior.
|
|
273
|
+
const originalResolve = batch.resolveTables.bind(batch);
|
|
274
|
+
vi.spyOn(batch, 'resolveTables').mockImplementation((...args) => {
|
|
275
|
+
resolve();
|
|
276
|
+
return originalResolve(...args);
|
|
277
|
+
});
|
|
278
|
+
const originalCommit = batch.commit.bind(batch);
|
|
279
|
+
vi.spyOn(batch, 'commit').mockImplementation((...args) => {
|
|
280
|
+
commit();
|
|
281
|
+
return originalCommit(...args);
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
const result = await context.startStreaming();
|
|
286
|
+
expect(result.status).toBe('rejected');
|
|
287
|
+
if (result.status != 'rejected') throw new Error('Expected the controlled stop');
|
|
288
|
+
expect(String(result.reason)).toContain(
|
|
289
|
+
scenario == 'progress' ? 'three safe boundaries' : `injected ${scenario}`
|
|
290
|
+
);
|
|
291
|
+
expect(filtered).toBeGreaterThan(0);
|
|
292
|
+
// Excluded changes must bypass relation lookup and row writes. No checkpoint marker should
|
|
293
|
+
// have been committed before this attempt stops, even in the successful-progress scenario.
|
|
294
|
+
expect(save).not.toHaveBeenCalled();
|
|
295
|
+
expect(resolve).not.toHaveBeenCalled();
|
|
296
|
+
expect(commit).not.toHaveBeenCalled();
|
|
297
|
+
const status = await context.storage!.getStatus();
|
|
298
|
+
if (scenario == 'progress') {
|
|
299
|
+
// Each handled boundary has its own durable position, despite producing no bucket rows.
|
|
300
|
+
expect(new Set(savedPositions).size).toBe(3);
|
|
301
|
+
expect(savedPositions.every((lsn) => lsn > originalLsn)).toBe(true);
|
|
302
|
+
expect(status.resumeLsn).toBe(savedPositions.at(-1));
|
|
303
|
+
} else {
|
|
304
|
+
// Neither failure path may acknowledge the boundary that failed to finish processing.
|
|
305
|
+
expect(status.resumeLsn).toBe(beforeFailure);
|
|
306
|
+
}
|
|
307
|
+
// Persisting source progress is not permission to expose a checkpoint past the pending barrier.
|
|
308
|
+
expect((await context.storage!.getCheckpoint()).lsn).toBe(checkpointBefore.lsn);
|
|
309
|
+
}
|
|
310
|
+
);
|
|
311
|
+
});
|
|
312
|
+
});
|