UNPKG

mongochangestream

Version:

Sync MongoDB collections via change streams into any database.

699 lines (653 loc) 21.5 kB
import _debug from 'debug' import { EventEmitter } from 'eventemitter3' import type { Redis } from 'ioredis' import _ from 'lodash/fp.js' import { type ChangeStream, type ChangeStreamInsertDocument, type Collection, type Db, type Document, ObjectId, } from 'mongodb' import * as mongodb from 'mongodb' import ms from 'ms' import { type Options as RetryOptions } from 'p-retry' import { batchQueue, defer, type Deferred, pausable, type QueueOptions, } from 'prom-utils' import { fsm, type StateTransitions } from 'simple-machines' import { safelyCheckNext } from './safelyCheckNext.js' import type { ChangeOptions, ChangeStreamOptions, CursorErrorEvent, DocumentsForOperationTypes, Events, JSONSchema, OperationType, ProcessChangeStreamRecords, ProcessInitialScanRecords, ScanOptions, SchemaChangeEvent, SimpleState, SortField, State, StatsEvent, SyncOptions, } from './types.js' import { docToChangeStreamInsert, generatePipelineFromOmit, getCollectionKey, omitFieldsForUpdate, removeUnusedFields, safeRetry, setDefaults, when, } from './util.js' const debug = _debug('mongochangestream') const keyPrefix = 'mongoChangeStream' /** * Get Redis keys used for the collection. */ export const getKeys = (collection: Collection, options: SyncOptions) => { const collectionKey = getCollectionKey(collection) const uniqueId = options.uniqueId ? `:${options.uniqueId}` : '' const collectionPrefix = `${keyPrefix}:${collectionKey}${uniqueId}` const scanCompletedKey = `${collectionPrefix}:initialScanCompletedOn` const lastScanIdKey = `${collectionPrefix}:lastScanId` const changeStreamTokenKey = `${collectionPrefix}:changeStreamToken` const schemaKey = `${collectionPrefix}:schema` const lastChangeProcessedAtKey = `${collectionPrefix}:lastChangeProcessedAt` const lastScanProcessedAtKey = `${collectionPrefix}:lastScanProcessedAt` const resyncKey = `${collectionPrefix}:resync` return { scanCompletedKey, lastScanIdKey, changeStreamTokenKey, schemaKey, lastChangeProcessedAtKey, lastScanProcessedAtKey, resyncKey, } } const stateTransitions: StateTransitions<State> = { stopped: ['starting'], starting: ['started', 'stopped'], started: ['stopping'], stopping: ['stopped'], } const simpleStateTransistions: StateTransitions<SimpleState> = { started: ['stopped'], stopped: ['started'], } const stateFields = ['get', 'is'] as const const defaultRetryOptions: RetryOptions = { minTimeout: ms('30s'), maxTimeout: ms('1h'), // This translates to retrying for up to 24 hours. It takes 7 retries // to reach the maxTimeout of 1 hour. retries: 30, } export function initSync<ExtendedEvents extends EventEmitter.ValidEventTypes>( redis: Redis, collection: Collection, options: SyncOptions = {} ) { /** * Redis keys used for the collection. */ const keys = getKeys(collection, options) const { omit } = options const retryOptions: RetryOptions = { ...defaultRetryOptions, ...options.retry, } /** Event emitter */ const emitter = new EventEmitter<Events | ExtendedEvents>() const emit = (event: Events, data: object) => { emitter.emit(event, { type: event, ...data }) } const emitStateChange = (change: object) => emit('stateChange', change) const pause = pausable(options.maxPauseTime) const toChangeStreamInsert = docToChangeStreamInsert(collection) /** * Determine if the collection should be resynced by checking for the existence * of the resync key in Redis. */ const detectResync = (resyncCheckInterval = ms('1m')) => { let resyncTimer: NodeJS.Timeout const state = fsm(simpleStateTransistions, 'stopped', { name: 'detectResync', onStateChange: emitStateChange, }) const shouldResync = () => redis.exists(keys.resyncKey) const start = () => { debug('Starting resync check') if (state.is('started')) { return } // Check periodically if the collection should be resynced resyncTimer = setInterval(async () => { if (pause.isPaused) { debug('Skipping re-sync check - paused') return } if (await shouldResync()) { debug('Resync triggered') emit('resync', {}) } }, resyncCheckInterval) state.change('started') } const stop = () => { debug('Stopping resync check') if (state.is('stopped')) { return } clearInterval(resyncTimer) state.change('stopped') } return { start, stop } } /** * Run initial collection scan. `options.batchSize` defaults to 500. * Sorting defaults to `_id`. * * Call `start` to start processing documents and `stop` to close * the cursor. */ async function runInitialScan<T = any>( processRecords: ProcessInitialScanRecords, options: QueueOptions & ScanOptions<T> = {} ) { let deferred: Deferred const retryController = new AbortController() let cursor: ReturnType<typeof collection.aggregate> const state = fsm(stateTransitions, 'stopped', { name: 'runInitialScan', onStateChange: emitStateChange, }) const defaultSortField: SortField<ObjectId> = { field: '_id', serialize: _.toString, deserialize: (x: string) => new ObjectId(x), order: 'asc', } const sortField = options.sortField || defaultSortField const sortOrderToNum = { asc: 1, desc: -1 } const getCursor = async () => { // Lookup last id successfully processed const lastIdProcessed = await redis.get(keys.lastScanIdKey) debug('Last id processed %s', lastIdProcessed) // Query collection const omitPipeline = omit ? [{ $project: setDefaults(omit, 0) }] : [] const extendedPipeline = options.pipeline ?? [] const pipeline = [ // Skip ids already processed ...(lastIdProcessed ? [ { $match: { [sortField.field]: { $gt: sortField.deserialize(lastIdProcessed), }, }, }, ] : []), { $sort: { [sortField.field]: sortOrderToNum[sortField.order ?? 'asc'], }, }, ...omitPipeline, ...extendedPipeline, ] debug('Initial scan pipeline %O', pipeline) return collection.aggregate(pipeline) } /** Start the initial scan */ const start = async () => { debug('Starting initial scan') // Nothing to do if (state.is('starting', 'started')) { debug('Initial scan already starting or started') return } if (state.is('stopping')) { // Wait until stopping -> stopped await state.waitForChange('stopped') } state.change('starting') deferred = defer() // Determine if initial scan has already completed const scanCompleted = await redis.get(keys.scanCompletedKey) // Scan already completed so return if (scanCompleted) { debug(`Initial scan previously completed on %s`, scanCompleted) // We're done state.change('stopped') return } const _processRecords = async (records: ChangeStreamInsertDocument[]) => { const numRecords = records.length const lastDocument = records[numRecords - 1].fullDocument // Record last id of the batch const lastId = _.get(sortField.field, lastDocument) // Process batch of records with retries. // NOTE: processRecords could mutate records. try { await safeRetry(() => processRecords(records), { ...retryOptions, signal: retryController.signal, }) debug('Processed %d records', numRecords) debug('Last id %s', lastId) if (lastId) { await redis.mset( keys.lastScanIdKey, sortField.serialize(lastId), keys.lastScanProcessedAtKey, new Date().getTime() ) } } catch (error) { debug('Process error %o', error) if (!state.is('stopping')) { emit('processError', { error, name: 'runInitialScan' }) } } // Emit stats emit('stats', { name: 'runInitialScan', stats: queue.getStats(), lastFlush: queue.lastFlush, } as StatsEvent) } // Create queue const queue = batchQueue(_processRecords, options) // Query collection cursor = await getCursor() // Change state state.change('started') const nextChecker = safelyCheckNext(cursor) let doc: Document | null // Process documents while ((doc = await nextChecker.getNext())) { debug('Initial scan doc %O', doc) await queue.enqueue(toChangeStreamInsert(doc)) await pause.maybeBlock() } // Flush the queue await queue.flush() // We are not stopping if (!state.is('stopping')) { // An error occurred getting next if (nextChecker.errorExists()) { emit('cursorError', { name: 'runInitialScan', error: nextChecker.getLastError(), } as CursorErrorEvent) } // Exited cleanly from the loop so we're done else { debug('Completed initial scan') // Record scan complete await redis.set(keys.scanCompletedKey, new Date().toString()) // Emit event emit('initialScanComplete', {}) } } // Resolve deferred deferred.done() debug('Exit initial scan') } /** Stop the initial scan */ const stop = async () => { debug('Stopping initial scan') // Nothing to do if (state.is('stopping', 'stopped')) { debug('Initial scan already stopping or stopped') return } if (state.is('starting')) { // Wait until starting -> started await state.waitForChange('started') } state.change('stopping') // Close the cursor await cursor?.close() debug('MongoDB cursor closed') // Abort retries retryController.abort('stopping') debug('Retry controller aborted') // Unpause pause.resume() debug('Unpaused') // Wait for start fn to finish await deferred?.promise state.change('stopped') debug('Stopped initial scan') } /** Restart the initial scan */ const restart = async () => { debug('Restarting initial scan') await stop() start() } return { start, stop, restart, state: _.pick(stateFields, state) } } /** * Process MongoDB change stream for the collection. * If omit is passed to `initSync` a pipeline stage that removes * those fields will be prepended to the `pipeline` argument. * * Call `start` to start processing events and `stop` to close * the change stream. */ const processChangeStream = async < T extends OperationType[] | undefined = undefined, >( processRecords: ProcessChangeStreamRecords<T>, options: QueueOptions & ChangeStreamOptions<T> = {} ) => { let deferred: Deferred const retryController = new AbortController() let changeStream: ChangeStream const state = fsm(stateTransitions, 'stopped', { name: 'processChangeStream', onStateChange: emitStateChange, }) const defaultOptions = { fullDocument: 'updateLookup' } const operationTypes = options.operationTypes debug('Operation types %o', operationTypes) /** * Get the change stream, resuming from a previous token if exists. */ const getChangeStream = async () => { const operationsPipeline = operationTypes ? [{ $match: { operationType: { $in: operationTypes } } }] : [] const omitPipeline = omit ? generatePipelineFromOmit(omit) : [] const extendedPipeline = options.pipeline ?? [] const pipeline = [ ...operationsPipeline, ...omitPipeline, ...extendedPipeline, ] debug('Change stream pipeline %O', pipeline) // Lookup change stream token const token = await redis.get(keys.changeStreamTokenKey) debug('Last recorded resume token: %O', token) const changeStreamOptions: mongodb.ChangeStreamOptions = token ? // Resume token found, so set change stream resume point { ...defaultOptions, resumeAfter: JSON.parse(token) } : defaultOptions return collection.watch(pipeline, changeStreamOptions) } /** Start processing change stream */ const start = async () => { debug('Starting change stream') // Nothing to do if (state.is('starting', 'started')) { debug('Change stream already starting or started') return } if (state.is('stopping')) { // Wait until stopping -> stopped await state.waitForChange('stopped') } state.change('starting') changeStream = await getChangeStream() debug('Started change stream') const _processRecords = async ( maybeRecords: (DocumentsForOperationTypes<T> | null)[] ) => { // Each item in `maybeRecords` is either an actual record, or null. Null // is a signal that we have reached the end of the change stream and we // just need to update the resume token in Redis. const records: DocumentsForOperationTypes<T>[] = [] for (const record of maybeRecords) { if (record) { records.push(record) } } // Process batch of records with retries. // NOTE: processRecords could mutate records. try { if (records.length > 0) { await safeRetry(() => processRecords(records), { ...retryOptions, signal: retryController.signal, }) debug('Processed %d records', records.length) } // Persist state const token = changeStream.resumeToken debug('Updating resume token to: %s', token) await redis.mset( keys.changeStreamTokenKey, JSON.stringify(token), keys.lastChangeProcessedAtKey, new Date().getTime() ) } catch (error) { debug('Process error %o', error) if (!state.is('stopping')) { emit('processError', { error, name: 'processChangeStream' }) } } // Emit stats emit('stats', { name: 'processChangeStream', stats: queue.getStats(), lastFlush: queue.lastFlush, } as StatsEvent) } // New deferred deferred = defer() // Create queue const queue = batchQueue(_processRecords, { timeout: ms('30s'), ...options, }) const nextChecker = safelyCheckNext(changeStream) state.change('started') // Consume change stream until there is an error or `stop` is called. while (!state.is('stopping', 'stopped')) { // This always updates `changeStream.resumeToken`, even when we reach // the end of the change stream and null is returned. // // This is important because we always want to keep a recent resume // token. Attempting to resume from an older resume token might not // work, because only a certain (configurable) amount of oplog history // is kept. Even if the oplog entry still exists, it can take a very // long time to scan through all of the oplog entries since then, so // keeping a recent resume token is vital for performance. const event: DocumentsForOperationTypes<T> | null = await nextChecker.getNext() if (event) { debug('Change stream event %O', event) // Omit nested fields that are not handled by $unset. // For example, if 'a' was omitted then 'a.b.c' should be omitted. if ( omit && event.operationType === 'update' && // Downstream libraries might unset event.updateDescription // to optimize performance (e.g., mongo2elastic). event.updateDescription ) { omitFieldsForUpdate(omit, event) } } await queue.enqueue(event) await pause.maybeBlock() if (nextChecker.errorExists() && !state.is('stopping')) { emit('cursorError', { name: 'processChangeStream', error: nextChecker.getLastError(), } as CursorErrorEvent) break } } await queue.flush() // Signal stopping => stopped state change deferred.done() debug('Exit change stream') } /** Stop processing change stream */ const stop = async () => { debug('Stopping change stream') // Nothing to do if (state.is('stopping', 'stopped')) { debug('Change stream already stopping or stopped') return } if (state.is('starting')) { // Wait until starting -> started await state.waitForChange('started') } state.change('stopping') // Close the change stream await changeStream?.close() debug('MongoDB change stream closed') // Abort retries retryController.abort('stopping') debug('Retry controller aborted') // Unpause pause.resume() debug('Unpaused') // Wait for start fn to finish await deferred?.promise state.change('stopped') debug('Stopped change stream') } /** Restart change stream */ const restart = async () => { debug('Restarting change stream') await stop() start() } return { start, stop, restart, state: _.pick(stateFields, state) } } /** * Delete all Redis keys for the collection. */ const reset = async () => { debug('Reset') await redis.del(...Object.values(keys)) } /** * Get the existing JSON schema for the collection. */ const getCollectionSchema = async (db: Db): Promise<JSONSchema> => { const colls = await db .listCollections({ name: collection.collectionName }) .toArray() return _.get('0.options.validator.$jsonSchema', colls) || {} } /** * Get cached collection schema */ const getCachedCollectionSchema = () => redis.get(keys.schemaKey).then((val: any) => val && JSON.parse(val)) /** * Check for schema changes every interval and emit 'change' event if found. * Optionally, set interval and strip unused fields (e.g., title and description) * from the JSON schema. * * Call `start` to start polling for schema changes and `stop` to clear * the timer. */ const detectSchemaChange = async (db: Db, options: ChangeOptions = {}) => { const interval = options.interval || ms('1m') const shouldRemoveUnusedFields = options.shouldRemoveUnusedFields const maybeRemoveUnusedFields = when( shouldRemoveUnusedFields, removeUnusedFields ) const state = fsm(simpleStateTransistions, 'stopped', { name: 'detectSchemaChange', onStateChange: emitStateChange, }) let timer: NodeJS.Timeout // Check for a cached schema let previousSchema = await getCachedCollectionSchema().then((schema) => { if (schema) { return maybeRemoveUnusedFields(schema) } }) if (!previousSchema) { const schema = await getCollectionSchema(db).then(maybeRemoveUnusedFields) // Persist schema await redis.setnx(keys.schemaKey, JSON.stringify(schema)) previousSchema = schema } debug('Previous schema %O', previousSchema) // Check for a schema change const checkForSchemaChange = async () => { if (pause.isPaused) { debug('Skipping schema change check - paused') return } const currentSchema = await getCollectionSchema(db).then( maybeRemoveUnusedFields ) // Schemas are no longer the same if (!_.isEqual(currentSchema, previousSchema)) { debug('Schema change detected %O', currentSchema) // Persist schema await redis.set(keys.schemaKey, JSON.stringify(currentSchema)) // Emit change emit('schemaChange', { previousSchema, currentSchema, } as SchemaChangeEvent) // Previous schema is now the current schema previousSchema = currentSchema } } const start = () => { debug('Starting polling for schema changes') if (state.is('started')) { return } // Perform an inital check checkForSchemaChange() // Check for schema changes every interval timer = setInterval(checkForSchemaChange, interval) state.change('started') } const stop = () => { debug('Stopping polling for schema changes') if (state.is('stopped')) { return } clearInterval(timer) state.change('stopped') } return { start, stop } } return { runInitialScan, processChangeStream, reset, getCollectionSchema, detectSchemaChange, detectResync, keys, emitter, /** * Pause and resume all syncing functions at once. A call to `stop` * for `runInitialScan` or `processChangeStream` will resume to prevent * hanging. */ pausable: pause, } }