@powersync/service-module-mongodb 0.21.2 → 0.22.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 +51 -0
- package/benchmarks/env.ts +11 -0
- package/benchmarks/replication_branch_throughput.benchmark.ts +240 -0
- package/dist/api/MongoRouteAPIAdapter.d.ts +6 -0
- package/dist/api/MongoRouteAPIAdapter.js +21 -2
- package/dist/api/MongoRouteAPIAdapter.js.map +1 -1
- 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 -1
- package/dist/module/MongoModule.js +1 -0
- package/dist/module/MongoModule.js.map +1 -1
- package/dist/replication/ChangeStream.d.ts +13 -3
- package/dist/replication/ChangeStream.js +130 -196
- package/dist/replication/ChangeStream.js.map +1 -1
- package/dist/replication/ChangeStreamReplicationJob.d.ts +4 -1
- package/dist/replication/ChangeStreamReplicationJob.js +19 -8
- package/dist/replication/ChangeStreamReplicationJob.js.map +1 -1
- package/dist/replication/ChangeStreamReplicator.d.ts +4 -0
- package/dist/replication/ChangeStreamReplicator.js +6 -0
- package/dist/replication/ChangeStreamReplicator.js.map +1 -1
- package/dist/replication/MongoReplicationQueryProvider.d.ts +85 -0
- package/dist/replication/MongoReplicationQueryProvider.js +23 -0
- package/dist/replication/MongoReplicationQueryProvider.js.map +1 -0
- package/dist/replication/MongoReplicationStream.d.ts +74 -0
- package/dist/replication/MongoReplicationStream.js +228 -0
- package/dist/replication/MongoReplicationStream.js.map +1 -0
- package/dist/replication/MongoReplicationStreamTypes.d.ts +157 -0
- package/dist/replication/MongoReplicationStreamTypes.js +19 -0
- package/dist/replication/MongoReplicationStreamTypes.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 +90 -88
- 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/estimateFilteredSnapshotCount.d.ts +12 -0
- package/dist/replication/estimateFilteredSnapshotCount.js +79 -0
- package/dist/replication/estimateFilteredSnapshotCount.js.map +1 -0
- package/dist/replication/replication-index.d.ts +7 -0
- package/dist/replication/replication-index.js +7 -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 +390 -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 +11 -10
- package/src/api/MongoRouteAPIAdapter.ts +26 -2
- package/src/index.ts +1 -0
- package/src/module/MongoModule.ts +2 -1
- package/src/replication/ChangeStream.ts +143 -226
- package/src/replication/ChangeStreamReplicationJob.ts +21 -9
- package/src/replication/ChangeStreamReplicator.ts +9 -0
- package/src/replication/MongoReplicationQueryProvider.ts +106 -0
- package/src/replication/MongoReplicationStream.ts +314 -0
- package/src/replication/MongoReplicationStreamTypes.ts +169 -0
- package/src/replication/MongoSnapshotQuery.ts +19 -3
- package/src/replication/MongoSnapshotter.ts +101 -92
- package/src/replication/RawChangeStream.ts +2 -2
- package/src/replication/estimateFilteredSnapshotCount.ts +93 -0
- package/src/replication/replication-index.ts +7 -0
- package/src/test-utils/ChangeStreamTestContext.ts +520 -0
- package/src/test-utils/test-utils-index.ts +1 -0
- package/test/src/change_event_memory.test.ts +33 -0
- package/test/src/change_stream.test.ts +3 -3
- package/test/src/change_stream_abort.test.ts +110 -0
- 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/filtering_example.test.ts +65 -0
- package/test/src/filtering_lifecycle.test.ts +101 -0
- package/test/src/query_provider_availability.test.ts +77 -0
- package/test/src/query_provider_diagnostics.test.ts +55 -0
- package/test/src/replication_stream.test.ts +688 -0
- package/test/src/resume.test.ts +10 -4
- package/test/src/resuming_snapshots.test.ts +3 -3
- package/test/src/slow_tests.test.ts +3 -3
- package/test/src/snapshot_count.test.ts +146 -0
- package/test/src/snapshot_count_integration.test.ts +70 -0
- package/test/src/snapshot_query_provider.test.ts +122 -0
- package/test/src/stream_progress.test.ts +334 -0
- package/test/tsconfig.json +2 -2
- package/tsconfig.tsbuildinfo +1 -1
- package/vitest.benchmark.config.ts +15 -0
- package/test/src/change_stream_utils.ts +0 -375
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
import { mongo } from '@powersync/lib-service-mongodb';
|
|
2
|
+
import { ReplicationAbortedError } from '@powersync/lib-services-framework';
|
|
3
|
+
import {
|
|
4
|
+
BucketDataRequest,
|
|
5
|
+
BucketStorageFactory,
|
|
6
|
+
createCoreReplicationMetrics,
|
|
7
|
+
initializeCoreReplicationMetrics,
|
|
8
|
+
InternalOpId,
|
|
9
|
+
isBatchEnd,
|
|
10
|
+
LEGACY_STORAGE_VERSION,
|
|
11
|
+
OplogEntry,
|
|
12
|
+
ProtocolOpId,
|
|
13
|
+
ReplicationCheckpoint,
|
|
14
|
+
settledPromise,
|
|
15
|
+
storage,
|
|
16
|
+
SyncRulesBucketStorage,
|
|
17
|
+
TestStorageFactory,
|
|
18
|
+
unsettledPromise,
|
|
19
|
+
updateSyncRulesFromConfig,
|
|
20
|
+
utils
|
|
21
|
+
} from '@powersync/service-core';
|
|
22
|
+
import { SqlSyncRules } from '@powersync/service-sync-rules';
|
|
23
|
+
import { setTimeout } from 'node:timers/promises';
|
|
24
|
+
|
|
25
|
+
import { SentinelLSN } from '../common/SentinelLSN.js';
|
|
26
|
+
import { ChangeStream, ChangeStreamOptions } from '../replication/ChangeStream.js';
|
|
27
|
+
import { MongoManager } from '../replication/MongoManager.js';
|
|
28
|
+
import {
|
|
29
|
+
createCheckpoint,
|
|
30
|
+
createSentinelCheckpointLsn,
|
|
31
|
+
STANDALONE_CHECKPOINT_ID
|
|
32
|
+
} from '../replication/MongoRelation.js';
|
|
33
|
+
import { detectDocumentDb } from '../replication/replication-utils.js';
|
|
34
|
+
import { NormalizedMongoConnectionConfig } from '../types/types.js';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Stream settings callers may override while the context owns connections, storage, metrics and cancellation.
|
|
38
|
+
*/
|
|
39
|
+
export type TestChangeStreamOptions = Omit<ChangeStreamOptions, 'connections' | 'storage' | 'metrics' | 'abort_signal'>;
|
|
40
|
+
|
|
41
|
+
export interface ChangeStreamTestContextOptions {
|
|
42
|
+
factory: TestStorageFactory;
|
|
43
|
+
connectionOptions: NormalizedMongoConnectionConfig;
|
|
44
|
+
metrics: ChangeStreamOptions['metrics'];
|
|
45
|
+
/**
|
|
46
|
+
* Preserve both source data and bucket storage when reopening an interrupted replication attempt.
|
|
47
|
+
*/
|
|
48
|
+
doNotClear?: boolean;
|
|
49
|
+
storageVersion?: number;
|
|
50
|
+
streamOptions?: TestChangeStreamOptions;
|
|
51
|
+
/**
|
|
52
|
+
* Defaults to detecting DocumentDB from the source's hello response.
|
|
53
|
+
*/
|
|
54
|
+
documentDbMode?: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface ChangeStreamTestContextResources {
|
|
58
|
+
factory: BucketStorageFactory;
|
|
59
|
+
connectionManager: MongoManager;
|
|
60
|
+
metrics: ChangeStreamOptions['metrics'];
|
|
61
|
+
storageVersion?: number;
|
|
62
|
+
streamOptions?: TestChangeStreamOptions;
|
|
63
|
+
documentDbMode?: boolean;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Owns source/storage connections and real replication. Callers supply their environment and storage implementation.
|
|
68
|
+
*/
|
|
69
|
+
export class ChangeStreamTestContext implements AsyncDisposable {
|
|
70
|
+
private _walStream?: ChangeStream;
|
|
71
|
+
private abortController = new AbortController();
|
|
72
|
+
private settledReplicationPromise?: Promise<PromiseSettledResult<void>>;
|
|
73
|
+
private syncRulesContent?: storage.PersistedSyncConfigContent;
|
|
74
|
+
private readonly replicationLeases = new Map<number, Promise<storage.ReplicationLock>>();
|
|
75
|
+
public storage?: SyncRulesBucketStorage;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Open a fresh fixture by default; use doNotClear to resume its persisted source and storage state.
|
|
79
|
+
*/
|
|
80
|
+
static async open(options: ChangeStreamTestContextOptions): Promise<ChangeStreamTestContext> {
|
|
81
|
+
return this.openWith({ options, createContext: (resources) => new ChangeStreamTestContext(resources) });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Share resource setup and failure cleanup with subclasses that supply their own context and defaults.
|
|
86
|
+
*/
|
|
87
|
+
protected static async openWith<T extends ChangeStreamTestContext>({
|
|
88
|
+
options,
|
|
89
|
+
createContext
|
|
90
|
+
}: {
|
|
91
|
+
options: ChangeStreamTestContextOptions;
|
|
92
|
+
createContext: (resources: ChangeStreamTestContextResources) => T | Promise<T>;
|
|
93
|
+
}): Promise<T> {
|
|
94
|
+
const factory = await options.factory({ doNotClear: options.doNotClear });
|
|
95
|
+
try {
|
|
96
|
+
const connectionManager = new MongoManager(options.connectionOptions);
|
|
97
|
+
try {
|
|
98
|
+
if (!options.doNotClear) {
|
|
99
|
+
await connectionManager.db.dropDatabase();
|
|
100
|
+
}
|
|
101
|
+
const documentDbMode = options.documentDbMode ?? (await detectDocumentDb(connectionManager.db));
|
|
102
|
+
// Await subclass setup so failures still close the shared connections below.
|
|
103
|
+
return await createContext({ ...options, factory, connectionManager, documentDbMode });
|
|
104
|
+
} catch (error) {
|
|
105
|
+
await connectionManager.end();
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
} catch (error) {
|
|
109
|
+
await factory[Symbol.asyncDispose]();
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
readonly factory: BucketStorageFactory;
|
|
115
|
+
readonly connectionManager: MongoManager;
|
|
116
|
+
protected readonly metrics: ChangeStreamOptions['metrics'];
|
|
117
|
+
protected readonly streamOptions: TestChangeStreamOptions;
|
|
118
|
+
protected readonly storageVersion: number;
|
|
119
|
+
private readonly documentDbMode: boolean;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Takes ownership of an already configured storage factory and source connection.
|
|
123
|
+
*/
|
|
124
|
+
constructor({
|
|
125
|
+
factory,
|
|
126
|
+
connectionManager,
|
|
127
|
+
metrics,
|
|
128
|
+
streamOptions = {},
|
|
129
|
+
storageVersion = LEGACY_STORAGE_VERSION,
|
|
130
|
+
documentDbMode = false
|
|
131
|
+
}: ChangeStreamTestContextResources) {
|
|
132
|
+
this.factory = factory;
|
|
133
|
+
this.connectionManager = connectionManager;
|
|
134
|
+
this.metrics = metrics;
|
|
135
|
+
this.streamOptions = streamOptions;
|
|
136
|
+
this.storageVersion = storageVersion;
|
|
137
|
+
this.documentDbMode = documentDbMode;
|
|
138
|
+
createCoreReplicationMetrics(metrics);
|
|
139
|
+
initializeCoreReplicationMetrics(metrics);
|
|
140
|
+
const unregister = factory.registerListener({
|
|
141
|
+
beforeDispose: async () => {
|
|
142
|
+
unregister();
|
|
143
|
+
try {
|
|
144
|
+
for (const lease of this.replicationLeases.values()) {
|
|
145
|
+
await (await lease).release();
|
|
146
|
+
}
|
|
147
|
+
} finally {
|
|
148
|
+
this.replicationLeases.clear();
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Abort snapshot and/or replication, without actively closing connections.
|
|
156
|
+
*/
|
|
157
|
+
abort(cause?: Error) {
|
|
158
|
+
this.abortController.abort(cause);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Drain replication before inspecting its final persisted state or closing connections.
|
|
163
|
+
*/
|
|
164
|
+
async stop(cause?: Error) {
|
|
165
|
+
this.abort(cause);
|
|
166
|
+
await this.settledReplicationPromise;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async dispose() {
|
|
170
|
+
await this.stop(new Error('Disposing test context'));
|
|
171
|
+
try {
|
|
172
|
+
await this.factory[Symbol.asyncDispose]();
|
|
173
|
+
} finally {
|
|
174
|
+
await this.connectionManager.end();
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async [Symbol.asyncDispose]() {
|
|
179
|
+
await this.dispose();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
get client() {
|
|
183
|
+
return this.connectionManager.client;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
get db() {
|
|
187
|
+
return this.connectionManager.db;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
get connectionTag() {
|
|
191
|
+
return this.connectionManager.connectionTag;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async updateSyncRules(content: string) {
|
|
195
|
+
const replicationStream = await this.factory.updateSyncRules(
|
|
196
|
+
updateSyncRulesFromConfig(
|
|
197
|
+
SqlSyncRules.fromYaml(content, {
|
|
198
|
+
defaultSchema: this.db.databaseName,
|
|
199
|
+
throwOnError: true
|
|
200
|
+
}),
|
|
201
|
+
{ storageVersion: this.storageVersion, defaultSchema: this.db.databaseName }
|
|
202
|
+
)
|
|
203
|
+
);
|
|
204
|
+
this.syncRulesContent = replicationStream.syncConfigContent[0];
|
|
205
|
+
this.storage = await this.getWritableStorage(replicationStream);
|
|
206
|
+
return this.storage!;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async loadNextSyncRules() {
|
|
210
|
+
const syncConfig = await this.factory.getDeployingSyncConfig();
|
|
211
|
+
if (syncConfig == null) {
|
|
212
|
+
throw new Error(`Next sync config not available`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
this.syncRulesContent = syncConfig.content;
|
|
216
|
+
this.storage = await this.getWritableStorage(syncConfig.replicationStream);
|
|
217
|
+
return this.storage!;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async loadActiveSyncRules() {
|
|
221
|
+
const syncConfig = await this.factory.getActiveSyncConfig();
|
|
222
|
+
if (syncConfig == null) {
|
|
223
|
+
throw new Error(`Active sync config not found`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
this.syncRulesContent = syncConfig.content;
|
|
227
|
+
this.storage = await this.getWritableStorage(syncConfig.replicationStream);
|
|
228
|
+
return this.storage!;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Reuse a replication lease per stream until the owning factory is disposed.
|
|
233
|
+
*/
|
|
234
|
+
private async getWritableStorage(stream: storage.PersistedReplicationStream): Promise<SyncRulesBucketStorage> {
|
|
235
|
+
let pending = this.replicationLeases.get(stream.replicationStreamId);
|
|
236
|
+
if (pending == null) {
|
|
237
|
+
pending = stream.lock();
|
|
238
|
+
this.replicationLeases.set(stream.replicationStreamId, pending);
|
|
239
|
+
}
|
|
240
|
+
let replicationLock: storage.ReplicationLock;
|
|
241
|
+
try {
|
|
242
|
+
replicationLock = await pending;
|
|
243
|
+
} catch (error) {
|
|
244
|
+
this.replicationLeases.delete(stream.replicationStreamId);
|
|
245
|
+
throw error;
|
|
246
|
+
}
|
|
247
|
+
return this.factory.getInstance(stream, { replicationLock });
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
getSyncConfigContent(): storage.PersistedSyncConfigContent {
|
|
251
|
+
if (this.syncRulesContent == null) {
|
|
252
|
+
throw new Error('Sync config not configured - call updateSyncRules() first');
|
|
253
|
+
}
|
|
254
|
+
return this.syncRulesContent;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
get streamer() {
|
|
258
|
+
if (this.storage == null) {
|
|
259
|
+
throw new Error('updateSyncRules() first');
|
|
260
|
+
}
|
|
261
|
+
if (this._walStream) {
|
|
262
|
+
return this._walStream;
|
|
263
|
+
}
|
|
264
|
+
const options: ChangeStreamOptions = {
|
|
265
|
+
// Forward all supported stream options, including custom query providers and snapshot/storage hooks.
|
|
266
|
+
...this.streamOptions,
|
|
267
|
+
storage: this.storage,
|
|
268
|
+
metrics: this.metrics,
|
|
269
|
+
connections: this.connectionManager,
|
|
270
|
+
abort_signal: this.abortController.signal,
|
|
271
|
+
// A shorter await also keeps aborts responsive on MongoDB <= 6.0.
|
|
272
|
+
maxAwaitTimeMS: this.streamOptions.maxAwaitTimeMS ?? 200
|
|
273
|
+
};
|
|
274
|
+
this._walStream = new ChangeStream(options);
|
|
275
|
+
return this._walStream!;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async replicateSnapshot() {
|
|
279
|
+
this.settledReplicationPromise ??= settledPromise(this.streamer.replicate());
|
|
280
|
+
try {
|
|
281
|
+
await Promise.race([unsettledPromise(this.settledReplicationPromise), this.streamer.waitForInitialSnapshot()]);
|
|
282
|
+
} catch (e) {
|
|
283
|
+
if (e instanceof ReplicationAbortedError && e.cause != null) {
|
|
284
|
+
throw e.cause;
|
|
285
|
+
}
|
|
286
|
+
throw e;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* A snapshot is not consistent until streaming replication has caught up.
|
|
292
|
+
* We simulate that for tests.
|
|
293
|
+
* Do not use if there are any writes performed while doing the snapshot, as that
|
|
294
|
+
* would result in inconsistent data.
|
|
295
|
+
*/
|
|
296
|
+
async markSnapshotConsistent() {
|
|
297
|
+
let checkpoint: string;
|
|
298
|
+
if (this.documentDbMode) {
|
|
299
|
+
const sentinelCheckpoint = SentinelLSN.fromSerialized(await createSentinelCheckpointLsn(this.client, this.db));
|
|
300
|
+
const status = await this.storage!.getStatus();
|
|
301
|
+
const resumeFrom = status.resumeLsn;
|
|
302
|
+
const resumeToken = resumeFrom ? SentinelLSN.fromSerialized(resumeFrom).resumeToken : null;
|
|
303
|
+
|
|
304
|
+
// This helper artificially marks the snapshot as consistent without
|
|
305
|
+
// waiting for the stream to observe the sentinel. Keep the sentinel as the
|
|
306
|
+
// comparable position, but carry forward the existing snapshot resume
|
|
307
|
+
// token so later DocumentDB streaming still resumes from a real token.
|
|
308
|
+
checkpoint = new SentinelLSN({
|
|
309
|
+
sentinel: sentinelCheckpoint.sentinel,
|
|
310
|
+
resume_token: resumeToken
|
|
311
|
+
}).comparable;
|
|
312
|
+
} else {
|
|
313
|
+
checkpoint = await createCheckpoint(this.db, STANDALONE_CHECKPOINT_ID);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
await using writer = await this.storage!.createWriter({
|
|
317
|
+
zeroLSN: '0000000000000000',
|
|
318
|
+
defaultSchema: this.db.databaseName,
|
|
319
|
+
storeCurrentData: true
|
|
320
|
+
});
|
|
321
|
+
await writer.keepalive(checkpoint);
|
|
322
|
+
await writer.flush();
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
startStreaming() {
|
|
326
|
+
this.settledReplicationPromise ??= settledPromise(this.streamer.replicate());
|
|
327
|
+
return this.settledReplicationPromise;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Wait for this stream to publish a real source marker; stop polling if replication exits first.
|
|
332
|
+
*/
|
|
333
|
+
async getCheckpoint(options?: { timeout?: number }): Promise<ReplicationCheckpoint> {
|
|
334
|
+
if (this.settledReplicationPromise == null) {
|
|
335
|
+
throw new Error('Start replication before requesting a checkpoint.');
|
|
336
|
+
}
|
|
337
|
+
const polling = new AbortController();
|
|
338
|
+
const wait = getClientCheckpoint({
|
|
339
|
+
client: this.client,
|
|
340
|
+
db: this.db,
|
|
341
|
+
storageFactory: this.factory,
|
|
342
|
+
storage: this.storage,
|
|
343
|
+
timeout: options?.timeout ?? 15_000,
|
|
344
|
+
documentDbMode: this.documentDbMode,
|
|
345
|
+
signal: polling.signal
|
|
346
|
+
});
|
|
347
|
+
try {
|
|
348
|
+
return await Promise.race([
|
|
349
|
+
wait,
|
|
350
|
+
unsettledPromise(this.settledReplicationPromise).then(() => {
|
|
351
|
+
throw new Error('Replication exited before publishing the requested checkpoint.');
|
|
352
|
+
})
|
|
353
|
+
]);
|
|
354
|
+
} finally {
|
|
355
|
+
polling.abort();
|
|
356
|
+
await wait.catch(() => {});
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async getBucketsDataBatch(buckets: Record<string, InternalOpId>, options?: { timeout?: number }) {
|
|
361
|
+
let checkpoint = await this.getCheckpoint(options);
|
|
362
|
+
const map = Object.entries(buckets).map(([bucket, start]) => this.bucketRequest(bucket, start));
|
|
363
|
+
const chunks = [];
|
|
364
|
+
for await (const chunk of this.storage!.getBucketDataBatch(checkpoint, map)) {
|
|
365
|
+
chunks.push(chunk);
|
|
366
|
+
}
|
|
367
|
+
return chunks;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async getBucketData(bucket: string, start?: ProtocolOpId | InternalOpId | undefined, options?: { timeout?: number }) {
|
|
371
|
+
const checkpoint = await this.getCheckpoint(options);
|
|
372
|
+
return this.getBucketDataAtCheckpoint(bucket, checkpoint, start);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async getBucketDataAtLatestCheckpoint(bucket: string, start?: ProtocolOpId | InternalOpId | undefined) {
|
|
376
|
+
if (this.storage == null) {
|
|
377
|
+
throw new Error('updateSyncRules() first');
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const checkpoint = await this.storage.getCheckpoint();
|
|
381
|
+
return this.getBucketDataAtCheckpoint(bucket, checkpoint, start);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
async getBucketDataAtCheckpoint(
|
|
385
|
+
bucket: string,
|
|
386
|
+
checkpoint: ReplicationCheckpoint,
|
|
387
|
+
start?: ProtocolOpId | InternalOpId | undefined
|
|
388
|
+
) {
|
|
389
|
+
start ??= 0n;
|
|
390
|
+
if (typeof start == 'string') {
|
|
391
|
+
start = BigInt(start);
|
|
392
|
+
}
|
|
393
|
+
let map = [this.bucketRequest(bucket, start)];
|
|
394
|
+
let data: OplogEntry[] = [];
|
|
395
|
+
while (true) {
|
|
396
|
+
const batch = this.storage!.getBucketDataBatch(checkpoint, map);
|
|
397
|
+
|
|
398
|
+
const chunks = [];
|
|
399
|
+
for await (const chunk of batch) {
|
|
400
|
+
chunks.push(chunk);
|
|
401
|
+
}
|
|
402
|
+
if (chunks.length == 0) {
|
|
403
|
+
break;
|
|
404
|
+
}
|
|
405
|
+
for (let chunk of chunks) {
|
|
406
|
+
if (isBatchEnd(chunk)) {
|
|
407
|
+
if (!chunk.hasMore) {
|
|
408
|
+
return data;
|
|
409
|
+
}
|
|
410
|
+
} else {
|
|
411
|
+
data = data.concat(chunk.chunkData.data ?? []);
|
|
412
|
+
map = [this.bucketRequest(bucket, BigInt(chunk.chunkData.next_after))];
|
|
413
|
+
if (!chunk.chunkData.has_more) {
|
|
414
|
+
return data;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
return data;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Resolve a readable test bucket such as global[] to its persisted, versioned bucket name.
|
|
424
|
+
*/
|
|
425
|
+
private bucketRequest(bucket: string, start: InternalOpId): BucketDataRequest {
|
|
426
|
+
const parsed = this.getSyncConfigContent().parsed({ defaultSchema: this.db.databaseName });
|
|
427
|
+
const parameterStart = bucket.indexOf('[');
|
|
428
|
+
const definitionName = bucket.substring(0, parameterStart);
|
|
429
|
+
const source = parsed.syncConfigs
|
|
430
|
+
.flatMap((config) => config.config.bucketDataSources)
|
|
431
|
+
.find((source) => source.uniqueName == definitionName);
|
|
432
|
+
if (source == null) {
|
|
433
|
+
throw new Error(`Unknown bucket '${bucket}'.`);
|
|
434
|
+
}
|
|
435
|
+
return {
|
|
436
|
+
bucket: parsed.hydrationState.getBucketSourceScope(source).bucketPrefix + bucket.substring(parameterStart),
|
|
437
|
+
source,
|
|
438
|
+
start
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
async getChecksums(buckets: string[], options?: { timeout?: number }): Promise<utils.ChecksumMap> {
|
|
443
|
+
let checkpoint = await this.getCheckpoint(options);
|
|
444
|
+
const versionedBuckets = buckets.map((bucket) => this.bucketRequest(bucket, 0n));
|
|
445
|
+
const checksums = await this.storage!.getChecksums(checkpoint, versionedBuckets);
|
|
446
|
+
|
|
447
|
+
const unversioned: utils.ChecksumMap = new Map();
|
|
448
|
+
for (let i = 0; i < buckets.length; i++) {
|
|
449
|
+
unversioned.set(buckets[i], checksums.get(versionedBuckets[i].bucket)!);
|
|
450
|
+
}
|
|
451
|
+
return unversioned;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
async getChecksum(bucket: string, options?: { timeout?: number }) {
|
|
455
|
+
const checksums = await this.getChecksums([bucket], options);
|
|
456
|
+
return checksums.get(bucket);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
export async function getClientCheckpoint({
|
|
461
|
+
client,
|
|
462
|
+
db,
|
|
463
|
+
storageFactory,
|
|
464
|
+
timeout = 50_000,
|
|
465
|
+
documentDbMode: forcedDocumentDbMode,
|
|
466
|
+
storage: expectedStorage,
|
|
467
|
+
signal
|
|
468
|
+
}: {
|
|
469
|
+
client: mongo.MongoClient;
|
|
470
|
+
db: mongo.Db;
|
|
471
|
+
storageFactory: BucketStorageFactory;
|
|
472
|
+
timeout?: number;
|
|
473
|
+
documentDbMode?: boolean;
|
|
474
|
+
/**
|
|
475
|
+
* Ignore checkpoints from another active stream while this one is still deploying.
|
|
476
|
+
*/
|
|
477
|
+
storage?: SyncRulesBucketStorage;
|
|
478
|
+
signal?: AbortSignal;
|
|
479
|
+
}): Promise<ReplicationCheckpoint> {
|
|
480
|
+
const start = Date.now();
|
|
481
|
+
const documentDbMode = forcedDocumentDbMode ?? (await detectDocumentDb(db));
|
|
482
|
+
|
|
483
|
+
const lsn = documentDbMode
|
|
484
|
+
? await createSentinelCheckpointLsn(client, db)
|
|
485
|
+
: await createCheckpoint(db, STANDALONE_CHECKPOINT_ID);
|
|
486
|
+
// The marker establishes a source position. Wait until replication publishes a client-visible checkpoint past it.
|
|
487
|
+
|
|
488
|
+
// DocumentDB: the streaming loop skips standalone checkpoint events while a
|
|
489
|
+
// batch barrier is pending (see ChangeStream.ts), so a single sentinel bump
|
|
490
|
+
// can be missed on an idle stream with no later event to advance the
|
|
491
|
+
// checkpoint. Periodically re-bump the sentinel so a standalone event
|
|
492
|
+
// eventually commits past `lsn`, mirroring getSnapshotLsn's retry loop.
|
|
493
|
+
const NUDGE_INTERVAL_MS = 1000;
|
|
494
|
+
let lastNudge = Date.now();
|
|
495
|
+
let lastCp: ReplicationCheckpoint | null = null;
|
|
496
|
+
|
|
497
|
+
while (Date.now() - start < timeout) {
|
|
498
|
+
signal?.throwIfAborted();
|
|
499
|
+
const activeStorage = (await storageFactory.getActiveSyncConfig())?.storage;
|
|
500
|
+
const cp =
|
|
501
|
+
expectedStorage == null || activeStorage?.replicationStreamId == expectedStorage.replicationStreamId
|
|
502
|
+
? await activeStorage?.getCheckpoint()
|
|
503
|
+
: undefined;
|
|
504
|
+
if (cp != null) {
|
|
505
|
+
lastCp = cp;
|
|
506
|
+
if (cp.lsn && cp.lsn >= lsn) {
|
|
507
|
+
return cp;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
if (documentDbMode && Date.now() - lastNudge >= NUDGE_INTERVAL_MS) {
|
|
512
|
+
await createSentinelCheckpointLsn(client, db);
|
|
513
|
+
lastNudge = Date.now();
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
await setTimeout(30, undefined, { signal });
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
throw new Error(`Timeout while waiting for checkpoint ${lsn}. Last checkpoint: ${lastCp?.lsn}`);
|
|
520
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './ChangeStreamTestContext.js';
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { parseChangeDocument } from '@module/replication/RawChangeStream.js';
|
|
2
|
+
import { mongo } from '@powersync/lib-service-mongodb';
|
|
3
|
+
import { expect, test } from 'vitest';
|
|
4
|
+
|
|
5
|
+
test('parsed document images share the raw event allocation', () => {
|
|
6
|
+
const raw = createRawEvent('first');
|
|
7
|
+
const parsed = parseChangeDocument(raw);
|
|
8
|
+
if (parsed.operationType != 'update') throw new Error('Expected an update');
|
|
9
|
+
|
|
10
|
+
for (const image of [parsed.fullDocument, parsed.fullDocumentBeforeChange]) {
|
|
11
|
+
expect(Buffer.isBuffer(image)).toBe(true);
|
|
12
|
+
expect(image!.buffer).toBe(raw.buffer);
|
|
13
|
+
expect(image!.byteOffset).toBeGreaterThan(raw.byteOffset);
|
|
14
|
+
expect(image!.byteOffset + image!.byteLength).toBeLessThanOrEqual(raw.byteOffset + raw.byteLength);
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Allocate each event independently so Node's shared small-buffer pool cannot explain retained memory.
|
|
20
|
+
*/
|
|
21
|
+
function createRawEvent(id: string): Buffer {
|
|
22
|
+
const encoded = mongo.BSON.serialize({
|
|
23
|
+
_id: { _data: id },
|
|
24
|
+
operationType: 'update',
|
|
25
|
+
ns: { db: 'app', coll: 'documents' },
|
|
26
|
+
documentKey: { _id: id },
|
|
27
|
+
fullDocument: { _id: id, body: 'x'.repeat(16_384) },
|
|
28
|
+
fullDocumentBeforeChange: { _id: id, body: 'y'.repeat(16_384) }
|
|
29
|
+
});
|
|
30
|
+
const raw = Buffer.alloc(encoded.byteLength);
|
|
31
|
+
raw.set(encoded);
|
|
32
|
+
return raw;
|
|
33
|
+
}
|
|
@@ -8,7 +8,7 @@ import { test_utils } from '@powersync/service-core-tests';
|
|
|
8
8
|
|
|
9
9
|
import { MongoRouteAPIAdapter } from '@module/api/MongoRouteAPIAdapter.js';
|
|
10
10
|
import { PostImagesOption } from '@module/types/types.js';
|
|
11
|
-
import {
|
|
11
|
+
import { openChangeStreamTestContext } from './change_stream_test_setup.js';
|
|
12
12
|
import { DATABASE_TYPE, DatabaseType } from './DatabaseType.js';
|
|
13
13
|
import { testTimeout } from './test-timeouts.js';
|
|
14
14
|
import { describeWithStorage, StorageVersionTestContext, TEST_CONNECTION_OPTIONS } from './util.js';
|
|
@@ -27,8 +27,8 @@ describe('change stream', () => {
|
|
|
27
27
|
function defineChangeStreamTests({ factory, storageVersion }: StorageVersionTestContext) {
|
|
28
28
|
const supportsConcurrentSnapshots = storageVersion >= 3;
|
|
29
29
|
|
|
30
|
-
const openContext = (options?: Parameters<typeof
|
|
31
|
-
return
|
|
30
|
+
const openContext = (options?: Parameters<typeof openChangeStreamTestContext>[1]) => {
|
|
31
|
+
return openChangeStreamTestContext(factory, { ...options, storageVersion });
|
|
32
32
|
};
|
|
33
33
|
// DocumentDB does not support changeStreamPreAndPostImages, which this test requires via postImages: READ_ONLY.
|
|
34
34
|
test.skipIf(DATABASE_TYPE == DatabaseType.DOCUMENTDB)('replicating basic values', async () => {
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { DEFAULT_MONGO_REPLICATION_QUERY_PROVIDER } from '@module/replication/MongoReplicationQueryProvider.js';
|
|
2
|
+
import { createMongoReplicationStreamBatch } from '@module/replication/MongoReplicationStream.js';
|
|
3
|
+
import type { MongoReplicationStreamBatch } from '@module/replication/MongoReplicationStreamTypes.js';
|
|
4
|
+
import { ReplicationAbortedError } from '@powersync/lib-services-framework';
|
|
5
|
+
import { describe, expect, test } from 'vitest';
|
|
6
|
+
import { openChangeStreamTestContext } from './change_stream_test_setup.js';
|
|
7
|
+
import { DATABASE_TYPE, DatabaseType } from './DatabaseType.js';
|
|
8
|
+
import { describeWithStorage } from './util.js';
|
|
9
|
+
|
|
10
|
+
type AbortPoint = 'mid-batch' | 'after-last-event';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Abort replication from inside event processing, using real source changes and bucket storage.
|
|
14
|
+
* The abort fires while the consumer holds an event, so it lands between the parser's own abort checks.
|
|
15
|
+
*/
|
|
16
|
+
describe.skipIf(DATABASE_TYPE == DatabaseType.DOCUMENTDB)('MongoDB change stream abort during a batch', () => {
|
|
17
|
+
describeWithStorage({ timeout: 30_000 }, ({ factory, storageVersion }) => {
|
|
18
|
+
test.each<AbortPoint>(['mid-batch', 'after-last-event'])('stops replication when aborted %s', async (point) => {
|
|
19
|
+
{
|
|
20
|
+
await using context = await openChangeStreamTestContext(factory, { storageVersion });
|
|
21
|
+
await context.updateSyncRules(/* yaml */ `
|
|
22
|
+
# Sync config fixture.
|
|
23
|
+
config:
|
|
24
|
+
edition: 3
|
|
25
|
+
streams:
|
|
26
|
+
global:
|
|
27
|
+
query: SELECT _id AS id FROM documents
|
|
28
|
+
`);
|
|
29
|
+
await context.db.createCollection('documents');
|
|
30
|
+
await context.replicateSnapshot();
|
|
31
|
+
await context.getCheckpoint();
|
|
32
|
+
await context.stop();
|
|
33
|
+
await context.db
|
|
34
|
+
.collection<{ _id: number }>('documents')
|
|
35
|
+
.insertMany(Array.from({ length: 20 }, (_, i) => ({ _id: i + 1 })));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const abortCause = new Error('test abort');
|
|
39
|
+
let abort: (cause: Error) => void = () => {};
|
|
40
|
+
let triggered = false;
|
|
41
|
+
let resumeBeforeAbortedBatch: string | null | undefined;
|
|
42
|
+
let readResumeLsn: () => Promise<string | null | undefined>;
|
|
43
|
+
let sawDocument = false;
|
|
44
|
+
const provider = {
|
|
45
|
+
...DEFAULT_MONGO_REPLICATION_QUERY_PROVIDER,
|
|
46
|
+
openChangeStream: ({ open }: Parameters<typeof DEFAULT_MONGO_REPLICATION_QUERY_PROVIDER.openChangeStream>[0]) =>
|
|
47
|
+
(async function* (): AsyncGenerator<MongoReplicationStreamBatch> {
|
|
48
|
+
for await (const batch of open({})) {
|
|
49
|
+
const resumeBeforeBatch = await readResumeLsn();
|
|
50
|
+
yield createMongoReplicationStreamBatch({
|
|
51
|
+
events: (function* () {
|
|
52
|
+
for (const change of batch.events) {
|
|
53
|
+
if ('ns' in change.event && change.event.ns.coll == 'documents') {
|
|
54
|
+
sawDocument = true;
|
|
55
|
+
}
|
|
56
|
+
yield change;
|
|
57
|
+
// Runs when the consumer asks for the next event, after it has processed this one.
|
|
58
|
+
const atPoint = point == 'mid-batch' ? change.hasBufferedChanges : !change.hasBufferedChanges;
|
|
59
|
+
if (!triggered && sawDocument && atPoint) {
|
|
60
|
+
triggered = true;
|
|
61
|
+
resumeBeforeAbortedBatch = resumeBeforeBatch;
|
|
62
|
+
abort(abortCause);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
})(),
|
|
66
|
+
resolveResumeToken: () => batch.getResumeToken(),
|
|
67
|
+
resolveMetadata: () => batch.metadata
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
})()
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
{
|
|
74
|
+
await using context = await openChangeStreamTestContext(factory, {
|
|
75
|
+
storageVersion,
|
|
76
|
+
doNotClear: true,
|
|
77
|
+
streamOptions: { createReplicationQueryProvider: () => provider }
|
|
78
|
+
});
|
|
79
|
+
readResumeLsn = async () => (await context.storage!.getStatus()).resumeLsn;
|
|
80
|
+
abort = (cause) => context.abort(cause);
|
|
81
|
+
await context.loadActiveSyncRules();
|
|
82
|
+
const result = await context.startStreaming();
|
|
83
|
+
|
|
84
|
+
expect(triggered).toBe(true);
|
|
85
|
+
expect(result.status).toBe('rejected');
|
|
86
|
+
if (result.status != 'rejected') {
|
|
87
|
+
throw new Error('Expected replication to abort');
|
|
88
|
+
}
|
|
89
|
+
if (point == 'after-last-event') {
|
|
90
|
+
// Every event of the batch was processed, so the consumer's own abort check ends the stream
|
|
91
|
+
// before it persists the batch's resume token.
|
|
92
|
+
expect(result.reason).toBeInstanceOf(ReplicationAbortedError);
|
|
93
|
+
}
|
|
94
|
+
const cause = result.reason instanceof ReplicationAbortedError ? result.reason.cause : result.reason;
|
|
95
|
+
expect(cause).toBe(abortCause);
|
|
96
|
+
expect(resumeBeforeAbortedBatch).toBeTruthy();
|
|
97
|
+
expect((await context.storage!.getStatus()).resumeLsn).toBe(resumeBeforeAbortedBatch);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Reopen the real storage after the interrupted batch and verify all source rows are recovered.
|
|
101
|
+
await using resumed = await openChangeStreamTestContext(factory, { storageVersion, doNotClear: true });
|
|
102
|
+
await resumed.loadActiveSyncRules();
|
|
103
|
+
resumed.startStreaming();
|
|
104
|
+
const rows = (await resumed.getBucketData('global|0[]')).filter((op) => op.op == 'PUT');
|
|
105
|
+
expect([...new Set(rows.map((op) => op.object_id))].sort()).toEqual(
|
|
106
|
+
Array.from({ length: 20 }, (_, index) => String(index + 1)).sort()
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
});
|