Skip to main content

laminar_connectors/mongodb/sink/
mod.rs

1//! `MongoDB` sink connector implementation.
2//!
3//! Implements [`SinkConnector`] for writing Arrow `RecordBatch` data to
4//! `MongoDB` collections. Supports insert, upsert, and CDC replay
5//! write modes, with optional time series collection support.
6//!
7//! Writes are bounded by fixed retained-memory and bulk limits and flushed when
8//! a bound is reached, `flush_interval` elapses, or shutdown begins.
9//! Keyed and CDC flushes use one ordered `MongoDB` 8+ bulk operation, preserving
10//! input order without a network round trip per row.
11
12use std::future::Future;
13use std::sync::Arc;
14use std::time::Duration;
15
16use arrow_array::RecordBatch;
17use arrow_schema::{DataType, SchemaRef};
18use async_trait::async_trait;
19use tracing::{debug, info};
20
21use crate::changelog::collapse_changelog;
22use crate::config::{ConnectorConfig, ConnectorState};
23use crate::connector::{
24    ConnectorTaskOwner, ConnectorTaskTracker, SinkConnector, SinkConsistency, SinkContract,
25    SinkInputMode, SinkTopology, WriteResult,
26};
27use crate::error::ConnectorError;
28use laminar_core::changelog::WEIGHT_COLUMN;
29
30use super::config::MongoDbSinkConfig;
31use super::metrics::MongoDbSinkMetrics;
32use super::timeseries::CollectionKind;
33use super::write_model::WriteMode;
34
35// One budget covers retained Arrow, materialized BSON/write models, conversion scratch, and the
36// driver's command serialization. The multiplier reserves space for the materialized document,
37// the encoded command, and conversion/driver scratch instead of pretending those allocations are
38// independent. These are implementation limits, not user tuning knobs.
39const MAX_BUFFERED_RETAINED_BYTES: usize = 16 * 1024 * 1024;
40const MAX_SINK_WORKING_SET_BYTES: usize = 64 * 1024 * 1024;
41const MAX_STANDARD_DOCUMENT_BYTES: usize = 16 * 1024 * 1024;
42const MAX_TIMESERIES_DOCUMENT_BYTES: usize = 4 * 1024 * 1024;
43const MAX_DOCUMENTS_PER_FLUSH: usize = 1_000;
44const MATERIALIZED_BYTE_CHARGE: usize = 3;
45const WRITE_MODEL_OVERHEAD_BYTES: usize = 256;
46const CDC_ROW_OVERHEAD_BYTES: usize = 512;
47const MONGODB_8_WIRE_VERSION: i32 = 25;
48const DEFAULT_WRITE_TIMEOUT: Duration = Duration::from_secs(30);
49const MIN_WRITE_TIMEOUT: Duration = Duration::from_millis(100);
50const MAX_DRIVER_TIMEOUT_HEADROOM: Duration = Duration::from_secs(1);
51const NAMESPACE_EXISTS_CODE: i32 = 48;
52
53mod bulk;
54mod cdc;
55mod conversion;
56mod failure;
57mod lifecycle;
58mod validation;
59
60#[cfg(test)]
61use bulk::{await_mongo_sink_operation, MongoOperationOutcome};
62use bulk::{cdc_bulk_models, CdcWrite};
63use conversion::{
64    account_bson_document, accumulate_write_result, arrow_value_to_bson, cdc_row_value,
65    checked_converted_total, clamp_client_timeout, encoded_document_size, ensure_working_set,
66    json_to_bson_document, mongo_partial_batch_error, requires_preflush, retained_batch_bytes,
67    validate_cdc_document_key, validate_cdc_replacement_key,
68};
69#[cfg(test)]
70use conversion::{timestamp_millis, working_set_charge};
71#[cfg(test)]
72use failure::{
73    classify_mongo_bulk_facts, mongo_bulk_failure_facts, MongoBulkDisposition,
74    MongoBulkFailureFacts, MongoBulkFailureShape,
75};
76use failure::{classify_mongo_bulk_failure, MongoBulkFailure};
77pub(super) use validation::harden_mongodb_tls;
78#[cfg(test)]
79use validation::is_namespace_exists_code;
80use validation::{
81    is_namespace_exists, is_supported_mongodb_arrow_type, validate_existing_timeseries_spec,
82};
83
84/// Writes Arrow record batches to a `MongoDB` collection.
85///
86/// Supports standard and time series collections in insert, upsert, and CDC replay modes.
87pub struct MongoDbSink {
88    config: MongoDbSinkConfig,
89    schema: SchemaRef,
90    state: ConnectorState,
91    buffer: Vec<RecordBatch>,
92    buffered_rows: usize,
93    buffered_retained_bytes: usize,
94    write_timeout: Duration,
95    metrics: MongoDbSinkMetrics,
96
97    /// Admission authority and terminal observer for bulk operations owned by this generation.
98    task_owner: ConnectorTaskOwner,
99    task_tracker: ConnectorTaskTracker,
100
101    client: Option<mongodb::Client>,
102    collection: Option<mongodb::Collection<mongodb::bson::Document>>,
103}
104
105impl MongoDbSink {
106    /// Creates a new `MongoDB` sink connector.
107    #[must_use]
108    pub fn new(
109        schema: SchemaRef,
110        config: MongoDbSinkConfig,
111        registry: Option<&prometheus::Registry>,
112    ) -> Self {
113        let (task_owner, task_tracker) = ConnectorTaskOwner::new();
114        Self {
115            config,
116            schema,
117            state: ConnectorState::Created,
118            buffer: Vec::with_capacity(4),
119            buffered_rows: 0,
120            buffered_retained_bytes: 0,
121            write_timeout: DEFAULT_WRITE_TIMEOUT,
122            metrics: MongoDbSinkMetrics::new(registry),
123            task_owner,
124            task_tracker,
125            client: None,
126            collection: None,
127        }
128    }
129
130    /// Creates a new sink from a generic [`ConnectorConfig`].
131    ///
132    /// # Errors
133    ///
134    /// Returns `ConnectorError` if the configuration is invalid.
135    pub fn from_config(
136        schema: SchemaRef,
137        config: &ConnectorConfig,
138    ) -> Result<Self, ConnectorError> {
139        let mongo_config = MongoDbSinkConfig::from_config(config)?;
140        Self::validate_schema(&schema, &mongo_config)?;
141        let mut sink = Self::new(schema, mongo_config, None);
142        sink.write_timeout = Self::configured_write_timeout(config)?;
143        Ok(sink)
144    }
145
146    pub(crate) fn from_connector_config(
147        config: &ConnectorConfig,
148        registry: Option<&prometheus::Registry>,
149    ) -> Result<Self, ConnectorError> {
150        let (sink_config, schema, write_timeout) = Self::decode_connector_config(config)?;
151        let mut sink = Self::new(schema, sink_config, registry);
152        sink.write_timeout = write_timeout;
153        Ok(sink)
154    }
155
156    fn decode_connector_config(
157        config: &ConnectorConfig,
158    ) -> Result<(MongoDbSinkConfig, SchemaRef, Duration), ConnectorError> {
159        let sink_config = MongoDbSinkConfig::from_config(config)?;
160        if config.get("_arrow_schema").is_none() {
161            return Err(ConnectorError::ConfigurationError(
162                "MongoDB sink requires the engine-injected '_arrow_schema'".into(),
163            ));
164        }
165        let schema = config.arrow_schema().ok_or_else(|| {
166            ConnectorError::ConfigurationError(
167                "invalid MongoDB sink '_arrow_schema' encoding".into(),
168            )
169        })?;
170        Self::validate_schema(&schema, &sink_config)?;
171        let write_timeout = Self::configured_write_timeout(config)?;
172        Ok((sink_config, schema, write_timeout))
173    }
174
175    fn configured_write_timeout(config: &ConnectorConfig) -> Result<Duration, ConnectorError> {
176        let timeout = config
177            .get_parsed::<u64>("sink.write.timeout.ms")?
178            .map_or(DEFAULT_WRITE_TIMEOUT, Duration::from_millis);
179        if timeout < MIN_WRITE_TIMEOUT {
180            return Err(ConnectorError::ConfigurationError(format!(
181                "MongoDB sink.write.timeout.ms must be at least {}",
182                MIN_WRITE_TIMEOUT.as_millis()
183            )));
184        }
185        Ok(timeout)
186    }
187
188    fn driver_timeout(&self) -> Duration {
189        let headroom = (self.write_timeout / 5).min(MAX_DRIVER_TIMEOUT_HEADROOM);
190        self.write_timeout.checked_sub(headroom).unwrap()
191    }
192
193    fn validate_schema(
194        schema: &SchemaRef,
195        config: &MongoDbSinkConfig,
196    ) -> Result<(), ConnectorError> {
197        if schema.fields().is_empty() {
198            return Err(ConnectorError::ConfigurationError(
199                "MongoDB sink schema must contain at least one field".into(),
200            ));
201        }
202
203        let mut names = std::collections::HashSet::with_capacity(schema.fields().len());
204        for field in schema.fields() {
205            if !names.insert(field.name()) {
206                return Err(ConnectorError::ConfigurationError(format!(
207                    "MongoDB sink schema contains duplicate field '{}'",
208                    field.name()
209                )));
210            }
211            if !is_supported_mongodb_arrow_type(field.data_type()) {
212                return Err(ConnectorError::ConfigurationError(format!(
213                    "MongoDB sink field '{}' has unsupported Arrow type {:?}",
214                    field.name(),
215                    field.data_type()
216                )));
217            }
218        }
219
220        match &config.write_mode {
221            WriteMode::Upsert { key_fields } => {
222                for key in key_fields {
223                    match schema.field_with_name(key) {
224                        Ok(field) if !field.is_nullable() => {}
225                        Ok(_) => {
226                            return Err(ConnectorError::ConfigurationError(format!(
227                                "upsert key field '{key}' must be non-nullable"
228                            )));
229                        }
230                        Err(_) => {
231                            return Err(ConnectorError::ConfigurationError(format!(
232                                "upsert key field '{key}' is not present in MongoDB sink schema"
233                            )));
234                        }
235                    }
236                }
237            }
238            WriteMode::CdcReplay => {
239                for field in [
240                    "_namespace",
241                    "_op",
242                    "_document_key",
243                    "_full_document",
244                    "_update_desc",
245                ] {
246                    match schema.field_with_name(field) {
247                        Ok(schema_field) if schema_field.data_type() == &DataType::Utf8 => {}
248                        Ok(schema_field) => {
249                            return Err(ConnectorError::ConfigurationError(format!(
250                                "MongoDB CDC replay field '{field}' must be Utf8, got {:?}",
251                                schema_field.data_type()
252                            )));
253                        }
254                        Err(_) => {
255                            return Err(ConnectorError::ConfigurationError(format!(
256                                "MongoDB CDC replay schema must contain '{field}'"
257                            )));
258                        }
259                    }
260                }
261                for field in ["_namespace", "_op", "_document_key"] {
262                    if schema
263                        .field_with_name(field)
264                        .is_ok_and(arrow_schema::Field::is_nullable)
265                    {
266                        return Err(ConnectorError::ConfigurationError(format!(
267                            "MongoDB CDC replay field '{field}' must be non-nullable"
268                        )));
269                    }
270                }
271            }
272            WriteMode::Insert => {}
273        }
274
275        if let CollectionKind::TimeSeries(time_series) = &config.collection_kind {
276            let time_field = schema
277                .field_with_name(&time_series.time_field)
278                .map_err(|_| {
279                    ConnectorError::ConfigurationError(format!(
280                        "time series field '{}' is not present in MongoDB sink schema",
281                        time_series.time_field
282                    ))
283                })?;
284            if !matches!(time_field.data_type(), DataType::Timestamp(..)) {
285                return Err(ConnectorError::ConfigurationError(format!(
286                    "MongoDB time series field '{}' must be an Arrow Timestamp",
287                    time_series.time_field
288                )));
289            }
290            if time_field.is_nullable() {
291                return Err(ConnectorError::ConfigurationError(format!(
292                    "MongoDB time series field '{}' must be non-nullable",
293                    time_series.time_field
294                )));
295            }
296            if let Some(meta_field) = &time_series.meta_field {
297                if schema.field_with_name(meta_field).is_err() {
298                    return Err(ConnectorError::ConfigurationError(format!(
299                        "time series metadata field '{meta_field}' is not present in MongoDB sink schema"
300                    )));
301                }
302            }
303        }
304
305        Ok(())
306    }
307
308    fn apply_connector_config(&mut self, config: &ConnectorConfig) -> Result<(), ConnectorError> {
309        let (sink_config, schema, write_timeout) = Self::decode_connector_config(config)?;
310        self.config = sink_config;
311        self.schema = schema;
312        self.write_timeout = write_timeout;
313        Ok(())
314    }
315
316    /// Returns a reference to the sink configuration.
317    #[must_use]
318    pub fn config(&self) -> &MongoDbSinkConfig {
319        &self.config
320    }
321
322    /// Returns the number of buffered rows.
323    #[must_use]
324    pub fn buffered_rows(&self) -> usize {
325        self.buffered_rows
326    }
327
328    fn retain_batch(&mut self, batch: RecordBatch, retained_bytes: usize) {
329        self.buffered_rows = self.buffered_rows.saturating_add(batch.num_rows());
330        self.buffered_retained_bytes = self.buffered_retained_bytes.saturating_add(retained_bytes);
331        self.buffer.push(batch);
332    }
333
334    fn take_buffer(&mut self) -> (Vec<RecordBatch>, usize) {
335        let retained_bytes = self.buffered_retained_bytes;
336        self.buffered_rows = 0;
337        self.buffered_retained_bytes = 0;
338        (std::mem::take(&mut self.buffer), retained_bytes)
339    }
340
341    /// Converts CDC rows one at a time and drops each JSON staging value before advancing.
342    fn batches_to_cdc_writes(
343        batches: &[RecordBatch],
344        retained_bytes: usize,
345        working_set_limit: usize,
346        expected_namespace: &str,
347    ) -> Result<(Vec<CdcWrite>, u64), ConnectorError> {
348        let row_count = batches.iter().map(RecordBatch::num_rows).sum();
349        let mut writes = Vec::with_capacity(row_count);
350        let mut encoded_bytes = 0;
351
352        for batch in batches {
353            for row_idx in 0..batch.num_rows() {
354                let (value, staging_bytes) = cdc_row_value(batch, row_idx)?;
355                ensure_working_set(
356                    retained_bytes,
357                    encoded_bytes,
358                    writes.len().saturating_add(1),
359                    staging_bytes,
360                    working_set_limit,
361                    "MongoDB CDC flush",
362                )?;
363                let (mut row_writes, row_bytes) = Self::prepare_cdc_writes(
364                    std::slice::from_ref(&value),
365                    usize::MAX,
366                    expected_namespace,
367                )?;
368                let row_bytes = usize::try_from(row_bytes).map_err(|_| {
369                    ConnectorError::ConfigurationError(
370                        "MongoDB CDC encoded byte count exceeds this platform".into(),
371                    )
372                })?;
373                encoded_bytes = checked_converted_total(
374                    encoded_bytes,
375                    row_bytes,
376                    usize::MAX,
377                    "MongoDB CDC flush",
378                )?;
379                let write = row_writes.pop().ok_or_else(|| {
380                    ConnectorError::Internal("MongoDB CDC row produced no write disposition".into())
381                })?;
382                ensure_working_set(
383                    retained_bytes,
384                    encoded_bytes,
385                    writes.len().saturating_add(1),
386                    staging_bytes,
387                    working_set_limit,
388                    "MongoDB CDC flush",
389                )?;
390                writes.push(write);
391            }
392        }
393
394        Ok((writes, encoded_bytes))
395    }
396
397    /// Arrow batches → BSON documents directly (no `serde_json::Value` hop), for the
398    /// insert/upsert paths. Returns `(docs, byte_estimate)`.
399    fn batches_to_bson_docs(
400        batches: &[RecordBatch],
401        retained_bytes: usize,
402        working_set_limit: usize,
403        document_limit: usize,
404    ) -> Result<(Vec<mongodb::bson::Document>, u64), ConnectorError> {
405        let row_count = batches.iter().map(RecordBatch::num_rows).sum();
406        let mut docs = Vec::with_capacity(row_count);
407        let mut bytes: u64 = 0;
408        for batch in batches {
409            let schema = batch.schema();
410            for row in 0..batch.num_rows() {
411                let mut doc = mongodb::bson::Document::new();
412                for (col_idx, field) in schema.fields().iter().enumerate() {
413                    let value = arrow_value_to_bson(batch.column(col_idx), row)?;
414                    doc.insert(field.name().clone(), value);
415                }
416                let document_bytes =
417                    encoded_document_size(&doc, document_limit, "MongoDB sink document")?;
418                bytes = checked_converted_total(
419                    bytes,
420                    document_bytes,
421                    usize::MAX,
422                    "MongoDB sink flush",
423                )?;
424                ensure_working_set(
425                    retained_bytes,
426                    bytes,
427                    docs.len().saturating_add(1),
428                    0,
429                    working_set_limit,
430                    "MongoDB sink flush",
431                )?;
432                docs.push(doc);
433            }
434        }
435        Ok((docs, bytes))
436    }
437
438    /// Internal flush that returns a [`WriteResult`] with actual counts.
439    ///
440    /// Both `write_batch` (on auto-flush) and `flush` delegate here.
441    async fn flush_inner(&mut self) -> Result<WriteResult, ConnectorError> {
442        if self.buffer.is_empty() {
443            return Ok(WriteResult::new(0, 0));
444        }
445
446        // Drain before any await. A timeout makes the bulk-write outcome unknown, retires this
447        // connector generation, and replays from the last durable engine checkpoint.
448        let (mut pending, retained_bytes) = self.take_buffer();
449        let write_result: Result<(usize, u64), ConnectorError> =
450            if matches!(self.config.write_mode, WriteMode::CdcReplay) {
451                match Self::batches_to_cdc_writes(
452                    &pending,
453                    retained_bytes,
454                    MAX_SINK_WORKING_SET_BYTES,
455                    &format!("{}.{}", self.config.database, self.config.collection),
456                ) {
457                    Ok((writes, bytes)) => {
458                        let count = writes.len();
459                        pending.clear();
460                        self.execute_cdc_writes(writes)
461                            .await
462                            .map(|()| (count, bytes))
463                    }
464                    Err(error) => Err(error),
465                }
466            } else {
467                match Self::collapse_changelog_buffer_for_upsert(&self.config, &mut pending)
468                    .and_then(|collapsed| {
469                        let retained_bytes = pending.iter().fold(0_usize, |total, batch| {
470                            total.saturating_add(retained_batch_bytes(batch))
471                        });
472                        Self::batches_to_bson_docs(
473                            &pending,
474                            retained_bytes,
475                            MAX_SINK_WORKING_SET_BYTES,
476                            if matches!(&self.config.collection_kind, CollectionKind::TimeSeries(_))
477                            {
478                                MAX_TIMESERIES_DOCUMENT_BYTES
479                            } else {
480                                MAX_STANDARD_DOCUMENT_BYTES
481                            },
482                        )
483                        .map(|(docs, bytes)| (docs, collapsed, bytes))
484                    }) {
485                    Ok((docs, collapsed, bytes)) => {
486                        let count = docs.len();
487                        pending.clear();
488                        self.write_bson_docs(docs, collapsed, bytes)
489                            .await
490                            .map(|()| (count, bytes))
491                    }
492                    Err(error) => Err(error),
493                }
494            };
495
496        pending.clear();
497        self.buffer = pending;
498
499        let (doc_count, byte_estimate) = write_result?;
500        self.metrics.record_flush(doc_count as u64, byte_estimate);
501        Ok(WriteResult::new(doc_count, byte_estimate))
502    }
503
504    /// Collapse a Z-set changelog buffer (an incremental MV: rows carry `__weight`) into a single
505    /// key-unique `{U,D}` batch before upsert, so retract+insert nets per key and a removed group
506    /// becomes a delete. No-op unless the write mode is upsert and the buffer is a Z-set changelog.
507    /// `Ok(true)` if the buffer was a Z-set changelog and got collapsed; `Ok(false)` for a plain
508    /// (non-changelog) upsert, so the caller doesn't interpret `_op` on rows we didn't produce.
509    fn collapse_changelog_buffer_for_upsert(
510        config: &MongoDbSinkConfig,
511        buffer: &mut Vec<RecordBatch>,
512    ) -> Result<bool, ConnectorError> {
513        let key_fields = match &config.write_mode {
514            WriteMode::Upsert { key_fields } => key_fields.clone(),
515            _ => return Ok(false),
516        };
517        // Gate on buffer[0] — it supplies the concat schema (concat requires all batches match it);
518        // `any` could pass on a later batch's weight while buffer[0] lacks it, failing the concat.
519        let Some(first) = buffer.first() else {
520            return Ok(false);
521        };
522        if first.schema().index_of(WEIGHT_COLUMN).is_err() {
523            return Ok(false);
524        }
525        let schema = first.schema();
526        let combined = arrow_select::concat::concat_batches(&schema, buffer.iter())
527            .map_err(|e| ConnectorError::Internal(format!("concat changelog: {e}")))?;
528        *buffer = vec![collapse_changelog(&combined, &key_fields)?];
529        Ok(true)
530    }
531
532    async fn write_batch_with_retained_limit(
533        &mut self,
534        batch: &RecordBatch,
535        retained_limit: usize,
536    ) -> Result<WriteResult, ConnectorError> {
537        if self.state != ConnectorState::Running {
538            return Err(ConnectorError::InvalidState {
539                expected: ConnectorState::Running.to_string(),
540                actual: self.state.to_string(),
541            });
542        }
543        if batch.schema().as_ref() != self.schema.as_ref() {
544            return Err(ConnectorError::SchemaMismatch(format!(
545                "MongoDB sink expected {:?}, got {:?}",
546                self.schema,
547                batch.schema()
548            )));
549        }
550
551        let rows = batch.num_rows();
552        if rows == 0 {
553            return Ok(WriteResult::new(0, 0));
554        }
555
556        let retained_bytes = retained_batch_bytes(batch);
557        // Reject a single oversized input before flushing or otherwise mutating existing state.
558        let _ = requires_preflush(0, retained_bytes, retained_limit)?;
559
560        let mut offset = 0;
561        let mut result = WriteResult::new(0, 0);
562        while offset < rows {
563            let available_rows = MAX_DOCUMENTS_PER_FLUSH - self.buffered_rows;
564            let chunk_rows = available_rows.min(rows - offset);
565            let chunk = batch.slice(offset, chunk_rows);
566            let chunk_retained_bytes = retained_batch_bytes(&chunk);
567
568            if requires_preflush(
569                self.buffered_retained_bytes,
570                chunk_retained_bytes,
571                retained_limit,
572            )? {
573                let flushed = self
574                    .flush_inner()
575                    .await
576                    .map_err(|error| mongo_partial_batch_error(&result, error))?;
577                accumulate_write_result(&mut result, &flushed);
578            }
579
580            self.retain_batch(chunk, chunk_retained_bytes);
581            offset += chunk_rows;
582            if self.buffered_rows == MAX_DOCUMENTS_PER_FLUSH {
583                let flushed = self
584                    .flush_inner()
585                    .await
586                    .map_err(|error| mongo_partial_batch_error(&result, error))?;
587                accumulate_write_result(&mut result, &flushed);
588            }
589        }
590
591        Ok(result)
592    }
593}
594
595#[cfg(test)]
596mod tests;