Skip to main content

laminar_connectors/mongodb/source/
mod.rs

1//! `MongoDB` CDC source connector implementation.
2//!
3//! Implements [`crate::connector::SourceConnector`] for streaming change events from `MongoDB`
4//! change streams into `LaminarDB` as Arrow `RecordBatch`es, in one of two shapes:
5//!
6//! - history: every event (and every snapshot copy) becomes an immutable, versioned record;
7//! - document: full post-images become keyed puts and deletes become key-only tombstones.
8//!
9//! # Cancellation Safety
10//!
11//! Connector lifecycle futures never directly poll the `MongoDB` driver. Driver
12//! I/O lives in an owned reader task; cancellation aborts that task so no
13//! connection or cursor outlives its connector.
14
15use std::collections::VecDeque;
16use std::sync::Arc;
17
18use arrow_array::{BinaryArray, UInt32Array};
19use arrow_schema::SchemaRef;
20use mongodb::bson::{RawDocumentBuf, Timestamp};
21use tokio::sync::{Notify, Semaphore};
22use uuid::Uuid;
23
24use crate::config::ConnectorState;
25use crate::connector::{
26    ConnectorTaskOwner, ConnectorTaskTracker, SourceBatch, SourceMutation, SourceRowPositions,
27};
28use crate::error::ConnectorError;
29
30use super::change_event::ChangeOperation;
31use super::config::{MongoDbSourceConfig, SourceOutputMode};
32use super::metrics::MongoDbCdcMetrics;
33
34const MAX_RESUME_TOKEN_BYTES: usize = 64 * 1024;
35const MONGODB_CHECKPOINT_CONNECTOR: &str = "mongodb-cdc";
36const MONGODB_CHECKPOINT_VERSION: &str = "5";
37const STREAM_IDENTITY_METADATA: &str = "stream_identity_sha256";
38const COLLECTION_UUID_METADATA: &str = "collection_uuid";
39const DEPLOYMENT_IDENTITY_METADATA: &str = "deployment_identity";
40const MAX_MONGODB_WIRE_EVENT_BYTES: usize = 16 * 1024 * 1024;
41const CURSOR_MAX_AWAIT_TIME: std::time::Duration = std::time::Duration::from_secs(1);
42const READER_STARTUP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
43
44mod admission;
45mod buffering;
46mod checkpoint;
47mod decoding;
48mod document;
49mod history;
50mod lifecycle;
51mod reader;
52mod schema_resolution;
53
54pub use history::{mongodb_history_schema, MONGODB_HISTORY_VERSION, SNAPSHOT_OPERATION};
55
56use admission::observe_mongodb_admission;
57use buffering::{BufferedMongoEvent, BufferedMongoPayload};
58use checkpoint::{
59    mongodb_stream_identity, parse_mongodb_checkpoint, EmittedPosition, MongoCheckpointPosition,
60    MongoDeploymentIdentity, ParsedMongoCheckpoint, SnapshotCut, StreamPosition,
61};
62use decoding::decode_change;
63use document::{DocumentProjection, DocumentRow};
64use history::{history_batch, id_key, HistoryIdentity};
65use reader::{run_change_stream_reader, MongoResumePosition, ReaderStart, READER_SHUTDOWN_TIMEOUT};
66
67/// `MongoDB` CDC source connector.
68///
69/// Events are read by an owned background task into a byte-bounded queue and converted to
70/// Arrow on `poll_batch`, outside the engine's compute thread.
71pub struct MongoDbCdcSource {
72    config: MongoDbSourceConfig,
73    state: ConnectorState,
74    schema: SchemaRef,
75    metrics: Arc<MongoDbCdcMetrics>,
76    event_buffer: VecDeque<BufferedMongoEvent>,
77
78    /// Position and row count of everything returned by `poll_batch`. The reader's newer
79    /// cursor position is deliberately not shared with this field.
80    emitted: Option<EmittedPosition>,
81
82    /// Physical identity of the fixed collection admitted by `listCollections`.
83    collection_uuid: Option<Uuid>,
84
85    /// Immutable server-issued identity of the replica set or sharded cluster.
86    deployment_identity: Option<MongoDeploymentIdentity>,
87
88    /// Document-mode projection, validated before the reader starts.
89    projection: Option<DocumentProjection>,
90
91    /// Opens the snapshot copy once a checkpoint carrying the fresh snapshot cut is durable.
92    snapshot_committed: Option<(Timestamp, tokio::sync::watch::Sender<bool>)>,
93
94    /// Shared ownership limits span the reader channel and poll buffer.
95    byte_budget: Arc<Semaphore>,
96
97    /// Notification handle signalled when data arrives from the stream.
98    data_ready: Arc<Notify>,
99
100    reader_handle: Option<tokio::task::JoinHandle<()>>,
101    event_rx: Option<ChangeStreamRx>,
102    reader_shutdown: Option<tokio::sync::watch::Sender<bool>>,
103
104    /// Terminal reader failure, independent of the bounded event queue.
105    reader_error: Option<tokio::sync::watch::Receiver<Option<MongoReaderFailure>>>,
106
107    /// Admission authority and terminal observer for this connector generation.
108    task_owner: ConnectorTaskOwner,
109    task_tracker: ConnectorTaskTracker,
110}
111
112impl Drop for MongoDbCdcSource {
113    fn drop(&mut self) {
114        if let Some(shutdown) = self.reader_shutdown.take() {
115            shutdown.send_replace(true);
116        }
117        if let Some(handle) = self.reader_handle.take() {
118            reap_mongo_reader(handle, &self.task_owner);
119        }
120    }
121}
122
123fn reap_mongo_reader(handle: tokio::task::JoinHandle<()>, task_owner: &ConnectorTaskOwner) {
124    let Some(reaper_guard) = task_owner.track() else {
125        tracing::warn!("MongoDB CDC task generation was sealed before reader reaping");
126        return;
127    };
128    let Ok(runtime) = tokio::runtime::Handle::try_current() else {
129        // The reader owns a separate task guard. Runtime destruction drops the
130        // reader future and resolves that proof without a timer or join guess.
131        drop(reaper_guard);
132        return;
133    };
134    drop(runtime.spawn(async move {
135        let _reaper_guard = reaper_guard;
136        if let Err(error) = handle.await {
137            tracing::debug!(%error, "MongoDB CDC retired reader task reaped");
138        }
139    }));
140}
141
142/// Cloneable async sender for the change stream reader → `poll_batch` queue.
143type ChangeStreamTx = crossfire::MAsyncTx<crossfire::mpsc::Array<BufferedMongoEvent>>;
144/// Single-consumer async receiver for the change stream reader → `poll_batch` queue.
145type ChangeStreamRx = crossfire::AsyncRx<crossfire::mpsc::Array<BufferedMongoEvent>>;
146
147#[derive(Debug)]
148struct MongoReaderReady {
149    /// Fresh starts only: the boundary established before the first record.
150    initial_position: Option<MongoCheckpointPosition>,
151    collection_uuid: Uuid,
152    deployment_identity: MongoDeploymentIdentity,
153}
154
155#[derive(Clone, Debug)]
156enum MongoReaderFailure {
157    Configuration(String),
158    Connection(String),
159    Read(String),
160}
161
162impl MongoReaderFailure {
163    fn from_connector(error: &ConnectorError) -> Self {
164        match error {
165            ConnectorError::ConfigurationError(message) => Self::Configuration(message.clone()),
166            ConnectorError::ConnectionFailed(message) => Self::Connection(message.clone()),
167            ConnectorError::ReadError(message) => Self::Read(message.clone()),
168            error if error.is_transient() => Self::Read(error.to_string()),
169            error => Self::Configuration(error.to_string()),
170        }
171    }
172
173    fn into_connector(self) -> ConnectorError {
174        match self {
175            Self::Configuration(message) => ConnectorError::ConfigurationError(message),
176            Self::Connection(message) => ConnectorError::ConnectionFailed(message),
177            Self::Read(message) => ConnectorError::ReadError(message),
178        }
179    }
180}
181
182#[derive(Clone, Copy, Debug, PartialEq, Eq)]
183struct MongoCollectionObservation {
184    collection_uuid: Uuid,
185    post_images_enabled: bool,
186}
187
188#[derive(Clone, Debug, PartialEq, Eq)]
189struct MongoAdmissionObservation {
190    deployment_identity: MongoDeploymentIdentity,
191    collection: MongoCollectionObservation,
192}
193
194struct MongoReaderAdmissionGuard {
195    shutdown: Option<tokio::sync::watch::Sender<bool>>,
196}
197
198impl MongoReaderAdmissionGuard {
199    fn new(shutdown: tokio::sync::watch::Sender<bool>) -> Self {
200        Self {
201            shutdown: Some(shutdown),
202        }
203    }
204
205    fn disarm(&mut self) {
206        self.shutdown = None;
207    }
208}
209
210impl Drop for MongoReaderAdmissionGuard {
211    fn drop(&mut self) {
212        if let Some(shutdown) = self.shutdown.as_ref() {
213            shutdown.send_replace(true);
214        }
215    }
216}
217
218/// Records of one batch, resolved against the emitted position they advance.
219struct PendingBatch {
220    items: Vec<BufferedMongoEvent>,
221    next: EmittedPosition,
222    first_sequence: u64,
223    /// Snapshot time for snapshot rows in this batch.
224    snapshot_at: Option<Timestamp>,
225}
226
227impl MongoDbCdcSource {
228    /// Creates a new `MongoDB` CDC source with the given configuration.
229    #[must_use]
230    pub fn new(config: MongoDbSourceConfig, registry: Option<&prometheus::Registry>) -> Self {
231        let byte_budget = Arc::new(Semaphore::new(config.max_buffered_bytes));
232        let (task_owner, task_tracker) = ConnectorTaskOwner::new();
233        Self {
234            byte_budget,
235            config,
236            state: ConnectorState::Created,
237            schema: mongodb_history_schema(),
238            metrics: Arc::new(MongoDbCdcMetrics::new(registry)),
239            event_buffer: VecDeque::new(),
240            emitted: None,
241            collection_uuid: None,
242            deployment_identity: None,
243            projection: None,
244            snapshot_committed: None,
245            data_ready: Arc::new(Notify::new()),
246            reader_handle: None,
247            event_rx: None,
248            reader_shutdown: None,
249            reader_error: None,
250            task_owner,
251            task_tracker,
252        }
253    }
254
255    /// Advance `position` past one buffered item without mutating the source.
256    fn advance(
257        &self,
258        position: &mut EmittedPosition,
259        item: &BufferedMongoEvent,
260    ) -> Result<Option<Timestamp>, ConnectorError> {
261        let snapshot_at = match &position.position {
262            MongoCheckpointPosition::Snapshot(cut) => Some(cut.at),
263            MongoCheckpointPosition::Stream(_) => None,
264        };
265        match &item.payload {
266            BufferedMongoPayload::Change(record) => {
267                if self.produces_row(record.operation)? {
268                    position.next_sequence = next_sequence(position.next_sequence)?;
269                }
270                position.position = MongoCheckpointPosition::Stream(
271                    if record.operation == ChangeOperation::Invalidate {
272                        StreamPosition::StartAfter(record.token.clone())
273                    } else {
274                        StreamPosition::ResumeAfter(record.token.clone())
275                    },
276                );
277            }
278            BufferedMongoPayload::Snapshot(record) => {
279                let at = snapshot_at.ok_or_else(|| {
280                    ConnectorError::Internal("snapshot row outside a snapshot scan".into())
281                })?;
282                position.next_sequence = next_sequence(position.next_sequence)?;
283                position.position = MongoCheckpointPosition::Snapshot(SnapshotCut {
284                    at,
285                    after_key: Some(record.key.clone()),
286                });
287            }
288            BufferedMongoPayload::HighWatermark {
289                token,
290                requires_start_after,
291            } => {
292                position.position = MongoCheckpointPosition::Stream(if *requires_start_after {
293                    StreamPosition::StartAfter(token.clone())
294                } else {
295                    StreamPosition::ResumeAfter(token.clone())
296                });
297            }
298            BufferedMongoPayload::SnapshotComplete => {
299                let at = snapshot_at.ok_or_else(|| {
300                    ConnectorError::Internal("snapshot completion outside a snapshot scan".into())
301                })?;
302                position.position = MongoCheckpointPosition::Stream(StreamPosition::StartAt(at));
303            }
304        }
305        Ok(snapshot_at)
306    }
307
308    /// Whether a change event becomes an output row in this output mode.
309    fn produces_row(&self, operation: ChangeOperation) -> Result<bool, ConnectorError> {
310        match (self.config.output_mode, operation) {
311            (SourceOutputMode::History, _)
312            | (
313                SourceOutputMode::Document,
314                ChangeOperation::Insert
315                | ChangeOperation::Update
316                | ChangeOperation::Replace
317                | ChangeOperation::Delete,
318            ) => Ok(true),
319            (SourceOutputMode::Document, ChangeOperation::Metadata) => Ok(false),
320            (
321                SourceOutputMode::Document,
322                ChangeOperation::Invalidate
323                | ChangeOperation::Drop
324                | ChangeOperation::Rename
325                | ChangeOperation::DropDatabase
326                | ChangeOperation::Unknown,
327            ) => Err(ConnectorError::ConfigurationError(format!(
328                "MongoDB CDC document replication of {}.{} stopped at a {operation:?} event; \
329                 the mirror is not changed. Recreating, renaming, or dropping the source \
330                 collection requires rebuilding the targets from a new snapshot",
331                self.config.database, self.config.collection
332            ))),
333        }
334    }
335
336    /// Select the next batch: at most `max_records` items, ending at an invalidation.
337    fn plan_batch(&mut self, max_records: usize) -> Result<Option<PendingBatch>, ConnectorError> {
338        let Some(start) = self.emitted.clone() else {
339            return Err(ConnectorError::InvalidState {
340                expected: "admitted position".into(),
341                actual: "no emitted position".into(),
342            });
343        };
344        let mut next = start.clone();
345        let mut snapshot_at = None;
346        let mut count = 0;
347        for item in self.event_buffer.iter().take(max_records) {
348            let at = self.advance(&mut next, item)?;
349            snapshot_at = snapshot_at.or(at);
350            count += 1;
351            // An invalidate token changes the legal resume option. End the batch exactly there
352            // even when the reader has already reopened with startAfter and queued later data.
353            if item.is_invalidate() {
354                break;
355            }
356        }
357        if count == 0 {
358            return Ok(None);
359        }
360        Ok(Some(PendingBatch {
361            items: self.event_buffer.drain(..count).collect(),
362            next,
363            first_sequence: start.next_sequence,
364            snapshot_at,
365        }))
366    }
367
368    /// Drains up to `max_records` items and converts them to one source batch.
369    ///
370    /// # Errors
371    ///
372    /// Returns `ConnectorError` if a record cannot be represented; the queue is left intact.
373    fn drain_to_batch(
374        &mut self,
375        max_records: usize,
376    ) -> Result<Option<SourceBatch>, ConnectorError> {
377        if max_records == 0 || self.event_buffer.is_empty() {
378            return Ok(None);
379        }
380        let Some(pending) = self.plan_batch(max_records)? else {
381            return Ok(None);
382        };
383        let result = if pending.items.iter().any(BufferedMongoEvent::is_record) {
384            self.build_batch(&pending).map(Some)
385        } else {
386            Ok(None)
387        };
388        match result {
389            Ok(batch) => {
390                for item in &pending.items {
391                    if let BufferedMongoPayload::Change(record) = &item.payload {
392                        self.metrics.record_event(record.operation);
393                        if self.config.output_mode == SourceOutputMode::Document
394                            && record.operation == ChangeOperation::Metadata
395                        {
396                            self.metrics.metadata_events_skipped.inc();
397                        }
398                    }
399                }
400                if batch.is_some() {
401                    self.metrics.record_batch();
402                }
403                self.metrics
404                    .snapshot_in_progress
405                    .set(i64::from(pending.next.in_snapshot()));
406                self.emitted = Some(pending.next);
407                Ok(batch)
408            }
409            Err(error) => {
410                for item in pending.items.into_iter().rev() {
411                    self.event_buffer.push_front(item);
412                }
413                Err(error)
414            }
415        }
416    }
417
418    fn build_batch(&self, pending: &PendingBatch) -> Result<SourceBatch, ConnectorError> {
419        let (Some(collection_uuid), Some(deployment)) =
420            (self.collection_uuid, self.deployment_identity.as_ref())
421        else {
422            return Err(ConnectorError::InvalidState {
423                expected: "admitted collection identity".into(),
424                actual: "unbound source".into(),
425            });
426        };
427        match &self.projection {
428            None => {
429                let deployment = deployment.encode();
430                let identity = HistoryIdentity {
431                    deployment: &deployment,
432                    collection_uuid,
433                    database: &self.config.database,
434                    collection: &self.config.collection,
435                };
436                let records = pending
437                    .items
438                    .iter()
439                    .filter(|item| item.is_record())
440                    .map(|item| &item.payload)
441                    .collect::<Vec<_>>();
442                let batch = history_batch(
443                    records.into_iter(),
444                    &self.schema,
445                    &identity,
446                    pending.snapshot_at,
447                )?;
448                Ok(SourceBatch::new(batch))
449            }
450            Some(projection) => {
451                self.document_batch(projection, pending, collection_uuid.as_bytes())
452            }
453        }
454    }
455
456    fn document_batch(
457        &self,
458        projection: &DocumentProjection,
459        pending: &PendingBatch,
460        partition: &[u8],
461    ) -> Result<SourceBatch, ConnectorError> {
462        let snapshot_keys = pending
463            .items
464            .iter()
465            .filter_map(|item| match &item.payload {
466                BufferedMongoPayload::Snapshot(record) => Some(record),
467                _ => None,
468            })
469            .map(|record| {
470                record
471                    .raw
472                    .get("_id")
473                    .ok()
474                    .flatten()
475                    .map(id_key)
476                    .ok_or_else(|| {
477                        ConnectorError::SchemaMismatch("snapshot document has no _id".into())
478                    })
479            })
480            .collect::<Result<Vec<RawDocumentBuf>, _>>()?;
481        let mut snapshot_keys = snapshot_keys.iter();
482        let mut rows = Vec::with_capacity(pending.items.len());
483        for item in &pending.items {
484            match &item.payload {
485                BufferedMongoPayload::Change(record) => {
486                    if let Some(row) = self.change_row(record)? {
487                        rows.push(row);
488                    }
489                }
490                BufferedMongoPayload::Snapshot(record) => rows.push(DocumentRow::Put {
491                    key: snapshot_keys.next().ok_or_else(|| {
492                        ConnectorError::Internal("snapshot key bookkeeping".into())
493                    })?,
494                    document: &record.raw,
495                }),
496                BufferedMongoPayload::HighWatermark { .. }
497                | BufferedMongoPayload::SnapshotComplete => {}
498            }
499        }
500        let (records, mutations) = projection.build(&rows)?;
501        let positions = row_positions(partition, pending.first_sequence, rows.len())?;
502        let batch = SourceBatch::positioned(records, positions)?;
503        if mutations.contains(&SourceMutation::Tombstone) {
504            batch.with_mutations(mutations)
505        } else {
506            Ok(batch)
507        }
508    }
509
510    fn change_row<'a>(
511        &self,
512        record: &'a buffering::ChangeRecord,
513    ) -> Result<Option<DocumentRow<'a>>, ConnectorError> {
514        if !self.produces_row(record.operation)? {
515            return Ok(None);
516        }
517        let change = decode_change(&record.raw)?;
518        let key = change.document_key.ok_or_else(|| {
519            ConnectorError::SchemaMismatch(format!(
520                "MongoDB {} event omitted its document key",
521                change.operation
522            ))
523        })?;
524        if record.operation == ChangeOperation::Delete {
525            return Ok(Some(DocumentRow::Tombstone { key }));
526        }
527        let document = change.full_document.ok_or_else(|| {
528            ConnectorError::SchemaMismatch(format!(
529                "MongoDB {} event for {}.{} has no full document; document replication never \
530                 treats a missing image as a delete",
531                change.operation, self.config.database, self.config.collection
532            ))
533        })?;
534        Ok(Some(DocumentRow::Put { key, document }))
535    }
536}
537
538fn next_sequence(sequence: u64) -> Result<u64, ConnectorError> {
539    sequence
540        .checked_add(1)
541        .ok_or_else(|| ConnectorError::Internal("MongoDB CDC row sequence exhausted u64".into()))
542}
543
544/// Ordered positions: one partition per collection incarnation, the emitted-row sequence as the
545/// order key. The sequence is restored from the checkpoint, so replay reproduces it exactly.
546fn row_positions(
547    partition: &[u8],
548    first_sequence: u64,
549    rows: usize,
550) -> Result<SourceRowPositions, ConnectorError> {
551    let mut order_keys = Vec::with_capacity(rows);
552    let mut sequence = first_sequence;
553    for _ in 0..rows {
554        order_keys.push(sequence.to_be_bytes());
555        sequence = next_sequence(sequence)?;
556    }
557    SourceRowPositions::try_new(
558        BinaryArray::from_iter_values(std::iter::repeat_n(partition, rows)),
559        BinaryArray::from_iter_values(order_keys.iter()),
560        UInt32Array::from(vec![0; rows]),
561    )
562}
563
564#[cfg(test)]
565mod tests;