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.
5//!
6//! # Cancellation Safety
7//!
8//! Connector lifecycle futures never directly poll the `MongoDB` driver. Driver
9//! I/O lives in an owned reader task; cancellation aborts that task so no
10//! connection or cursor outlives its connector.
11
12use std::collections::VecDeque;
13use std::sync::Arc;
14
15use arrow_schema::{DataType, Field, Schema, SchemaRef};
16use tokio::sync::{Notify, Semaphore};
17use uuid::Uuid;
18
19use crate::config::ConnectorState;
20use crate::connector::{ConnectorTaskOwner, ConnectorTaskTracker, SourceBatch};
21use crate::error::ConnectorError;
22
23use super::change_event::{MongoDbChangeEvent, OperationType};
24use super::config::MongoDbSourceConfig;
25use super::metrics::MongoDbCdcMetrics;
26
27const MAX_RESUME_TOKEN_BYTES: usize = 64 * 1024;
28const MONGODB_CHECKPOINT_CONNECTOR: &str = "mongodb-cdc";
29const MONGODB_CHECKPOINT_VERSION: &str = "4";
30const STREAM_IDENTITY_METADATA: &str = "stream_identity_sha256";
31const COLLECTION_UUID_METADATA: &str = "collection_uuid";
32const DEPLOYMENT_IDENTITY_METADATA: &str = "deployment_identity";
33const RESUME_TOKEN_OFFSET: &str = "resume_token";
34const START_AFTER_TOKEN_OFFSET: &str = "start_after_token";
35#[cfg(feature = "mongodb-cdc")]
36const MAX_MONGODB_WIRE_EVENT_BYTES: usize = 16 * 1024 * 1024;
37#[cfg(feature = "mongodb-cdc")]
38const CURSOR_MAX_AWAIT_TIME: std::time::Duration = std::time::Duration::from_secs(1);
39#[cfg(feature = "mongodb-cdc")]
40const READER_STARTUP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
41
42mod admission;
43mod buffering;
44mod checkpoint;
45mod decoding;
46mod lifecycle;
47mod reader;
48
49use admission::observe_mongodb_admission;
50#[cfg(test)]
51use admission::{
52    await_mongo_reader_ready, mongodb_identity_command_is_permanent, source_client_options,
53};
54#[cfg(test)]
55use buffering::events_to_record_batch;
56use buffering::{
57    events_to_record_batch_refs, mongo_event_retained_bytes, mongo_high_watermark_retained_bytes,
58    BufferedMongoEvent, BufferedMongoPayload,
59};
60#[cfg(test)]
61use checkpoint::parse_deployment_identity;
62use checkpoint::{
63    canonical_resume_token, mongodb_stream_identity, parse_mongodb_checkpoint,
64    MongoCheckpointPosition, MongoDeploymentIdentity, ParsedMongoCheckpoint,
65};
66use decoding::parse_change_stream_event;
67#[cfg(test)]
68use reader::{
69    acquire_mongo_event_ownership, bootstrap_change_stream_options, change_stream_options,
70    send_event_or_shutdown, verify_mongodb_collection, verify_mongodb_collection_uuid,
71    verify_mongodb_deployment_identity,
72};
73use reader::{run_change_stream_reader, READER_SHUTDOWN_TIMEOUT};
74
75/// Returns the Arrow schema for `MongoDB` CDC envelope records.
76///
77/// | Column              | Type   | Nullable | Description                        |
78/// |---------------------|--------|----------|------------------------------------|
79/// | `_namespace`        | Utf8   | no       | `database.collection`              |
80/// | `_op`               | Utf8   | no       | Operation code (I/U/R/D/DROP/...)  |
81/// | `_document_key`     | Utf8   | no       | Document key JSON                  |
82/// | `_cluster_time_s`   | UInt32 | no       | Cluster time seconds               |
83/// | `_cluster_time_i`   | UInt32 | no       | Cluster time increment             |
84/// | `_wall_time_ms`     | Timestamp(ms) | no | Wall clock timestamp             |
85/// | `_full_document`    | Utf8   | yes      | Full document JSON                 |
86/// | `_update_desc`      | Utf8   | yes      | Update description JSON            |
87/// | `_resume_token`     | Utf8   | no       | Opaque resume token JSON           |
88#[must_use]
89pub fn mongodb_cdc_envelope_schema() -> SchemaRef {
90    Arc::new(Schema::new(vec![
91        Field::new("_namespace", DataType::Utf8, false),
92        Field::new("_op", DataType::Utf8, false),
93        Field::new("_document_key", DataType::Utf8, false),
94        Field::new("_cluster_time_s", DataType::UInt32, false),
95        Field::new("_cluster_time_i", DataType::UInt32, false),
96        Field::new(
97            "_wall_time_ms",
98            DataType::Timestamp(arrow_schema::TimeUnit::Millisecond, None),
99            false,
100        ),
101        Field::new("_full_document", DataType::Utf8, true),
102        Field::new("_update_desc", DataType::Utf8, true),
103        Field::new("_resume_token", DataType::Utf8, false),
104    ]))
105}
106
107/// `MongoDB` CDC source connector.
108///
109/// Streams change events from a `MongoDB` change stream using the
110/// `SourceConnector` trait. Events are buffered internally and
111/// converted to Arrow `RecordBatch`es on `poll_batch`.
112///
113/// # Sharded Cluster Note
114///
115/// On sharded clusters, `mongos` opens per-shard cursors and merges
116/// results transparently. Ensure `max_pool_size` is at least as large
117/// as the expected number of concurrent change streams to avoid
118/// connection starvation.
119pub struct MongoDbCdcSource {
120    /// Connector configuration.
121    config: MongoDbSourceConfig,
122
123    /// Current lifecycle state.
124    state: ConnectorState,
125
126    /// Output schema (CDC envelope).
127    schema: SchemaRef,
128
129    /// Lock-free metrics.
130    metrics: Arc<MongoDbCdcMetrics>,
131
132    /// Buffered change events awaiting `poll_batch`.
133    event_buffer: VecDeque<BufferedMongoEvent>,
134
135    /// Latest ordered event or post-batch token consumed by `poll_batch`.
136    /// The reader's newer cursor token is deliberately not shared with this field.
137    checkpoint_resume_token: Option<String>,
138
139    /// Invalidation tokens must be restored with `startAfter`, never `resumeAfter`.
140    checkpoint_requires_start_after: bool,
141
142    /// Physical identity of the fixed collection admitted by `listCollections`.
143    collection_uuid: Option<Uuid>,
144
145    /// Immutable server-issued identity of the replica set or sharded cluster.
146    deployment_identity: Option<MongoDeploymentIdentity>,
147
148    /// Shared ownership limits span the reader channel and poll buffer.
149    byte_budget: Arc<Semaphore>,
150
151    /// Notification handle signalled when data arrives from the stream.
152    data_ready: Arc<Notify>,
153
154    /// Background change stream reader task handle (feature-gated).
155    #[cfg(feature = "mongodb-cdc")]
156    reader_handle: Option<tokio::task::JoinHandle<()>>,
157
158    /// Channel receiver for change events from the background task.
159    #[cfg(feature = "mongodb-cdc")]
160    event_rx: Option<ChangeStreamRx>,
161
162    /// Shutdown signal for the background reader task.
163    #[cfg(feature = "mongodb-cdc")]
164    reader_shutdown: Option<tokio::sync::watch::Sender<bool>>,
165
166    /// Terminal reader failure, independent of the bounded event queue.
167    #[cfg(feature = "mongodb-cdc")]
168    reader_error: Option<tokio::sync::watch::Receiver<Option<MongoReaderFailure>>>,
169
170    /// Admission authority and terminal observer for this connector generation.
171    task_owner: ConnectorTaskOwner,
172    task_tracker: ConnectorTaskTracker,
173}
174
175impl Drop for MongoDbCdcSource {
176    fn drop(&mut self) {
177        #[cfg(feature = "mongodb-cdc")]
178        if let Some(shutdown) = self.reader_shutdown.take() {
179            shutdown.send_replace(true);
180        }
181        #[cfg(feature = "mongodb-cdc")]
182        if let Some(handle) = self.reader_handle.take() {
183            reap_mongo_reader(handle, &self.task_owner);
184        }
185    }
186}
187
188#[cfg(feature = "mongodb-cdc")]
189fn reap_mongo_reader(handle: tokio::task::JoinHandle<()>, task_owner: &ConnectorTaskOwner) {
190    let Some(reaper_guard) = task_owner.track() else {
191        tracing::warn!("MongoDB CDC task generation was sealed before reader reaping");
192        return;
193    };
194    let Ok(runtime) = tokio::runtime::Handle::try_current() else {
195        // The reader owns a separate task guard. Runtime destruction drops the
196        // reader future and resolves that proof without a timer or join guess.
197        drop(reaper_guard);
198        return;
199    };
200    drop(runtime.spawn(async move {
201        let _reaper_guard = reaper_guard;
202        if let Err(error) = handle.await {
203            tracing::debug!(%error, "MongoDB CDC retired reader task reaped");
204        }
205    }));
206}
207
208/// Cloneable async sender for the change stream reader → `poll_batch` queue.
209#[cfg(feature = "mongodb-cdc")]
210type ChangeStreamTx = crossfire::MAsyncTx<crossfire::mpsc::Array<BufferedMongoEvent>>;
211/// Single-consumer async receiver for the change stream reader → `poll_batch` queue.
212#[cfg(feature = "mongodb-cdc")]
213type ChangeStreamRx = crossfire::AsyncRx<crossfire::mpsc::Array<BufferedMongoEvent>>;
214
215#[cfg(feature = "mongodb-cdc")]
216#[derive(Debug)]
217struct MongoReaderReady {
218    initial_resume_token: Option<String>,
219    collection_uuid: Uuid,
220    deployment_identity: MongoDeploymentIdentity,
221}
222
223#[cfg(feature = "mongodb-cdc")]
224#[derive(Clone, Debug)]
225enum MongoReaderFailure {
226    Configuration(String),
227    Connection(String),
228    Read(String),
229}
230
231#[cfg(feature = "mongodb-cdc")]
232impl MongoReaderFailure {
233    fn from_connector(error: &ConnectorError) -> Self {
234        match error {
235            ConnectorError::ConfigurationError(message) => Self::Configuration(message.clone()),
236            ConnectorError::ConnectionFailed(message) => Self::Connection(message.clone()),
237            ConnectorError::ReadError(message) => Self::Read(message.clone()),
238            error if error.is_transient() => Self::Read(error.to_string()),
239            error => Self::Configuration(error.to_string()),
240        }
241    }
242
243    fn into_connector(self) -> ConnectorError {
244        match self {
245            Self::Configuration(message) => ConnectorError::ConfigurationError(message),
246            Self::Connection(message) => ConnectorError::ConnectionFailed(message),
247            Self::Read(message) => ConnectorError::ReadError(message),
248        }
249    }
250}
251
252#[cfg(feature = "mongodb-cdc")]
253#[derive(Clone, Copy, Debug, PartialEq, Eq)]
254struct MongoCollectionObservation {
255    collection_uuid: Uuid,
256    post_images_enabled: bool,
257}
258
259#[cfg(feature = "mongodb-cdc")]
260#[derive(Clone, Debug, PartialEq, Eq)]
261struct MongoAdmissionObservation {
262    deployment_identity: MongoDeploymentIdentity,
263    collection: MongoCollectionObservation,
264}
265
266#[cfg(feature = "mongodb-cdc")]
267struct MongoReaderAdmissionGuard {
268    shutdown: Option<tokio::sync::watch::Sender<bool>>,
269}
270
271#[cfg(feature = "mongodb-cdc")]
272impl MongoReaderAdmissionGuard {
273    fn new(shutdown: tokio::sync::watch::Sender<bool>) -> Self {
274        Self {
275            shutdown: Some(shutdown),
276        }
277    }
278
279    fn disarm(&mut self) {
280        self.shutdown = None;
281    }
282}
283
284#[cfg(feature = "mongodb-cdc")]
285impl Drop for MongoReaderAdmissionGuard {
286    fn drop(&mut self) {
287        if let Some(shutdown) = self.shutdown.as_ref() {
288            shutdown.send_replace(true);
289        }
290    }
291}
292
293#[cfg(feature = "mongodb-cdc")]
294#[derive(Clone, Debug, PartialEq)]
295enum MongoResumePosition {
296    ResumeAfter(mongodb::change_stream::event::ResumeToken),
297    StartAfter(mongodb::change_stream::event::ResumeToken),
298}
299
300impl MongoDbCdcSource {
301    /// Creates a new `MongoDB` CDC source with the given configuration.
302    #[must_use]
303    pub fn new(config: MongoDbSourceConfig, registry: Option<&prometheus::Registry>) -> Self {
304        let byte_budget = Arc::new(Semaphore::new(config.max_buffered_bytes));
305        let (task_owner, task_tracker) = ConnectorTaskOwner::new();
306        Self {
307            byte_budget,
308            config,
309            state: ConnectorState::Created,
310            schema: mongodb_cdc_envelope_schema(),
311            metrics: Arc::new(MongoDbCdcMetrics::new(registry)),
312            event_buffer: VecDeque::new(),
313            checkpoint_resume_token: None,
314            checkpoint_requires_start_after: false,
315            collection_uuid: None,
316            deployment_identity: None,
317            data_ready: Arc::new(Notify::new()),
318            #[cfg(feature = "mongodb-cdc")]
319            reader_handle: None,
320            #[cfg(feature = "mongodb-cdc")]
321            event_rx: None,
322            #[cfg(feature = "mongodb-cdc")]
323            reader_shutdown: None,
324            #[cfg(feature = "mongodb-cdc")]
325            reader_error: None,
326            task_owner,
327            task_tracker,
328        }
329    }
330
331    #[cfg(test)]
332    fn buffered_events(&self) -> usize {
333        self.event_buffer
334            .iter()
335            .filter(|item| item.event().is_some())
336            .count()
337    }
338
339    /// Enqueues a change event for focused source tests without bypassing production bounds.
340    #[cfg(test)]
341    fn enqueue_event(&mut self, event: MongoDbChangeEvent) -> Result<(), ConnectorError> {
342        let retained_bytes = mongo_event_retained_bytes(&event)?;
343        let byte_permits = u32::try_from(retained_bytes).map_err(|_| {
344            ConnectorError::ConfigurationError(format!(
345                "MongoDB CDC event exceeds the hard byte bound: event={retained_bytes}, limit={}",
346                self.config.max_buffered_bytes
347            ))
348        })?;
349        if retained_bytes > self.config.max_buffered_bytes {
350            return Err(ConnectorError::ConfigurationError(format!(
351                "MongoDB CDC event exceeds the hard byte bound: event={retained_bytes}, limit={}",
352                self.config.max_buffered_bytes
353            )));
354        }
355        let byte_permit = Arc::clone(&self.byte_budget)
356            .try_acquire_many_owned(byte_permits)
357            .map_err(|_| {
358                ConnectorError::ConfigurationError(format!(
359                    "MongoDB CDC buffered bytes reached the hard bound: limit={}",
360                    self.config.max_buffered_bytes
361                ))
362            })?;
363        self.metrics.record_event(event.operation_type.as_str());
364        self.event_buffer
365            .push_back(BufferedMongoEvent::new(event, byte_permit));
366        Ok(())
367    }
368
369    #[cfg(test)]
370    fn enqueue_high_watermark(&mut self, token: &str) -> Result<(), ConnectorError> {
371        let token = canonical_resume_token(token)?;
372        let retained_bytes = mongo_high_watermark_retained_bytes(token.capacity())?;
373        if retained_bytes > self.config.max_buffered_bytes {
374            return Err(ConnectorError::ConfigurationError(format!(
375                "MongoDB CDC high watermark exceeds the hard byte bound: item={retained_bytes}, \
376                 limit={}",
377                self.config.max_buffered_bytes
378            )));
379        }
380        let permits = u32::try_from(retained_bytes).map_err(|_| {
381            ConnectorError::ConfigurationError("MongoDB CDC high watermark is too large".into())
382        })?;
383        let byte_permit = Arc::clone(&self.byte_budget)
384            .try_acquire_many_owned(permits)
385            .map_err(|_| {
386                ConnectorError::ConfigurationError(
387                    "MongoDB CDC high watermark exceeded the byte budget".into(),
388                )
389            })?;
390        self.event_buffer
391            .push_back(BufferedMongoEvent::high_watermark(
392                token,
393                false,
394                byte_permit,
395            ));
396        Ok(())
397    }
398
399    /// Drains up to `max_records` events from the buffer and converts
400    /// them to an Arrow `RecordBatch`.
401    ///
402    /// # Errors
403    ///
404    /// Returns `ConnectorError` if Arrow batch construction fails.
405    fn drain_to_batch(
406        &mut self,
407        max_records: usize,
408    ) -> Result<Option<SourceBatch>, ConnectorError> {
409        if max_records == 0 || self.event_buffer.is_empty() {
410            return Ok(None);
411        }
412
413        let count = max_records.min(self.event_buffer.len());
414        // An invalidate token changes the legal resume option. End the batch exactly there even
415        // when the background reader has already reopened with startAfter and queued later data.
416        let count = self
417            .event_buffer
418            .iter()
419            .take(count)
420            .position(|item| {
421                item.event()
422                    .is_some_and(|event| event.operation_type == OperationType::Invalidate)
423            })
424            .map_or(count, |index| index + 1);
425        let items: Vec<BufferedMongoEvent> = self.event_buffer.drain(..count).collect();
426        let events: Vec<&MongoDbChangeEvent> =
427            items.iter().filter_map(BufferedMongoEvent::event).collect();
428        let (position_token, requires_start_after) = match items.last() {
429            Some(item) => {
430                let (token, start_after) = match &item.payload {
431                    BufferedMongoPayload::Event(event) => (
432                        event.resume_token.as_str(),
433                        event.operation_type == OperationType::Invalidate,
434                    ),
435                    BufferedMongoPayload::HighWatermark {
436                        token,
437                        requires_start_after,
438                    } => (token.as_str(), *requires_start_after),
439                };
440                match canonical_resume_token(token) {
441                    Ok(token) => (token, start_after),
442                    Err(error) => {
443                        drop(events);
444                        for item in items.into_iter().rev() {
445                            self.event_buffer.push_front(item);
446                        }
447                        return Err(error);
448                    }
449                }
450            }
451            None => return Ok(None),
452        };
453
454        if events.is_empty() {
455            self.checkpoint_resume_token = Some(position_token);
456            self.checkpoint_requires_start_after = requires_start_after;
457            return Ok(None);
458        }
459
460        let batch = match events_to_record_batch_refs(&events, &self.schema) {
461            Ok(batch) => batch,
462            Err(error) => {
463                drop(events);
464                for item in items.into_iter().rev() {
465                    self.event_buffer.push_front(item);
466                }
467                return Err(error);
468            }
469        };
470        self.metrics.record_batch();
471        self.checkpoint_resume_token = Some(position_token);
472        self.checkpoint_requires_start_after = requires_start_after;
473
474        Ok(Some(SourceBatch::new(batch)))
475    }
476}
477
478#[cfg(test)]
479mod tests;