UNPKG

mongochangestream

Version:

Sync MongoDB collections via change streams into any database.

1,257 lines (1,157 loc) 38.5 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, 'processed all documents') // Check that Redis keys are set assert.ok(await redis.get(sync.keys.lastScanIdKey), 'lastScanIdKey set') assert.ok( await redis.get(sync.keys.lastScanProcessedAtKey), 'lastScanProcessedAtKey set' ) assert.ok( await redis.get(sync.keys.scanCompletedKey), 'scanCompletedKey set' ) // 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, 'processError emitted once') // 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, 'retries aborted') assert.strictEqual(processError, false, 'no processError emitted') }) 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, 'processed all documents') // Expect one extra call to processRecords assert.strictEqual(counter, numDocs / batchSize + 1, 'retry occurred') // Stop await initialScan.stop() }) 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, 'processed all documents') assert.ok( stats.itemsPerSec > 0 && stats.itemsPerSec < 250, 'itemsPerSec throttled' ) assert.ok( stats.bytesPerSec > 0 && stats.bytesPerSec < 40000, 'bytesPerSec throttled' ) assert.deepEqual(lastFlush, { batchSize: 100 }, 'lastFlush correct') // 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, 'v1 processed all documents' ) assert.strictEqual( processed.v2.length, numDocs, 'v2 processed all documents' ) // 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, 'processed all documents') // 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, 'city field omitted' ) assert.strictEqual( documents[0]?.address?.geo, undefined, 'geo field omitted' ) // 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, 'initial scan completed') assert.strictEqual(processed.length, 0, 'no documents processed') // 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, 'Subset of documents processed') // Should not emit cursorError when stopping assert.strictEqual(cursorError, false, 'No cursor error emitted') // Wait for the initial scan to complete await initialScan.start() assert.ok(completed, 'Initial scan completed') assert.strictEqual(processed.length, numDocs, 'All documents processed') // 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, 'Initial scan not completed') assert.ok(cursorErrorEmitted, 'Cursor error emitted') // 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, 'pipeline field added') }) //------- 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, 'processed all documents') // Check that Redis keys are set assert.ok( await redis.get(sync.keys.changeStreamTokenKey), 'changeStreamTokenKey set' ) assert.ok( await redis.get(sync.keys.lastChangeProcessedAtKey), 'lastChangeProcessedAtKey set' ) // Stop await changeStream.stop() // Should not emit cursorError when stopping assert.strictEqual(cursorError, false, 'no cursor error when stopping') }) 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, 'processed all documents') // Expect one extra call to processRecords assert.strictEqual(counter, numDocs / batchSize + 1, 'retry occurred') // 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, 'processError emitted once') // 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, 'retries aborted') assert.strictEqual(processError, false, 'no processError emitted') }) 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, 'processed all documents') assert.ok( stats.itemsPerSec > 0 && stats.itemsPerSec < 200, 'itemsPerSec throttled' ) assert.ok( stats.bytesPerSec > 0 && stats.bytesPerSec < 80000, 'bytesPerSec throttled' ) assert.deepEqual(lastFlush, { batchSize: 100 }, 'lastFlush correct') // 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, 'processed all documents') // 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, 'city field omitted' ) assert.strictEqual( documents[0].fullDocument.address.geo, undefined, 'geo field omitted' ) const fields = ['address.city', 'address.geo.lat'] for (const field of fields) { assert.strictEqual( documents[0].updateDescription.updatedFields[field], undefined, `${field} omitted from updatedFields` ) } assert.deepEqual( documents[0].updateDescription.removedFields, [], 'removedFields empty' ) // 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, 'long field preserved' ) assert.strictEqual( documents[0].fullDocument.address.geo.lat, undefined, 'lat field omitted' ) assert.strictEqual( documents[0].updateDescription.updatedFields['address.geo'].long, 25, 'long field in updatedFields' ) assert.strictEqual( documents[0].updateDescription.updatedFields['address.geo'].lat, undefined, 'lat field omitted from updatedFields' ) // 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, 'address field omitted' ) assert.strictEqual( documents[0].updateDescription.updatedFields.address, undefined, 'address omitted from updatedFields' ) // 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'], 'only insert operations processed' ) // 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, 'subset processed before stop') // 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, 'all documents processed after resume' ) 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), 'invalid oplog entry detected' ) 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), 'missing oplog entry detected' ) 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, 'no cursor error with empty collection' ) 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, 'initial token is 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, 'resync triggered') assert.strictEqual( processed.length, numDocs, 'all documents processed after resync' ) await initialScan.stop() }) 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, 'schema change event triggered') 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', 'custom event emitted') }) })