UNPKG

mongochangestream

Version:

Sync MongoDB collections via change streams into any database.

1,319 lines (1,207 loc) 40.8 kB
import { Redis } from 'ioredis' import _ from 'lodash/fp.js' import { assertEventually, genUser, initState, numDocs, schema, } from 'mongochangestream-testing' import { type ChangeStreamDocument, type ChangeStreamInsertDocument, type Document, MongoClient, ObjectId, } from 'mongodb' import ms from 'ms' import assert from 'node:assert' import { setTimeout } from 'node:timers/promises' import { type LastFlush, type QueueOptions, type QueueStats } from 'prom-utils' import { describe, test } from 'vitest' import { getKeys, initSync } from './mongoChangeStream.js' import type { CursorErrorEvent, ScanOptions, SchemaChangeEvent, SortField, StatsEvent, SyncOptions, } from './types.js' import { missingOplogEntry } from './util.js' const getConns = _.memoize(async (x?: any) => { // Memoize hack console.log(x) const redis = new Redis({ keyPrefix: 'testing:' }) const client = await MongoClient.connect(process.env.MONGO_CONN as string) const db = client.db() const coll = db.collection('testing') return { client, db, coll, redis } }) const getSync = async (options?: SyncOptions) => { const { redis, coll } = await getConns() const sync = initSync(redis, coll, options) sync.emitter.on('stateChange', console.log) return sync } describe.sequential('syncing', () => { // ------- Idempotence ------- // NOTE: This test is flaky. Having it run first seems to help :) test('stopping change stream is idempotent', async () => { const { coll, db } = await getConns() const sync = await getSync() await initState(sync, db, coll) // Change stream const processRecords = async () => { await setTimeout(5) } const changeStream = await sync.processChangeStream(processRecords) changeStream.start() // Change documents await coll.updateMany({}, { $set: { createdAt: new Date('2022-01-03') } }) // Stop twice await changeStream.stop() await changeStream.stop() }) test('starting change stream is idempotent', async () => { const sync = await getSync() // Change stream const processRecords = async () => { await setTimeout(5) } const changeStream = await sync.processChangeStream(processRecords) // Start twice changeStream.start() changeStream.start() await setTimeout(500) await changeStream.stop() }) test('starting initial scan is idempotent', async () => { const { coll, db } = await getConns() const sync = await getSync() await initState(sync, db, coll) const processRecords = async () => { await setTimeout(50) } const initialScan = await sync.runInitialScan(processRecords) // Start twice initialScan.start() initialScan.start() await setTimeout(500) await initialScan.stop() }) test('stopping initial scan is idempotent', async () => { const { coll, db } = await getConns() const sync = await getSync() await initState(sync, db, coll) const processRecords = async () => { await setTimeout(50) } const initialScan = await sync.runInitialScan(processRecords) initialScan.start() await setTimeout(1000) // Stop twice await initialScan.stop() await initialScan.stop() }) //------- Initial Scan ------- test('should complete initial scan', async () => { const { coll, db, redis } = await getConns() const sync = await getSync() await initState(sync, db, coll) const processed: unknown[] = [] const processRecords = async (docs: ChangeStreamInsertDocument[]) => { await setTimeout(50) for (const doc of docs) { // `processRecords` can mutate the documents in arbitrary ways, // including deleting the `_id`, which would cause issues with // continuing from where we last left off. // // This simulates that scenario, in order to test that the library // handles it appropriately. delete doc._id processed.push(doc) } } const scanOptions = { batchSize: 100 } const initialScan = await sync.runInitialScan(processRecords, scanOptions) // Wait for initial scan to complete await initialScan.start() assert.strictEqual(processed.length, numDocs) // Check that Redis keys are set assert.ok(await redis.get(sync.keys.lastScanIdKey)) assert.ok(await redis.get(sync.keys.lastScanProcessedAtKey)) assert.ok(await redis.get(sync.keys.scanCompletedKey)) // Stop await initialScan.stop() }) test('should emit processError after exhausting retries - initial scan', async () => { const { coll, db } = await getConns() const options: SyncOptions = { retry: { retries: 1, minTimeout: 100, }, } const sync = await getSync(options) await initState(sync, db, coll) let processErrorCount = 0 sync.emitter.on('processError', () => { processErrorCount++ }) const processRecords = async () => { await setTimeout(50) // Simulate a failure throw new Error('Fail') } const initialScan = await sync.runInitialScan(processRecords) // Wait for initial scan to complete await initialScan.start() assert.strictEqual(processErrorCount, 1) // Stop await initialScan.stop() }) test('should abort retries if stopping and not emit error - initial scan', async () => { const { coll, db } = await getConns() const options: SyncOptions = { retry: { retries: 2, minTimeout: ms('1s'), }, } const sync = await getSync(options) await initState(sync, db, coll) let processError = false sync.emitter.on('processError', () => { processError = true }) let processRecordsCount = 0 const processRecords = async () => { processRecordsCount++ await setTimeout(50) // Simulate a failure throw new Error('Fail') } const initialScan = await sync.runInitialScan(processRecords) // Start initial scan initialScan.start() await setTimeout(ms('2s')) // Stop await initialScan.stop() console.log('processRecordsCount %d', processRecordsCount) assert.ok(processRecordsCount < 3) assert.strictEqual(processError, false) }) test('should retry initial scan', async () => { const { coll, db } = await getConns() const options: SyncOptions = { retry: { minTimeout: 100, }, } const sync = await getSync(options) await initState(sync, db, coll) let counter = 0 const processed: unknown[] = [] const processRecords = async (docs: ChangeStreamInsertDocument[]) => { await setTimeout(50) // Simulate a failure on the first try if (counter++ === 0) { throw new Error('Fail') } processed.push(...docs) } const batchSize = 100 const initialScan = await sync.runInitialScan(processRecords, { batchSize }) // Wait for initial scan to complete await initialScan.start() assert.strictEqual(processed.length, numDocs) // Expect one extra call to processRecords assert.strictEqual(counter, numDocs / batchSize + 1) // Stop await initialScan.stop() }) test('should pause initial scan', async () => { const { coll, db } = await getConns() const sync = await getSync() await initState(sync, db, coll) const processed: unknown[] = [] const processRecords = async (docs: ChangeStreamInsertDocument[]) => { await setTimeout(50) processed.push(...docs) if (processed.length === 200) { sync.pausable.pause() // Call resume after 1s global.setTimeout(sync.pausable.resume, ms('1s')) } } const scanOptions = { batchSize: 100 } const initialScan = await sync.runInitialScan(processRecords, scanOptions) // Wait for initial scan to complete await initialScan.start() assert.strictEqual(processed.length, numDocs) // Stop await initialScan.stop() }) test('stopping paused initial scan works', async () => { const { coll, db } = await getConns() const sync = await getSync() await initState(sync, db, coll) const processRecords = async () => { await setTimeout(50) } const initialScan = await sync.runInitialScan(processRecords) initialScan.start() await setTimeout(200) // Pause sync.pausable.pause() // Stop await initialScan.stop() // Check that we are not paused assert.strictEqual(sync.pausable.isPaused, false) }) test('initial scan should throttle', async () => { const { coll, db } = await getConns() const sync = await getSync() await initState(sync, db, coll) let stats: QueueStats = { itemsPerSec: 0, bytesPerSec: 0 } let lastFlush: LastFlush | undefined sync.emitter.on('stats', (event: StatsEvent) => { stats = event.stats lastFlush = event.lastFlush }) const processed: unknown[] = [] const processRecords = async (docs: ChangeStreamInsertDocument[]) => { await setTimeout(50) processed.push(...docs) } const initialScan = await sync.runInitialScan(processRecords, { batchSize: 100, maxItemsPerSec: 200, maxBytesPerSec: 25000, }) // Wait for initial scan to complete await initialScan.start() assert.strictEqual(processed.length, numDocs) assert.ok(stats.itemsPerSec > 0 && stats.itemsPerSec < 250) assert.ok(stats.bytesPerSec > 0 && stats.bytesPerSec < 40000) assert.deepEqual(lastFlush, { batchSize: 100 }) // Stop await initialScan.stop() }) test('should allow parallel syncing via uniqueId option', async () => { const { coll, db } = await getConns() const sync = await getSync() const sync2 = await getSync({ uniqueId: 'v2' }) await initState(sync, db, coll) // Clear syncing state await sync2.reset() const processed: { v1: unknown[]; v2: unknown[] } = { v1: [], v2: [] } const processRecords = (version: 'v1' | 'v2') => async (docs: ChangeStreamInsertDocument[]) => { await setTimeout(50) processed[version].push(...docs) } const scanOptions = { batchSize: 100 } const initialScan = await sync.runInitialScan( processRecords('v1'), scanOptions ) const initialScan2 = await sync2.runInitialScan( processRecords('v2'), scanOptions ) // Wait for initial scan to complete await Promise.all([initialScan.start(), initialScan2.start()]) // Assertions assert.strictEqual(processed.v1.length, numDocs) assert.strictEqual(processed.v2.length, numDocs) // Stop await Promise.all([initialScan.stop(), initialScan2.stop()]) }) test('should exit cleanly if initial scan is already complete', async () => { const { coll, db, redis } = await getConns() const sync = await getSync() await initState(sync, db, coll) // Mark initial scan complete await redis.set(sync.keys.scanCompletedKey, new Date().toString()) const processRecords = async () => { await setTimeout(50) } const scanOptions = { batchSize: 100 } const initialScan = await sync.runInitialScan(processRecords, scanOptions) await initialScan.start() await initialScan.stop() }) test('should run initial scan in reverse sort order', async () => { const { coll, db } = await getConns() const sync = await getSync() await initState(sync, db, coll) const processed: unknown[] = [] const processRecords = async (docs: ChangeStreamInsertDocument[]) => { await setTimeout(50) processed.push(...docs) } const sortField: SortField<ObjectId> = { field: '_id', serialize: _.toString, deserialize: (x: string) => new ObjectId(x), order: 'desc', } const scanOptions = { batchSize: 100, sortField } const initialScan = await sync.runInitialScan(processRecords, scanOptions) // Wait for initial scan to complete await initialScan.start() assert.strictEqual(processed.length, numDocs) // Stop await initialScan.stop() }) test('should omit fields from initial scan', async () => { const { coll, db } = await getConns() const sync = await getSync({ omit: ['address.city', 'address.geo'] }) await initState(sync, db, coll) const documents: Document[] = [] const processRecords = async (docs: ChangeStreamInsertDocument[]) => { await setTimeout(50) documents.push(docs[0].fullDocument) } const scanOptions = { batchSize: 100 } const initialScan = await sync.runInitialScan(processRecords, scanOptions) // Wait for initial scan to complete await initialScan.start() assert.strictEqual(documents[0]?.address?.city, undefined) assert.strictEqual(documents[0]?.address?.geo, undefined) // Stop await initialScan.stop() }) test('should complete initial scan if collection is empty', async () => { const { coll } = await getConns() const sync = await getSync() // Reset state await sync.reset() await coll.deleteMany({}) const processed: unknown[] = [] const processRecords = async (docs: ChangeStreamInsertDocument[]) => { await setTimeout(50) processed.push(...docs) } let completed = false sync.emitter.on('initialScanComplete', () => { completed = true }) const scanOptions = { batchSize: 100 } const initialScan = await sync.runInitialScan(processRecords, scanOptions) // Wait for initial scan to complete await initialScan.start() assert.ok(completed) assert.strictEqual(processed.length, 0) // Stop await initialScan.stop() }) test('initial scan should resume after stop', async () => { const { coll, db } = await getConns() const sync = await getSync() await initState(sync, db, coll) const processed: unknown[] = [] const processRecords = async (docs: ChangeStreamInsertDocument[]) => { await setTimeout(50) processed.push(...docs) } const scanOptions = { batchSize: 25 } const initialScan = await sync.runInitialScan(processRecords, scanOptions) let completed = false sync.emitter.on('initialScanComplete', () => { completed = true }) let cursorError = false sync.emitter.on('cursorError', () => { cursorError = true }) // Start initialScan.start() // Allow for some records to be processed await setTimeout(200) // Stop the initial scan await initialScan.stop() // Only a subset of the documents were processed assert.ok(processed.length < numDocs) // Should not emit cursorError when stopping assert.strictEqual(cursorError, false) // Wait for the initial scan to complete await initialScan.start() assert.ok(completed) assert.strictEqual(processed.length, numDocs) // Stop await initialScan.stop() }) test('initial scan should not be marked as completed if connection is closed', async () => { // Get a new connection since we're closing the connection in the test const { coll, redis, db, client } = await getConns({}) const sync = initSync(redis, coll) let cursorErrorEmitted = false sync.emitter.on('stateChange', console.log) sync.emitter.on('cursorError', (e: unknown) => { cursorErrorEmitted = true console.log(e) }) await initState(sync, db, coll) const processed: unknown[] = [] const processRecords = async (docs: ChangeStreamInsertDocument[]) => { await setTimeout(10) processed.push(...docs) } const scanOptions = { batchSize: 50 } const initialScan = await sync.runInitialScan(processRecords, scanOptions) // Start initialScan.start() // Allow for some records to be processed await setTimeout(100) // Close the connection. await client.close() // Allow for some time await setTimeout(100) // Check if completed const completedAt = await redis.get(sync.keys.scanCompletedKey) assert.strictEqual(completedAt, null) assert.ok(cursorErrorEmitted) // Stop await initialScan.stop() }) test('initial scan should support custom pipeline', async () => { const { coll, db } = await getConns() const sync = await getSync() await initState(sync, db, coll) sync.emitter.on('stateChange', console.log) const documents: Document[] = [] const processRecords = async (docs: ChangeStreamInsertDocument[]) => { await setTimeout(10) documents.push(docs[0].fullDocument) } const scanOptions: QueueOptions & ScanOptions = { batchSize: 50, pipeline: [ { $addFields: { cityState: { $concat: ['$address.city', '-', '$address.state'] }, }, }, ], } const initialScan = await sync.runInitialScan(processRecords, scanOptions) // Start initialScan.start() // Allow for some records to be processed await setTimeout(500) // Stop await initialScan.stop() assert.ok(documents[0].cityState) }) //------- Change Stream ------- test('should process records via change stream', async () => { const { coll, db, redis } = await getConns() const sync = await getSync() await initState(sync, db, coll) let cursorError = false sync.emitter.on('cursorError', () => { cursorError = true }) const processed: unknown[] = [] const changeStream = await sync.processChangeStream( // Type for docs is ChangeStreamDocument[] async (docs) => { for (const doc of docs) { await setTimeout(5) // Simulate downstream mutation delete doc._id processed.push(doc) } } ) // Start changeStream.start() await setTimeout(ms('1s')) // Update records coll.updateMany({}, { $set: { createdAt: new Date('2022-01-01') } }) // Wait for the change stream events to be processed await setTimeout(ms('6s')) assert.strictEqual(processed.length, numDocs) // Check that Redis keys are set assert.ok(await redis.get(sync.keys.changeStreamTokenKey)) assert.ok(await redis.get(sync.keys.lastChangeProcessedAtKey)) // Stop await changeStream.stop() // Should not emit cursorError when stopping assert.strictEqual(cursorError, false) }) test('should retry processing change stream records', async () => { const { coll, db } = await getConns() const options: SyncOptions = { retry: { minTimeout: 100, }, } const sync = await getSync(options) await initState(sync, db, coll) let counter = 0 const processed: unknown[] = [] const processRecords = async (docs: ChangeStreamDocument[]) => { // Simulate a failure on the first try if (counter++ === 0) { throw new Error('Fail') } for (const doc of docs) { await setTimeout(5) processed.push(doc) } } const batchSize = 100 const changeStream = await sync.processChangeStream(processRecords, { batchSize, }) // Start changeStream.start() await setTimeout(ms('1s')) // Update records coll.updateMany({}, { $set: { createdAt: new Date('2022-01-01') } }) // Wait for the change stream events to be processed await setTimeout(ms('6s')) assert.strictEqual(processed.length, numDocs) // Expect one extra call to processRecords assert.strictEqual(counter, numDocs / batchSize + 1) // Stop await changeStream.stop() }) test('should emit error after exhausting retries - change stream', async () => { const { coll, db } = await getConns() const options: SyncOptions = { retry: { retries: 1, minTimeout: 100, }, } const sync = await getSync(options) await initState(sync, db, coll) let processErrorCount = 0 sync.emitter.on('processError', () => { processErrorCount++ }) const processRecords = async () => { // Simulate a failure throw new Error('Fail') } const changeStream = await sync.processChangeStream(processRecords) // Start changeStream.start() await setTimeout(ms('1s')) // Update records coll.updateMany({}, { $set: { createdAt: new Date('2022-01-01') } }) // Wait for the change stream events to be processed await setTimeout(ms('6s')) assert.strictEqual(processErrorCount, 1) // Stop await changeStream.stop() }) test('should abort retries if stopping and not emit error - change stream', async () => { const { coll, db } = await getConns() const options: SyncOptions = { retry: { retries: 2, minTimeout: ms('1s'), }, } const sync = await getSync(options) await initState(sync, db, coll) let processError = false sync.emitter.on('processError', () => { processError = true }) let processRecordsCount = 0 const processRecords = async () => { processRecordsCount++ await setTimeout(50) // Simulate a failure throw new Error('Fail') } const changeStream = await sync.processChangeStream(processRecords) // Start changeStream.start() await setTimeout(ms('1s')) // Update records coll.updateMany({}, { $set: { createdAt: new Date('2022-01-01') } }) // Wait for some of the change stream events to be processed await setTimeout(ms('2s')) // Stop await changeStream.stop() console.log('processRecordsCount %d', processRecordsCount) assert.ok(processRecordsCount < 3) assert.strictEqual(processError, false) }) test('change stream should resume after pause in events', async () => { const { coll, db } = await getConns() // Use maxPauseTime option to auto-resume const sync = await getSync({ maxPauseTime: ms('1s') }) await initState(sync, db, coll) let cursorError = false sync.emitter.on('cursorError', () => { cursorError = true }) const processed: unknown[] = [] const processRecords = async (docs: ChangeStreamDocument[]) => { for (const doc of docs) { await setTimeout(5) processed.push(doc) if (processed.length === 200) { sync.pausable.pause() } } } const changeStream = await sync.processChangeStream(processRecords, { batchSize: 100, }) // Start changeStream.start() await setTimeout(ms('1s')) // Update records coll.updateMany({}, { $set: { createdAt: new Date('2022-01-01') } }) // Wait for the change stream events to be processed await setTimeout(ms('6s')) assert.strictEqual(processed.length, numDocs) // Stop await changeStream.stop() // Should not emit cursorError when stopping assert.strictEqual(cursorError, false) }) test('stopping paused change stream works', async () => { const { coll, db } = await getConns() const sync = await getSync() await initState(sync, db, coll) // Change stream const processRecords = async () => { await setTimeout(5) } const changeStream = await sync.processChangeStream(processRecords) changeStream.start() // Pause sync.pausable.pause() // Stop await changeStream.stop() // Check that we are not paused assert.strictEqual(sync.pausable.isPaused, false) }) test('change stream should throttle', async () => { const { coll, db } = await getConns() const sync = await getSync() await initState(sync, db, coll) let stats: QueueStats = { itemsPerSec: 0, bytesPerSec: 0 } let lastFlush: LastFlush | undefined sync.emitter.on('stats', (event: StatsEvent) => { stats = event.stats lastFlush = event.lastFlush }) const processed: unknown[] = [] const processRecords = async (docs: ChangeStreamDocument[]) => { for (const doc of docs) { await setTimeout(5) processed.push(doc) } } const changeStream = await sync.processChangeStream(processRecords, { batchSize: 100, maxItemsPerSec: 100, maxBytesPerSec: 65000, }) // Start changeStream.start() await setTimeout(ms('1s')) // Update records coll.updateMany( {}, { $set: { createdAt: new Date('2022-01-01') }, $unset: { 'address.city': '', 'address.geo.lat': '' }, $pop: { likes: 1 }, } ) // Wait for the change stream events to be processed await setTimeout(ms('8s')) assert.strictEqual(processed.length, numDocs) assert.ok(stats.itemsPerSec > 0 && stats.itemsPerSec < 200) assert.ok(stats.bytesPerSec > 0 && stats.bytesPerSec < 80000) assert.deepEqual(lastFlush, { batchSize: 100 }) // Stop await changeStream.stop() }) test('should process records via change stream - updateDescription removed', async () => { const { coll, db } = await getConns() const sync = await getSync() await initState(sync, db, coll) const processed: unknown[] = [] const processRecords = async (docs: ChangeStreamDocument[]) => { for (const doc of docs) { await setTimeout(5) processed.push(doc) } } const changeStream = await sync.processChangeStream(processRecords, { pipeline: [{ $unset: ['updateDescription'] }], }) // Start changeStream.start() await setTimeout(ms('1s')) // Update records coll.updateMany({}, { $set: { createdAt: new Date('2022-01-01') } }) // Wait for the change stream events to be processed await setTimeout(ms('6s')) assert.strictEqual(processed.length, numDocs) // Stop await changeStream.stop() }) test('should omit fields from change stream - dotted paths', async () => { const { coll, db } = await getConns() // address.geo is a path prefix relative to the paths being updated below const sync = await getSync({ omit: ['address.city', 'address.geo'] }) await initState(sync, db, coll) const documents: Document[] = [] const processRecords = async (docs: ChangeStreamDocument[]) => { for (const doc of docs) { await setTimeout(5) if (doc.operationType === 'update' && doc.fullDocument) { documents.push(doc) } } } const changeStream = await sync.processChangeStream(processRecords) // Start changeStream.start() await setTimeout(ms('1s')) // Update records coll.updateMany( {}, { $set: { name: 'unknown', 'address.city': 'San Diego', 'address.geo.lat': 24, }, $unset: { 'address.geo.long': '', }, } ) // Wait for the change stream events to be processed await setTimeout(ms('2s')) // Assertions assert.strictEqual(documents[0].fullDocument.address.city, undefined) assert.strictEqual(documents[0].fullDocument.address.geo, undefined) const fields = ['address.city', 'address.geo.lat'] for (const field of fields) { assert.strictEqual( documents[0].updateDescription.updatedFields[field], undefined ) } assert.deepEqual(documents[0].updateDescription.removedFields, []) // Stop await changeStream.stop() }) test('should omit fields from change stream - nested dotted path', async () => { const { coll, db } = await getConns() // address.geo is a path prefix relative to the paths being updated below const sync = await getSync({ omit: ['address.geo.lat'] }) await initState(sync, db, coll) const documents: Document[] = [] const processRecords = async (docs: ChangeStreamDocument[]) => { for (const doc of docs) { await setTimeout(5) if (doc.operationType === 'update' && doc.fullDocument) { documents.push(doc) } } } const changeStream = await sync.processChangeStream(processRecords) // Start changeStream.start() await setTimeout(ms('1s')) // Update record coll.updateMany({}, [ { $set: { 'address.geo.lat': 24, 'address.geo.long': 25, }, }, ]) // Wait for the change stream events to be processed await setTimeout(ms('2s')) // Assertions assert.strictEqual(documents[0].fullDocument.address.geo.long, 25) assert.strictEqual(documents[0].fullDocument.address.geo.lat, undefined) assert.strictEqual( documents[0].updateDescription.updatedFields['address.geo'].long, 25 ) assert.strictEqual( documents[0].updateDescription.updatedFields['address.geo'].lat, undefined ) // Stop await changeStream.stop() }) test('should omit fields from change stream - object', async () => { const { coll, db } = await getConns() // address.geo is a path prefix relative to the paths being updated below const sync = await getSync({ omit: ['address'] }) await initState(sync, db, coll) const documents: Document[] = [] const processRecords = async (docs: ChangeStreamDocument[]) => { for (const doc of docs) { await setTimeout(5) if (doc.operationType === 'update' && doc.fullDocument) { documents.push(doc) } } } const changeStream = await sync.processChangeStream(processRecords) // Start changeStream.start() await setTimeout(ms('1s')) // Update records coll.updateMany( {}, { $set: { address: { city: 'San Diego' }, }, } ) // Wait for the change stream events to be processed await setTimeout(ms('2s')) // Assertions assert.strictEqual(documents[0].fullDocument.address, undefined) assert.strictEqual( documents[0].updateDescription.updatedFields.address, undefined ) // Stop await changeStream.stop() }) test('should omit operation types from change stream', async () => { const { coll, db } = await getConns() const sync = await getSync() await initState(sync, db, coll) const operations: string[] = [] const changeStream = await sync.processChangeStream( // Type for docs is ChangeStreamInsertDocument[] async (docs) => { for (const doc of docs) { await setTimeout(5) operations.push(doc.operationType) } }, { operationTypes: ['insert'], // Short timeout since only event will be queued timeout: 500, } ) // Start changeStream.start() await setTimeout(ms('1s')) // Update records await coll.updateMany({}, { $set: { name: 'unknown' } }) // Insert record await coll.insertOne(genUser()) // Wait for the change stream events to be processed await setTimeout(ms('2s')) assert.deepEqual(_.uniq(operations), ['insert']) // Stop await changeStream.stop() }) test('change stream should resume after being stopped', async () => { const { coll, db } = await getConns() const sync = await getSync() await initState(sync, db, coll) const processed: unknown[] = [] // Change stream const processRecords = async (docs: ChangeStreamDocument[]) => { for (const doc of docs) { await setTimeout(8) processed.push(doc) } } const changeStream = await sync.processChangeStream(processRecords) changeStream.start() // Let change stream connect await setTimeout(ms('1s')) // Change documents coll.updateMany({}, { $set: { createdAt: new Date('2022-01-02') } }) // Wait for some change stream events to be processed await setTimeout(ms('2s')) // Only a subset of the documents were processed assert.ok(processed.length < numDocs) // Stop await changeStream.stop() // Resume change stream changeStream.start() // Wait for all documents to be processed await setTimeout(ms('5s')) // All change stream docs were processed assert.strictEqual(processed.length, numDocs) await changeStream.stop() }) test('change stream handle missing oplog entry properly', async () => { const { coll, db, redis } = await getConns() const sync = await getSync() let cursorError: any sync.emitter.on('cursorError', (event: CursorErrorEvent) => { cursorError = event }) await initState(sync, db, coll) // Set missing token key await redis.set( sync.keys.changeStreamTokenKey, '{"_data":"8263F51B8F000000012B022C0100296E5A1004F852F6C89F924F0A8711460F0C1FBD8846645F6964006463F51B8FD1AACE003022EFC80004"}' ) // Change stream const processRecords = async () => { await setTimeout(5) } const changeStream = await sync.processChangeStream(processRecords) changeStream.start() // Let change stream connect await setTimeout(ms('1s')) assert.ok(missingOplogEntry(cursorError.error)) await changeStream.stop() }) test('change stream handle invalid oplog entry properly', async () => { const { coll, db, redis } = await getConns() const sync = await getSync() let cursorError: any sync.emitter.on('cursorError', (event: CursorErrorEvent) => { cursorError = event }) await initState(sync, db, coll) // Set missing token key await redis.set(sync.keys.changeStreamTokenKey, '{"_data":"123"}') // Change stream const processRecord = async () => { await setTimeout(5) } const changeStream = await sync.processChangeStream(processRecord) changeStream.start() // Let change stream connect await setTimeout(ms('1s')) assert.ok(missingOplogEntry(cursorError.error)) await changeStream.stop() }) test('change stream should handle empty collection', async () => { const { coll, db } = await getConns() const sync = await getSync() let cursorError = false sync.emitter.on('cursorError', () => { cursorError = true }) await initState(sync, db, coll) // Delete all documents await coll.deleteMany({}) // Change stream const processRecords = async () => { await setTimeout(5) } const changeStream = await sync.processChangeStream(processRecords) changeStream.start() // Let change stream connect await setTimeout(ms('1s')) assert.strictEqual(cursorError, false) await changeStream.stop() }) test('should update the resume token, even if there were no records updated', async () => { const { coll, db, redis } = await getConns() const sync = await getSync() await initState(sync, db, coll) const processRecords = async () => { await setTimeout(5) } // Utilities for comparing resume tokens to see if they change over time. const { changeStreamTokenKey } = getKeys(coll, {}) const getCurrentToken = async () => await redis.get(changeStreamTokenKey) const assertResumeTokenUpdated = async ( lastToken: string | null, when: string ): Promise<string | null> => { let currentToken: string | null = null await assertEventually(async () => { currentToken = await getCurrentToken() return currentToken !== lastToken }, `Resume token was not updated ${when}`) return currentToken } // The resume token should initially be null, since we ran `initState` // above. const token = await getCurrentToken() assert.equal(token, null) const changeStream = await sync.processChangeStream(processRecords, { timeout: ms('5s'), }) changeStream.start() // Let change stream connect await setTimeout(ms('1s')) // Waiting for a while should result in an updated resume token. await assertResumeTokenUpdated(token, 'after waiting') // Updating records results in an updated resume token. await coll.updateMany({}, { $set: { createdAt: new Date('2022-01-03') } }) await assertResumeTokenUpdated(token, 'after updating records') // Waiting for a while after an update should result in the resume token // updating again. await assertResumeTokenUpdated(token, 'after waiting again after an update') await changeStream.stop() }) // ------ Detect Resync ------- test('Should resync when resync flag is set', async () => { const { coll, db, redis } = await getConns() const sync = await getSync() await initState(sync, db, coll) let resyncTriggered = false const processed: unknown[] = [] const processRecords = async (docs: ChangeStreamInsertDocument[]) => { await setTimeout(50) processed.push(...docs) } const initialScan = await sync.runInitialScan(processRecords) const resync = sync.detectResync(250) sync.emitter.on('resync', async () => { // Stop checking for resync resync.stop() resyncTriggered = true // Stop the initial scan await initialScan.stop() // Reset keys await sync.reset() // Reset processed processed.length = 0 // Start initial scan initialScan.start() }) // Start initial scan initialScan.start() // Start resync detection resync.start() // Allow for initial scan to start await setTimeout(500) // Trigger resync await redis.set(sync.keys.resyncKey, 1) // Wait for initial scan to complete await setTimeout(ms('5s')) assert.ok(resyncTriggered) assert.strictEqual(processed.length, numDocs) await initialScan.stop() }) test('Resync is pausable', async () => { const { coll, db, redis } = await getConns() const sync = await getSync() await initState(sync, db, coll) let resyncTriggered = false const resync = sync.detectResync(250) sync.emitter.on('resync', async () => { resyncTriggered = true }) // Start resync detection resync.start() // Pause sync.pausable.pause() // Trigger resync await redis.set(sync.keys.resyncKey, 1) await setTimeout(ms('1s')) assert.ok(!resyncTriggered) }) test('Resync start/stop is idempotent', async () => { const sync = await getSync() const resync = sync.detectResync() resync.start() resync.start() resync.stop() resync.stop() }) //------ Detect Schema Change ------- test('Detect schema change', async () => { const { db, coll } = await getConns() const sync = await getSync() await initState(sync, db, coll) // Look for a new schema every 250 ms const schemaChange = await sync.detectSchemaChange(db, { shouldRemoveUnusedFields: true, interval: 250, }) let schemaChangeEventTriggered = false sync.emitter.on('schemaChange', ({ currentSchema }: SchemaChangeEvent) => { console.dir(currentSchema, { depth: 10 }) schemaChangeEventTriggered = true }) // Start detecting schema changes schemaChange.start() // Modify the schema const modifiedSchema = _.set( 'properties.email', { bsonType: 'string' }, schema ) await db.command({ collMod: coll.collectionName, validator: { $jsonSchema: modifiedSchema }, }) await setTimeout(ms('1s')) assert.ok(schemaChangeEventTriggered) schemaChange.stop() }) test('Detect schema change is pausable', async () => { const { db, coll } = await getConns() const sync = await getSync() await initState(sync, db, coll) // Look for a new schema every 250 ms const schemaChange = await sync.detectSchemaChange(db, { shouldRemoveUnusedFields: true, interval: 250, }) let schemaChangeEventTriggered = false sync.emitter.on('schemaChange', () => { schemaChangeEventTriggered = true }) // Start detecting schema changes schemaChange.start() // Pause sync.pausable.pause() // Modify the schema const modifiedSchema = _.set( 'properties.email', { bsonType: 'string' }, schema ) await db.command({ collMod: coll.collectionName, validator: { $jsonSchema: modifiedSchema }, }) await setTimeout(ms('1s')) assert.ok(!schemaChangeEventTriggered) schemaChange.stop() }) test('Schema change start/stop is idempotent', async () => { const { db } = await getConns() const sync = await getSync() const schemaChange = await sync.detectSchemaChange(db) schemaChange.start() schemaChange.start() schemaChange.stop() schemaChange.stop() }) //------ Events ------- test('can extend events', async () => { const { coll, redis } = await getConns({}) const sync = initSync<'foo' | 'bar'>(redis, coll) let emitted = '' sync.emitter.on('foo', (x: string) => { emitted = x }) sync.emitter.emit('foo', 'bar') assert.strictEqual(emitted, 'bar') }) })