Skip to main content

laminar_connectors/mongodb/
sink.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
53fn is_supported_mongodb_arrow_type(data_type: &DataType) -> bool {
54    matches!(
55        data_type,
56        DataType::Null
57            | DataType::Boolean
58            | DataType::Int8
59            | DataType::Int16
60            | DataType::Int32
61            | DataType::Int64
62            | DataType::UInt8
63            | DataType::UInt16
64            | DataType::UInt32
65            | DataType::Float32
66            | DataType::Float64
67            | DataType::Utf8
68            | DataType::LargeUtf8
69            | DataType::Timestamp(..)
70    )
71}
72
73pub(super) fn harden_mongodb_tls(
74    options: &mut mongodb::options::ClientOptions,
75) -> Result<(), ConnectorError> {
76    use mongodb::options::{Tls, TlsOptions};
77
78    match options.tls.as_ref() {
79        Some(Tls::Enabled(tls)) if tls.allow_invalid_certificates == Some(true) => {
80            return Err(ConnectorError::ConfigurationError(
81                "MongoDB connection.uri must not set tlsInsecure=true or \
82                 tlsAllowInvalidCertificates=true"
83                    .into(),
84            ));
85        }
86        Some(Tls::Enabled(_) | Tls::Disabled) => {}
87        None => options.tls = Some(Tls::Enabled(TlsOptions::default())),
88    }
89    Ok(())
90}
91
92fn is_namespace_exists(error: &mongodb::error::Error) -> bool {
93    matches!(
94        error.kind.as_ref(),
95        mongodb::error::ErrorKind::Command(command) if is_namespace_exists_code(command.code)
96    )
97}
98
99fn is_namespace_exists_code(code: i32) -> bool {
100    code == NAMESPACE_EXISTS_CODE
101}
102
103fn validate_existing_timeseries_spec(
104    spec: &mongodb::results::CollectionSpecification,
105    expected: &super::timeseries::TimeSeriesConfig,
106) -> Result<(), ConnectorError> {
107    use super::timeseries::TimeSeriesGranularity;
108    use mongodb::options::TimeseriesGranularity as DriverGranularity;
109    use mongodb::results::CollectionType;
110
111    if spec.collection_type != CollectionType::Timeseries {
112        return Err(ConnectorError::ConfigurationError(format!(
113            "existing MongoDB collection '{}' is not a time series collection",
114            spec.name
115        )));
116    }
117
118    let actual = spec.options.timeseries.as_ref().ok_or_else(|| {
119        ConnectorError::ConfigurationError(format!(
120            "existing MongoDB time series collection '{}' has no time series options",
121            spec.name
122        ))
123    })?;
124
125    if actual.time_field != expected.time_field {
126        return Err(ConnectorError::ConfigurationError(format!(
127            "existing MongoDB time series collection '{}' uses time field '{}', expected '{}'",
128            spec.name, actual.time_field, expected.time_field
129        )));
130    }
131    if actual.meta_field != expected.meta_field {
132        return Err(ConnectorError::ConfigurationError(format!(
133            "existing MongoDB time series collection '{}' uses meta field {:?}, expected {:?}",
134            spec.name, actual.meta_field, expected.meta_field
135        )));
136    }
137
138    let granularity_matches = match expected.granularity {
139        TimeSeriesGranularity::Seconds => match actual.granularity.as_ref() {
140            Some(granularity) => granularity == &DriverGranularity::Seconds,
141            None => actual.bucket_max_span.is_none() && actual.bucket_rounding.is_none(),
142        },
143        TimeSeriesGranularity::Minutes => {
144            actual.granularity.as_ref() == Some(&DriverGranularity::Minutes)
145        }
146        TimeSeriesGranularity::Hours => {
147            actual.granularity.as_ref() == Some(&DriverGranularity::Hours)
148        }
149        TimeSeriesGranularity::Custom {
150            bucket_max_span_seconds,
151            bucket_rounding_seconds,
152        } => {
153            actual.granularity.is_none()
154                && actual.bucket_max_span
155                    == Some(Duration::from_secs(u64::from(bucket_max_span_seconds)))
156                && actual.bucket_rounding
157                    == Some(Duration::from_secs(u64::from(bucket_rounding_seconds)))
158        }
159    };
160    if !granularity_matches {
161        return Err(ConnectorError::ConfigurationError(format!(
162            "existing MongoDB time series collection '{}' has incompatible granularity",
163            spec.name
164        )));
165    }
166
167    let expected_ttl = expected.expire_after_seconds.map(Duration::from_secs);
168    if spec.options.expire_after_seconds != expected_ttl {
169        return Err(ConnectorError::ConfigurationError(format!(
170            "existing MongoDB time series collection '{}' uses TTL {:?}, expected {:?}",
171            spec.name, spec.options.expire_after_seconds, expected_ttl
172        )));
173    }
174
175    Ok(())
176}
177
178/// `MongoDB` sink connector.
179///
180/// Writes Arrow `RecordBatch` records to a `MongoDB` collection using
181/// configurable write modes. Supports standard and time series collections.
182pub struct MongoDbSink {
183    config: MongoDbSinkConfig,
184    schema: SchemaRef,
185    state: ConnectorState,
186    buffer: Vec<RecordBatch>,
187    buffered_rows: usize,
188    buffered_retained_bytes: usize,
189    write_timeout: Duration,
190    metrics: MongoDbSinkMetrics,
191
192    /// Admission authority and terminal observer for bulk operations owned by this generation.
193    task_owner: ConnectorTaskOwner,
194    task_tracker: ConnectorTaskTracker,
195
196    client: Option<mongodb::Client>,
197    collection: Option<mongodb::Collection<mongodb::bson::Document>>,
198}
199
200impl MongoDbSink {
201    /// Creates a new `MongoDB` sink connector.
202    #[must_use]
203    pub fn new(
204        schema: SchemaRef,
205        config: MongoDbSinkConfig,
206        registry: Option<&prometheus::Registry>,
207    ) -> Self {
208        let (task_owner, task_tracker) = ConnectorTaskOwner::new();
209        Self {
210            config,
211            schema,
212            state: ConnectorState::Created,
213            buffer: Vec::with_capacity(4),
214            buffered_rows: 0,
215            buffered_retained_bytes: 0,
216            write_timeout: DEFAULT_WRITE_TIMEOUT,
217            metrics: MongoDbSinkMetrics::new(registry),
218            task_owner,
219            task_tracker,
220            client: None,
221            collection: None,
222        }
223    }
224
225    /// Creates a new sink from a generic [`ConnectorConfig`].
226    ///
227    /// # Errors
228    ///
229    /// Returns `ConnectorError` if the configuration is invalid.
230    pub fn from_config(
231        schema: SchemaRef,
232        config: &ConnectorConfig,
233    ) -> Result<Self, ConnectorError> {
234        let mongo_config = MongoDbSinkConfig::from_config(config)?;
235        Self::validate_schema(&schema, &mongo_config)?;
236        let mut sink = Self::new(schema, mongo_config, None);
237        sink.write_timeout = Self::configured_write_timeout(config)?;
238        Ok(sink)
239    }
240
241    pub(crate) fn from_connector_config(
242        config: &ConnectorConfig,
243        registry: Option<&prometheus::Registry>,
244    ) -> Result<Self, ConnectorError> {
245        let (sink_config, schema, write_timeout) = Self::decode_connector_config(config)?;
246        let mut sink = Self::new(schema, sink_config, registry);
247        sink.write_timeout = write_timeout;
248        Ok(sink)
249    }
250
251    fn decode_connector_config(
252        config: &ConnectorConfig,
253    ) -> Result<(MongoDbSinkConfig, SchemaRef, Duration), ConnectorError> {
254        let sink_config = MongoDbSinkConfig::from_config(config)?;
255        if config.get("_arrow_schema").is_none() {
256            return Err(ConnectorError::ConfigurationError(
257                "MongoDB sink requires the engine-injected '_arrow_schema'".into(),
258            ));
259        }
260        let schema = config.arrow_schema().ok_or_else(|| {
261            ConnectorError::ConfigurationError(
262                "invalid MongoDB sink '_arrow_schema' encoding".into(),
263            )
264        })?;
265        Self::validate_schema(&schema, &sink_config)?;
266        let write_timeout = Self::configured_write_timeout(config)?;
267        Ok((sink_config, schema, write_timeout))
268    }
269
270    fn configured_write_timeout(config: &ConnectorConfig) -> Result<Duration, ConnectorError> {
271        let timeout = config
272            .get_parsed::<u64>("sink.write.timeout.ms")?
273            .map_or(DEFAULT_WRITE_TIMEOUT, Duration::from_millis);
274        if timeout < MIN_WRITE_TIMEOUT {
275            return Err(ConnectorError::ConfigurationError(format!(
276                "MongoDB sink.write.timeout.ms must be at least {}",
277                MIN_WRITE_TIMEOUT.as_millis()
278            )));
279        }
280        Ok(timeout)
281    }
282
283    fn driver_timeout(&self) -> Duration {
284        let headroom = (self.write_timeout / 5).min(MAX_DRIVER_TIMEOUT_HEADROOM);
285        self.write_timeout.checked_sub(headroom).unwrap()
286    }
287
288    fn validate_schema(
289        schema: &SchemaRef,
290        config: &MongoDbSinkConfig,
291    ) -> Result<(), ConnectorError> {
292        if schema.fields().is_empty() {
293            return Err(ConnectorError::ConfigurationError(
294                "MongoDB sink schema must contain at least one field".into(),
295            ));
296        }
297
298        let mut names = std::collections::HashSet::with_capacity(schema.fields().len());
299        for field in schema.fields() {
300            if !names.insert(field.name()) {
301                return Err(ConnectorError::ConfigurationError(format!(
302                    "MongoDB sink schema contains duplicate field '{}'",
303                    field.name()
304                )));
305            }
306            if !is_supported_mongodb_arrow_type(field.data_type()) {
307                return Err(ConnectorError::ConfigurationError(format!(
308                    "MongoDB sink field '{}' has unsupported Arrow type {:?}",
309                    field.name(),
310                    field.data_type()
311                )));
312            }
313        }
314
315        match &config.write_mode {
316            WriteMode::Upsert { key_fields } => {
317                for key in key_fields {
318                    match schema.field_with_name(key) {
319                        Ok(field) if !field.is_nullable() => {}
320                        Ok(_) => {
321                            return Err(ConnectorError::ConfigurationError(format!(
322                                "upsert key field '{key}' must be non-nullable"
323                            )));
324                        }
325                        Err(_) => {
326                            return Err(ConnectorError::ConfigurationError(format!(
327                                "upsert key field '{key}' is not present in MongoDB sink schema"
328                            )));
329                        }
330                    }
331                }
332            }
333            WriteMode::CdcReplay => {
334                for field in [
335                    "_namespace",
336                    "_op",
337                    "_document_key",
338                    "_full_document",
339                    "_update_desc",
340                ] {
341                    match schema.field_with_name(field) {
342                        Ok(schema_field) if schema_field.data_type() == &DataType::Utf8 => {}
343                        Ok(schema_field) => {
344                            return Err(ConnectorError::ConfigurationError(format!(
345                                "MongoDB CDC replay field '{field}' must be Utf8, got {:?}",
346                                schema_field.data_type()
347                            )));
348                        }
349                        Err(_) => {
350                            return Err(ConnectorError::ConfigurationError(format!(
351                                "MongoDB CDC replay schema must contain '{field}'"
352                            )));
353                        }
354                    }
355                }
356                for field in ["_namespace", "_op", "_document_key"] {
357                    if schema
358                        .field_with_name(field)
359                        .is_ok_and(arrow_schema::Field::is_nullable)
360                    {
361                        return Err(ConnectorError::ConfigurationError(format!(
362                            "MongoDB CDC replay field '{field}' must be non-nullable"
363                        )));
364                    }
365                }
366            }
367            WriteMode::Insert => {}
368        }
369
370        if let CollectionKind::TimeSeries(time_series) = &config.collection_kind {
371            let time_field = schema
372                .field_with_name(&time_series.time_field)
373                .map_err(|_| {
374                    ConnectorError::ConfigurationError(format!(
375                        "time series field '{}' is not present in MongoDB sink schema",
376                        time_series.time_field
377                    ))
378                })?;
379            if !matches!(time_field.data_type(), DataType::Timestamp(..)) {
380                return Err(ConnectorError::ConfigurationError(format!(
381                    "MongoDB time series field '{}' must be an Arrow Timestamp",
382                    time_series.time_field
383                )));
384            }
385            if time_field.is_nullable() {
386                return Err(ConnectorError::ConfigurationError(format!(
387                    "MongoDB time series field '{}' must be non-nullable",
388                    time_series.time_field
389                )));
390            }
391            if let Some(meta_field) = &time_series.meta_field {
392                if schema.field_with_name(meta_field).is_err() {
393                    return Err(ConnectorError::ConfigurationError(format!(
394                        "time series metadata field '{meta_field}' is not present in MongoDB sink schema"
395                    )));
396                }
397            }
398        }
399
400        Ok(())
401    }
402
403    fn apply_connector_config(&mut self, config: &ConnectorConfig) -> Result<(), ConnectorError> {
404        let (sink_config, schema, write_timeout) = Self::decode_connector_config(config)?;
405        self.config = sink_config;
406        self.schema = schema;
407        self.write_timeout = write_timeout;
408        Ok(())
409    }
410
411    /// Returns a reference to the sink configuration.
412    #[must_use]
413    pub fn config(&self) -> &MongoDbSinkConfig {
414        &self.config
415    }
416
417    /// Returns the number of buffered rows.
418    #[must_use]
419    pub fn buffered_rows(&self) -> usize {
420        self.buffered_rows
421    }
422
423    fn retain_batch(&mut self, batch: RecordBatch, retained_bytes: usize) {
424        self.buffered_rows = self.buffered_rows.saturating_add(batch.num_rows());
425        self.buffered_retained_bytes = self.buffered_retained_bytes.saturating_add(retained_bytes);
426        self.buffer.push(batch);
427    }
428
429    fn take_buffer(&mut self) -> (Vec<RecordBatch>, usize) {
430        let retained_bytes = self.buffered_retained_bytes;
431        self.buffered_rows = 0;
432        self.buffered_retained_bytes = 0;
433        (std::mem::take(&mut self.buffer), retained_bytes)
434    }
435
436    /// Converts CDC rows one at a time and drops each JSON staging value before advancing.
437    fn batches_to_cdc_writes(
438        batches: &[RecordBatch],
439        retained_bytes: usize,
440        working_set_limit: usize,
441        expected_namespace: &str,
442    ) -> Result<(Vec<CdcWrite>, u64), ConnectorError> {
443        let row_count = batches.iter().map(RecordBatch::num_rows).sum();
444        let mut writes = Vec::with_capacity(row_count);
445        let mut encoded_bytes = 0;
446
447        for batch in batches {
448            for row_idx in 0..batch.num_rows() {
449                let (value, staging_bytes) = cdc_row_value(batch, row_idx)?;
450                ensure_working_set(
451                    retained_bytes,
452                    encoded_bytes,
453                    writes.len().saturating_add(1),
454                    staging_bytes,
455                    working_set_limit,
456                    "MongoDB CDC flush",
457                )?;
458                let (mut row_writes, row_bytes) = Self::prepare_cdc_writes(
459                    std::slice::from_ref(&value),
460                    usize::MAX,
461                    expected_namespace,
462                )?;
463                let row_bytes = usize::try_from(row_bytes).map_err(|_| {
464                    ConnectorError::ConfigurationError(
465                        "MongoDB CDC encoded byte count exceeds this platform".into(),
466                    )
467                })?;
468                encoded_bytes = checked_converted_total(
469                    encoded_bytes,
470                    row_bytes,
471                    usize::MAX,
472                    "MongoDB CDC flush",
473                )?;
474                let write = row_writes.pop().ok_or_else(|| {
475                    ConnectorError::Internal("MongoDB CDC row produced no write disposition".into())
476                })?;
477                ensure_working_set(
478                    retained_bytes,
479                    encoded_bytes,
480                    writes.len().saturating_add(1),
481                    staging_bytes,
482                    working_set_limit,
483                    "MongoDB CDC flush",
484                )?;
485                writes.push(write);
486            }
487        }
488
489        Ok((writes, encoded_bytes))
490    }
491
492    /// Arrow batches → BSON documents directly (no `serde_json::Value` hop), for the
493    /// insert/upsert paths. Returns `(docs, byte_estimate)`.
494    fn batches_to_bson_docs(
495        batches: &[RecordBatch],
496        retained_bytes: usize,
497        working_set_limit: usize,
498        document_limit: usize,
499    ) -> Result<(Vec<mongodb::bson::Document>, u64), ConnectorError> {
500        let row_count = batches.iter().map(RecordBatch::num_rows).sum();
501        let mut docs = Vec::with_capacity(row_count);
502        let mut bytes: u64 = 0;
503        for batch in batches {
504            let schema = batch.schema();
505            for row in 0..batch.num_rows() {
506                let mut doc = mongodb::bson::Document::new();
507                for (col_idx, field) in schema.fields().iter().enumerate() {
508                    let value = arrow_value_to_bson(batch.column(col_idx), row)?;
509                    doc.insert(field.name().clone(), value);
510                }
511                let document_bytes =
512                    encoded_document_size(&doc, document_limit, "MongoDB sink document")?;
513                bytes = checked_converted_total(
514                    bytes,
515                    document_bytes,
516                    usize::MAX,
517                    "MongoDB sink flush",
518                )?;
519                ensure_working_set(
520                    retained_bytes,
521                    bytes,
522                    docs.len().saturating_add(1),
523                    0,
524                    working_set_limit,
525                    "MongoDB sink flush",
526                )?;
527                docs.push(doc);
528            }
529        }
530        Ok((docs, bytes))
531    }
532
533    /// Internal flush that returns a [`WriteResult`] with actual counts.
534    ///
535    /// Both `write_batch` (on auto-flush) and `flush` delegate here.
536    async fn flush_inner(&mut self) -> Result<WriteResult, ConnectorError> {
537        if self.buffer.is_empty() {
538            return Ok(WriteResult::new(0, 0));
539        }
540
541        // Drain before any await. A timeout makes the bulk-write outcome unknown, retires this
542        // connector generation, and replays from the last durable engine checkpoint.
543        let (mut pending, retained_bytes) = self.take_buffer();
544        let write_result: Result<(usize, u64), ConnectorError> =
545            if matches!(self.config.write_mode, WriteMode::CdcReplay) {
546                match Self::batches_to_cdc_writes(
547                    &pending,
548                    retained_bytes,
549                    MAX_SINK_WORKING_SET_BYTES,
550                    &format!("{}.{}", self.config.database, self.config.collection),
551                ) {
552                    Ok((writes, bytes)) => {
553                        let count = writes.len();
554                        pending.clear();
555                        self.execute_cdc_writes(writes)
556                            .await
557                            .map(|()| (count, bytes))
558                    }
559                    Err(error) => Err(error),
560                }
561            } else {
562                match Self::collapse_changelog_buffer_for_upsert(&self.config, &mut pending)
563                    .and_then(|collapsed| {
564                        let retained_bytes = pending.iter().fold(0_usize, |total, batch| {
565                            total.saturating_add(retained_batch_bytes(batch))
566                        });
567                        Self::batches_to_bson_docs(
568                            &pending,
569                            retained_bytes,
570                            MAX_SINK_WORKING_SET_BYTES,
571                            if matches!(&self.config.collection_kind, CollectionKind::TimeSeries(_))
572                            {
573                                MAX_TIMESERIES_DOCUMENT_BYTES
574                            } else {
575                                MAX_STANDARD_DOCUMENT_BYTES
576                            },
577                        )
578                        .map(|(docs, bytes)| (docs, collapsed, bytes))
579                    }) {
580                    Ok((docs, collapsed, bytes)) => {
581                        let count = docs.len();
582                        pending.clear();
583                        self.write_bson_docs(docs, collapsed, bytes)
584                            .await
585                            .map(|()| (count, bytes))
586                    }
587                    Err(error) => Err(error),
588                }
589            };
590
591        pending.clear();
592        self.buffer = pending;
593
594        let (doc_count, byte_estimate) = write_result?;
595        self.metrics.record_flush(doc_count as u64, byte_estimate);
596        Ok(WriteResult::new(doc_count, byte_estimate))
597    }
598
599    /// Collapse a Z-set changelog buffer (an incremental MV: rows carry `__weight`) into a single
600    /// key-unique `{U,D}` batch before upsert, so retract+insert nets per key and a removed group
601    /// becomes a delete. No-op unless the write mode is upsert and the buffer is a Z-set changelog.
602    /// `Ok(true)` if the buffer was a Z-set changelog and got collapsed; `Ok(false)` for a plain
603    /// (non-changelog) upsert, so the caller doesn't interpret `_op` on rows we didn't produce.
604    fn collapse_changelog_buffer_for_upsert(
605        config: &MongoDbSinkConfig,
606        buffer: &mut Vec<RecordBatch>,
607    ) -> Result<bool, ConnectorError> {
608        let key_fields = match &config.write_mode {
609            WriteMode::Upsert { key_fields } => key_fields.clone(),
610            _ => return Ok(false),
611        };
612        // Gate on buffer[0] — it supplies the concat schema (concat requires all batches match it);
613        // `any` could pass on a later batch's weight while buffer[0] lacks it, failing the concat.
614        let Some(first) = buffer.first() else {
615            return Ok(false);
616        };
617        if first.schema().index_of(WEIGHT_COLUMN).is_err() {
618            return Ok(false);
619        }
620        let schema = first.schema();
621        let combined = arrow_select::concat::concat_batches(&schema, buffer.iter())
622            .map_err(|e| ConnectorError::Internal(format!("concat changelog: {e}")))?;
623        *buffer = vec![collapse_changelog(&combined, &key_fields)?];
624        Ok(true)
625    }
626
627    async fn write_batch_with_retained_limit(
628        &mut self,
629        batch: &RecordBatch,
630        retained_limit: usize,
631    ) -> Result<WriteResult, ConnectorError> {
632        if self.state != ConnectorState::Running {
633            return Err(ConnectorError::InvalidState {
634                expected: ConnectorState::Running.to_string(),
635                actual: self.state.to_string(),
636            });
637        }
638        if batch.schema().as_ref() != self.schema.as_ref() {
639            return Err(ConnectorError::SchemaMismatch(format!(
640                "MongoDB sink expected {:?}, got {:?}",
641                self.schema,
642                batch.schema()
643            )));
644        }
645
646        let rows = batch.num_rows();
647        if rows == 0 {
648            return Ok(WriteResult::new(0, 0));
649        }
650
651        let retained_bytes = retained_batch_bytes(batch);
652        // Reject a single oversized input before flushing or otherwise mutating existing state.
653        let _ = requires_preflush(0, retained_bytes, retained_limit)?;
654
655        let mut offset = 0;
656        let mut result = WriteResult::new(0, 0);
657        while offset < rows {
658            let available_rows = MAX_DOCUMENTS_PER_FLUSH - self.buffered_rows;
659            let chunk_rows = available_rows.min(rows - offset);
660            let chunk = batch.slice(offset, chunk_rows);
661            let chunk_retained_bytes = retained_batch_bytes(&chunk);
662
663            if requires_preflush(
664                self.buffered_retained_bytes,
665                chunk_retained_bytes,
666                retained_limit,
667            )? {
668                let flushed = self
669                    .flush_inner()
670                    .await
671                    .map_err(|error| mongo_partial_batch_error(&result, error))?;
672                accumulate_write_result(&mut result, &flushed);
673            }
674
675            self.retain_batch(chunk, chunk_retained_bytes);
676            offset += chunk_rows;
677            if self.buffered_rows == MAX_DOCUMENTS_PER_FLUSH {
678                let flushed = self
679                    .flush_inner()
680                    .await
681                    .map_err(|error| mongo_partial_batch_error(&result, error))?;
682                accumulate_write_result(&mut result, &flushed);
683            }
684        }
685
686        Ok(result)
687    }
688}
689
690fn cdc_row_value(
691    batch: &RecordBatch,
692    row: usize,
693) -> Result<(serde_json::Value, usize), ConnectorError> {
694    use arrow_array::{Array, StringArray};
695
696    let mut value = serde_json::Map::with_capacity(5);
697    let mut staging_bytes = CDC_ROW_OVERHEAD_BYTES;
698    for field_name in [
699        "_namespace",
700        "_op",
701        "_document_key",
702        "_full_document",
703        "_update_desc",
704    ] {
705        let column_index = batch.schema().index_of(field_name).map_err(|_| {
706            ConnectorError::SchemaMismatch(format!(
707                "MongoDB CDC replay batch is missing '{field_name}'"
708            ))
709        })?;
710        let column = batch
711            .column(column_index)
712            .as_any()
713            .downcast_ref::<StringArray>()
714            .ok_or_else(|| {
715                ConnectorError::SchemaMismatch(format!(
716                    "MongoDB CDC replay field '{field_name}' must be Utf8"
717                ))
718            })?;
719        let (field_value, payload_bytes) = if column.is_null(row) {
720            (serde_json::Value::Null, 0)
721        } else {
722            let text = column.value(row);
723            (serde_json::Value::String(text.to_owned()), text.len())
724        };
725        staging_bytes = staging_bytes
726            .checked_add(
727                std::mem::size_of::<serde_json::Value>()
728                    .saturating_add(payload_bytes)
729                    .saturating_mul(3),
730            )
731            .ok_or_else(|| {
732                ConnectorError::ConfigurationError("MongoDB CDC staging byte count overflow".into())
733            })?;
734        value.insert(field_name.to_string(), field_value);
735    }
736    Ok((serde_json::Value::Object(value), staging_bytes))
737}
738
739/// Milliseconds since epoch for a timestamp column cell, normalizing the unit.
740/// Sub-millisecond precision is floored toward the preceding millisecond.
741fn timestamp_millis(col: &dyn arrow_array::Array, row: usize) -> Result<i64, ConnectorError> {
742    use arrow_array::{
743        TimestampMicrosecondArray, TimestampMillisecondArray, TimestampNanosecondArray,
744        TimestampSecondArray,
745    };
746    let DataType::Timestamp(unit, _) = col.data_type() else {
747        return Err(ConnectorError::ConfigurationError(
748            "MongoDB timestamp conversion received a non-timestamp Arrow column".into(),
749        ));
750    };
751    let a = col.as_any();
752    let millis = match unit {
753        arrow_schema::TimeUnit::Second => a
754            .downcast_ref::<TimestampSecondArray>()
755            .unwrap()
756            .value(row)
757            .checked_mul(1000)
758            .ok_or_else(|| {
759                ConnectorError::ConfigurationError(
760                    "MongoDB timestamp seconds overflow millisecond range".into(),
761                )
762            })?,
763        arrow_schema::TimeUnit::Millisecond => a
764            .downcast_ref::<TimestampMillisecondArray>()
765            .unwrap()
766            .value(row),
767        arrow_schema::TimeUnit::Microsecond => a
768            .downcast_ref::<TimestampMicrosecondArray>()
769            .unwrap()
770            .value(row)
771            .div_euclid(1000),
772        arrow_schema::TimeUnit::Nanosecond => a
773            .downcast_ref::<TimestampNanosecondArray>()
774            .unwrap()
775            .value(row)
776            .div_euclid(1_000_000),
777    };
778    Ok(millis)
779}
780
781/// Convert one Arrow cell straight to BSON for the insert/upsert paths,
782/// skipping the `serde_json::Value` intermediate (and the number string round-trip)
783/// that the CDC path still needs. Integers stay width-faithful and timestamps become
784/// BSON dates; unsupported types are rejected during schema validation.
785fn arrow_value_to_bson(
786    col: &dyn arrow_array::Array,
787    row: usize,
788) -> Result<mongodb::bson::Bson, ConnectorError> {
789    use arrow_array::{
790        BooleanArray, Float32Array, Float64Array, Int16Array, Int32Array, Int64Array, Int8Array,
791        LargeStringArray, StringArray, UInt16Array, UInt32Array, UInt8Array,
792    };
793    use mongodb::bson::Bson;
794
795    if !is_supported_mongodb_arrow_type(col.data_type()) {
796        return Err(ConnectorError::ConfigurationError(format!(
797            "MongoDB sink cannot convert unsupported Arrow type {:?}",
798            col.data_type()
799        )));
800    }
801    if col.is_null(row) {
802        return Ok(Bson::Null);
803    }
804    let a = col.as_any();
805    let i32_of = |v: i32| Bson::Int32(v);
806    let value = match col.data_type() {
807        DataType::Null => Bson::Null,
808        DataType::Boolean => Bson::Boolean(a.downcast_ref::<BooleanArray>().unwrap().value(row)),
809        DataType::Int8 => i32_of(i32::from(a.downcast_ref::<Int8Array>().unwrap().value(row))),
810        DataType::Int16 => i32_of(i32::from(
811            a.downcast_ref::<Int16Array>().unwrap().value(row),
812        )),
813        DataType::Int32 => i32_of(a.downcast_ref::<Int32Array>().unwrap().value(row)),
814        DataType::Int64 => Bson::Int64(a.downcast_ref::<Int64Array>().unwrap().value(row)),
815        DataType::UInt8 => i32_of(i32::from(
816            a.downcast_ref::<UInt8Array>().unwrap().value(row),
817        )),
818        DataType::UInt16 => i32_of(i32::from(
819            a.downcast_ref::<UInt16Array>().unwrap().value(row),
820        )),
821        DataType::UInt32 => Bson::Int64(i64::from(
822            a.downcast_ref::<UInt32Array>().unwrap().value(row),
823        )),
824        DataType::Float32 => Bson::Double(f64::from(
825            a.downcast_ref::<Float32Array>().unwrap().value(row),
826        )),
827        DataType::Float64 => Bson::Double(a.downcast_ref::<Float64Array>().unwrap().value(row)),
828        DataType::Utf8 => Bson::String(
829            a.downcast_ref::<StringArray>()
830                .unwrap()
831                .value(row)
832                .to_string(),
833        ),
834        DataType::LargeUtf8 => Bson::String(
835            a.downcast_ref::<LargeStringArray>()
836                .unwrap()
837                .value(row)
838                .to_string(),
839        ),
840        DataType::Timestamp(..) => Bson::DateTime(mongodb::bson::DateTime::from_millis(
841            timestamp_millis(col, row)?,
842        )),
843        _ => unreachable!("unsupported Arrow type rejected before conversion"),
844    };
845    Ok(value)
846}
847
848fn retained_batch_bytes(batch: &RecordBatch) -> usize {
849    batch.columns().iter().fold(0, |total, column| {
850        total.saturating_add(column.get_array_memory_size())
851    })
852}
853
854fn clamp_client_timeout(configured: Option<Duration>, limit: Duration) -> Duration {
855    configured
856        .filter(|timeout| !timeout.is_zero())
857        .map_or(limit, |timeout| timeout.min(limit))
858}
859
860fn requires_preflush(
861    buffered_bytes: usize,
862    incoming_bytes: usize,
863    retained_limit: usize,
864) -> Result<bool, ConnectorError> {
865    if incoming_bytes > retained_limit {
866        return Err(ConnectorError::ConfigurationError(format!(
867            "MongoDB sink input batch retains {incoming_bytes} bytes, exceeding the fixed \
868             {retained_limit}-byte per-sink buffer limit; split the batch upstream"
869        )));
870    }
871
872    Ok(buffered_bytes
873        .checked_add(incoming_bytes)
874        .is_none_or(|total| total > retained_limit))
875}
876
877fn accumulate_write_result(total: &mut WriteResult, flushed: &WriteResult) {
878    total.records_written = total
879        .records_written
880        .saturating_add(flushed.records_written);
881    total.bytes_written = total.bytes_written.saturating_add(flushed.bytes_written);
882}
883
884fn mongo_partial_batch_error(completed: &WriteResult, error: ConnectorError) -> ConnectorError {
885    if completed.records_written == 0 {
886        return error;
887    }
888    let retryable = error.is_transient();
889    ConnectorError::outcome_unknown(
890        format!(
891            "MongoDB batch failed after {} records and {} bytes were already written: {error}",
892            completed.records_written, completed.bytes_written
893        ),
894        retryable,
895    )
896}
897
898fn checked_converted_total(
899    current: u64,
900    incoming: usize,
901    limit: usize,
902    context: &str,
903) -> Result<u64, ConnectorError> {
904    let incoming = u64::try_from(incoming).unwrap_or(u64::MAX);
905    let limit = u64::try_from(limit).unwrap_or(u64::MAX);
906    let total = current.checked_add(incoming).ok_or_else(|| {
907        ConnectorError::ConfigurationError(format!("{context} encoded byte count overflow"))
908    })?;
909    if total > limit {
910        return Err(ConnectorError::ConfigurationError(format!(
911            "{context} encodes to {total} bytes, exceeding the fixed {limit}-byte conversion \
912             limit; split the batch upstream"
913        )));
914    }
915    Ok(total)
916}
917
918fn working_set_charge(
919    retained_bytes: usize,
920    encoded_bytes: u64,
921    model_count: usize,
922    staging_bytes: usize,
923) -> Option<usize> {
924    let encoded_bytes = usize::try_from(encoded_bytes).ok()?;
925    retained_bytes
926        .checked_add(encoded_bytes.checked_mul(MATERIALIZED_BYTE_CHARGE)?)?
927        .checked_add(model_count.checked_mul(WRITE_MODEL_OVERHEAD_BYTES)?)?
928        .checked_add(staging_bytes)
929}
930
931fn ensure_working_set(
932    retained_bytes: usize,
933    encoded_bytes: u64,
934    model_count: usize,
935    staging_bytes: usize,
936    limit: usize,
937    context: &str,
938) -> Result<(), ConnectorError> {
939    let charge = working_set_charge(retained_bytes, encoded_bytes, model_count, staging_bytes)
940        .ok_or_else(|| {
941            ConnectorError::ConfigurationError(format!("{context} working-set byte count overflow"))
942        })?;
943    if charge > limit {
944        return Err(ConnectorError::ConfigurationError(format!(
945            "{context} requires a conservative {charge}-byte working set, exceeding the fixed \
946             {limit}-byte per-sink limit; split the batch upstream"
947        )));
948    }
949    Ok(())
950}
951
952fn encoded_document_size(
953    document: &mongodb::bson::Document,
954    limit: usize,
955    context: &str,
956) -> Result<usize, ConnectorError> {
957    let encoded = mongodb::bson::to_vec(document).map_err(|error| {
958        ConnectorError::ConfigurationError(format!(
959            "{context} cannot be represented as BSON: {error}"
960        ))
961    })?;
962    if encoded.len() > limit {
963        return Err(ConnectorError::ConfigurationError(format!(
964            "{context} encodes to {} bytes, exceeding MongoDB's {limit}-byte BSON document \
965             limit",
966            encoded.len()
967        )));
968    }
969    Ok(encoded.len())
970}
971
972fn account_bson_document(
973    total: &mut u64,
974    document: &mongodb::bson::Document,
975    converted_limit: usize,
976    context: &str,
977) -> Result<(), ConnectorError> {
978    let bytes = encoded_document_size(document, MAX_STANDARD_DOCUMENT_BYTES, context)?;
979    *total = checked_converted_total(*total, bytes, converted_limit, "MongoDB CDC flush")?;
980    Ok(())
981}
982
983/// Convert source-envelope JSON to BSON while interpreting `MongoDB` Extended JSON.
984/// Serde's structural conversion would store `$date` as a literal sub-document.
985fn json_to_bson(value: &serde_json::Value) -> Result<mongodb::bson::Bson, ConnectorError> {
986    mongodb::bson::Bson::try_from(value.clone())
987        .map_err(|e| ConnectorError::ConfigurationError(format!("JSON to BSON: {e}")))
988}
989
990fn json_to_bson_document(
991    value: &serde_json::Value,
992) -> Result<mongodb::bson::Document, ConnectorError> {
993    match json_to_bson(value)? {
994        mongodb::bson::Bson::Document(doc) => Ok(doc),
995        other => Err(ConnectorError::ConfigurationError(format!(
996            "expected a BSON document, got {:?}",
997            other.element_type()
998        ))),
999    }
1000}
1001
1002fn validate_cdc_document_key(
1003    document: &mongodb::bson::Document,
1004) -> Result<mongodb::bson::Document, ConnectorError> {
1005    if document.is_empty() || !document.contains_key("_id") {
1006        return Err(ConnectorError::ConfigurationError(
1007            "MongoDB CDC replay document key must contain '_id' and every target shard-key field"
1008                .into(),
1009        ));
1010    }
1011    let id = document.get("_id").expect("_id presence checked above");
1012    if id == &mongodb::bson::Bson::Null {
1013        return Err(ConnectorError::ConfigurationError(
1014            "MongoDB CDC replay '_id' must be non-null".into(),
1015        ));
1016    }
1017    let mut filter = mongodb::bson::Document::new();
1018    for (field, value) in document {
1019        if field.starts_with('$') {
1020            return Err(ConnectorError::ConfigurationError(format!(
1021                "MongoDB CDC document-key field '{field}' must not be an operator"
1022            )));
1023        }
1024        // Explicit equality prevents a document-valued key from being interpreted as a query
1025        // operator while retaining every shard-key component for targeted writes.
1026        filter.insert(field.clone(), mongodb::bson::doc! { "$eq": value.clone() });
1027    }
1028    Ok(filter)
1029}
1030
1031fn document_key_value<'a>(
1032    document: &'a mongodb::bson::Document,
1033    field: &str,
1034) -> Option<&'a mongodb::bson::Bson> {
1035    if let Some(value) = document.get(field) {
1036        return Some(value);
1037    }
1038    let mut path = field.split('.');
1039    let mut value = document.get(path.next()?)?;
1040    for component in path {
1041        value = value.as_document()?.get(component)?;
1042    }
1043    Some(value)
1044}
1045
1046fn validate_cdc_replacement_key(
1047    document_key: &mongodb::bson::Document,
1048    replacement: &mongodb::bson::Document,
1049) -> Result<(), ConnectorError> {
1050    for (field, expected) in document_key {
1051        let actual = document_key_value(replacement, field).ok_or_else(|| {
1052            ConnectorError::ConfigurationError(format!(
1053                "MongoDB CDC replacement is missing document-key field '{field}'"
1054            ))
1055        })?;
1056        if actual != expected {
1057            return Err(ConnectorError::ConfigurationError(format!(
1058                "MongoDB CDC replacement document-key field '{field}' does not match the event"
1059            )));
1060        }
1061    }
1062    Ok(())
1063}
1064
1065#[async_trait]
1066impl SinkConnector for MongoDbSink {
1067    fn terminal_task_tracker(&self) -> Option<ConnectorTaskTracker> {
1068        Some(self.task_tracker.clone())
1069    }
1070
1071    fn contract(&self, config: &ConnectorConfig) -> Result<SinkContract, ConnectorError> {
1072        let (cfg, schema, _) = if config.properties().is_empty() {
1073            (
1074                self.config.clone(),
1075                Arc::clone(&self.schema),
1076                self.write_timeout,
1077            )
1078        } else {
1079            Self::decode_connector_config(config)?
1080        };
1081        cfg.validate()?;
1082        Self::validate_schema(&schema, &cfg)?;
1083        let (topology, input_mode) = match cfg.write_mode {
1084            WriteMode::Insert => (SinkTopology::MultiWriter, SinkInputMode::AppendOnly),
1085            WriteMode::Upsert { .. } | WriteMode::CdcReplay => {
1086                (SinkTopology::Singleton, SinkInputMode::FullChangelog)
1087            }
1088        };
1089        Ok(SinkContract::new(
1090            SinkConsistency::DurableAtLeastOnce,
1091            topology,
1092            input_mode,
1093        ))
1094    }
1095
1096    async fn open(&mut self, config: &ConnectorConfig) -> Result<(), ConnectorError> {
1097        if self.state != ConnectorState::Created {
1098            return Err(ConnectorError::InvalidState {
1099                expected: ConnectorState::Created.to_string(),
1100                actual: self.state.to_string(),
1101            });
1102        }
1103        if config.properties().is_empty() {
1104            self.config.validate()?;
1105            Self::validate_schema(&self.schema, &self.config)?;
1106        } else {
1107            self.apply_connector_config(config)?;
1108        }
1109        self.connect().await?;
1110
1111        self.state = ConnectorState::Running;
1112        info!(
1113            database = %self.config.database,
1114            collection = %self.config.collection,
1115            write_mode = ?self.config.write_mode,
1116            "MongoDB sink opened"
1117        );
1118
1119        Ok(())
1120    }
1121
1122    async fn write_batch(&mut self, batch: &RecordBatch) -> Result<WriteResult, ConnectorError> {
1123        self.write_batch_with_retained_limit(batch, MAX_BUFFERED_RETAINED_BYTES)
1124            .await
1125    }
1126
1127    fn schema(&self) -> SchemaRef {
1128        Arc::clone(&self.schema)
1129    }
1130
1131    fn suggested_write_timeout(&self) -> Duration {
1132        self.write_timeout
1133    }
1134
1135    fn flush_interval(&self) -> Duration {
1136        self.config.flush_interval()
1137    }
1138
1139    async fn flush(&mut self) -> Result<(), ConnectorError> {
1140        if self.state != ConnectorState::Running {
1141            return Err(ConnectorError::InvalidState {
1142                expected: ConnectorState::Running.to_string(),
1143                actual: self.state.to_string(),
1144            });
1145        }
1146        self.flush_inner().await.map(|_| ())
1147    }
1148
1149    async fn close(&mut self) -> Result<(), ConnectorError> {
1150        if self.state == ConnectorState::Closed {
1151            return Ok(());
1152        }
1153
1154        let flush_result = if self.state == ConnectorState::Running && !self.buffer.is_empty() {
1155            self.flush_inner().await.map(|_| ())
1156        } else {
1157            drop(self.take_buffer());
1158            Ok(())
1159        };
1160        self.collection = None;
1161        self.client = None;
1162        self.state = ConnectorState::Closed;
1163        info!("MongoDB sink closed");
1164        flush_result
1165    }
1166}
1167
1168#[derive(Debug)]
1169enum CdcWrite {
1170    Insert {
1171        filter: mongodb::bson::Document,
1172        replacement: mongodb::bson::Document,
1173    },
1174    Update {
1175        filter: mongodb::bson::Document,
1176        update: mongodb::bson::Document,
1177    },
1178    Replace {
1179        filter: mongodb::bson::Document,
1180        replacement: mongodb::bson::Document,
1181    },
1182    Delete {
1183        filter: mongodb::bson::Document,
1184    },
1185    Noop,
1186}
1187
1188#[derive(Default)]
1189struct BulkCounts {
1190    inserts: u64,
1191    upserts: u64,
1192    deletes: u64,
1193}
1194
1195enum MongoOperationOutcome<T> {
1196    Completed(T),
1197    Deadline,
1198    TaskFailed(tokio::task::JoinError),
1199}
1200
1201fn spawn_mongo_sink_operation<F, T>(
1202    task_owner: &ConnectorTaskOwner,
1203    operation: F,
1204) -> Result<tokio::task::JoinHandle<T>, ConnectorError>
1205where
1206    F: Future<Output = T> + Send + 'static,
1207    T: Send + 'static,
1208{
1209    let guard = task_owner.track().ok_or_else(|| {
1210        ConnectorError::Internal(
1211            "MongoDB sink task generation was sealed before bulk operation admission".into(),
1212        )
1213    })?;
1214    // The driver has no supported socket/operation timeout. Dropping this handle therefore
1215    // detaches the operation; its guard remains live until the driver future actually exits.
1216    Ok(tokio::spawn(async move {
1217        let _guard = guard;
1218        operation.await
1219    }))
1220}
1221
1222async fn await_mongo_sink_operation<F, T>(
1223    task_owner: &ConnectorTaskOwner,
1224    deadline: tokio::time::Instant,
1225    operation: F,
1226) -> Result<MongoOperationOutcome<T>, ConnectorError>
1227where
1228    F: Future<Output = T> + Send + 'static,
1229    T: Send + 'static,
1230{
1231    let mut task = spawn_mongo_sink_operation(task_owner, operation)?;
1232    match tokio::time::timeout_at(deadline, &mut task).await {
1233        Ok(Ok(result)) => Ok(MongoOperationOutcome::Completed(result)),
1234        Ok(Err(error)) => Ok(MongoOperationOutcome::TaskFailed(error)),
1235        Err(_) => Ok(MongoOperationOutcome::Deadline),
1236    }
1237}
1238
1239fn cdc_bulk_models(
1240    namespace: &mongodb::Namespace,
1241    writes: Vec<CdcWrite>,
1242) -> (Vec<mongodb::options::WriteModel>, BulkCounts) {
1243    use mongodb::options::{DeleteOneModel, ReplaceOneModel, UpdateOneModel, WriteModel};
1244
1245    let mut models = Vec::with_capacity(writes.len());
1246    let mut counts = BulkCounts::default();
1247    for write in writes {
1248        match write {
1249            CdcWrite::Insert {
1250                filter,
1251                replacement,
1252            } => {
1253                models.push(WriteModel::ReplaceOne(
1254                    ReplaceOneModel::builder()
1255                        .namespace(namespace.clone())
1256                        .filter(filter)
1257                        .replacement(replacement)
1258                        .upsert(true)
1259                        .build(),
1260                ));
1261                counts.inserts = counts.inserts.saturating_add(1);
1262            }
1263            CdcWrite::Update { filter, update } => {
1264                models.push(WriteModel::UpdateOne(
1265                    UpdateOneModel::builder()
1266                        .namespace(namespace.clone())
1267                        .filter(filter)
1268                        .update(update)
1269                        .build(),
1270                ));
1271                counts.upserts = counts.upserts.saturating_add(1);
1272            }
1273            CdcWrite::Replace {
1274                filter,
1275                replacement,
1276            } => {
1277                models.push(WriteModel::ReplaceOne(
1278                    ReplaceOneModel::builder()
1279                        .namespace(namespace.clone())
1280                        .filter(filter)
1281                        .replacement(replacement)
1282                        .upsert(true)
1283                        .build(),
1284                ));
1285                counts.upserts = counts.upserts.saturating_add(1);
1286            }
1287            CdcWrite::Delete { filter } => {
1288                models.push(WriteModel::DeleteOne(
1289                    DeleteOneModel::builder()
1290                        .namespace(namespace.clone())
1291                        .filter(filter)
1292                        .build(),
1293                ));
1294                counts.deletes = counts.deletes.saturating_add(1);
1295            }
1296            CdcWrite::Noop => {}
1297        }
1298    }
1299    (models, counts)
1300}
1301
1302impl MongoDbSink {
1303    /// Connects to `MongoDB` and sets up the target collection with write concern.
1304    async fn connect(&mut self) -> Result<(), ConnectorError> {
1305        use mongodb::options::{ClientOptions, CollectionOptions};
1306
1307        let mut client_options = ClientOptions::parse(&self.config.connection_uri)
1308            .await
1309            .map_err(|e| ConnectorError::ConnectionFailed(format!("parse URI: {e}")))?;
1310        harden_mongodb_tls(&mut client_options)?;
1311        let driver_timeout = self.driver_timeout();
1312        client_options.connect_timeout = Some(clamp_client_timeout(
1313            client_options.connect_timeout,
1314            driver_timeout,
1315        ));
1316        client_options.server_selection_timeout = Some(clamp_client_timeout(
1317            client_options.server_selection_timeout,
1318            driver_timeout,
1319        ));
1320
1321        let wc = {
1322            let mut wc = mongodb::options::WriteConcern::default();
1323            wc.w = Some(mongodb::options::Acknowledgment::Majority);
1324            wc.journal = Some(true);
1325            wc.w_timeout = Some(driver_timeout);
1326            wc
1327        };
1328        // Client::bulk_write is client-scoped, so its inherited concern must live on the client.
1329        client_options.write_concern = Some(wc.clone());
1330        let client = mongodb::Client::with_options(client_options)
1331            .map_err(|e| ConnectorError::ConnectionFailed(format!("create client: {e}")))?;
1332
1333        let db = client.database(&self.config.database);
1334        let hello = db
1335            .run_command(mongodb::bson::doc! { "hello": 1 })
1336            .await
1337            .map_err(|error| {
1338                ConnectorError::ConnectionFailed(format!(
1339                    "verify MongoDB bulk-write capability: {error}"
1340                ))
1341            })?;
1342        let max_wire_version = hello.get_i32("maxWireVersion").map_err(|_| {
1343            ConnectorError::ConnectionFailed(
1344                "MongoDB hello response omitted integer maxWireVersion".into(),
1345            )
1346        })?;
1347        if max_wire_version < MONGODB_8_WIRE_VERSION {
1348            return Err(ConnectorError::ConfigurationError(format!(
1349                "MongoDB sink requires MongoDB 8.0+ ordered bulk_write (maxWireVersion >= \
1350                 {MONGODB_8_WIRE_VERSION}); server reported {max_wire_version}"
1351            )));
1352        }
1353
1354        match &self.config.collection_kind {
1355            CollectionKind::Standard => self.validate_standard_collection(&db).await?,
1356            CollectionKind::TimeSeries(ts_config) => {
1357                self.ensure_timeseries_collection(&db, ts_config).await?;
1358            }
1359        }
1360
1361        let coll_opts = CollectionOptions::builder().write_concern(wc).build();
1362
1363        let collection = db
1364            .collection_with_options::<mongodb::bson::Document>(&self.config.collection, coll_opts);
1365
1366        self.client = Some(client);
1367        self.collection = Some(collection);
1368
1369        Ok(())
1370    }
1371
1372    /// Reject an existing view or time-series collection when standard mode was requested.
1373    async fn validate_standard_collection(
1374        &self,
1375        db: &mongodb::Database,
1376    ) -> Result<(), ConnectorError> {
1377        use futures_util::TryStreamExt;
1378        use mongodb::bson::doc;
1379        use mongodb::results::CollectionType;
1380
1381        let mut collections = db
1382            .list_collections()
1383            .filter(doc! { "name": &self.config.collection })
1384            .await
1385            .map_err(|error| {
1386                ConnectorError::ConnectionFailed(format!(
1387                    "inspect existing MongoDB collection '{}': {error}",
1388                    self.config.collection
1389                ))
1390            })?;
1391        if let Some(spec) = collections.try_next().await.map_err(|error| {
1392            ConnectorError::ConnectionFailed(format!(
1393                "read existing MongoDB collection '{}': {error}",
1394                self.config.collection
1395            ))
1396        })? {
1397            if spec.collection_type != CollectionType::Collection {
1398                return Err(ConnectorError::ConfigurationError(format!(
1399                    "MongoDB standard sink target '{}' already exists as {:?}",
1400                    self.config.collection, spec.collection_type
1401                )));
1402            }
1403        }
1404        Ok(())
1405    }
1406
1407    /// Ensures a time series collection exists with the correct configuration.
1408    async fn ensure_timeseries_collection(
1409        &self,
1410        db: &mongodb::Database,
1411        ts_config: &super::timeseries::TimeSeriesConfig,
1412    ) -> Result<(), ConnectorError> {
1413        use mongodb::bson::doc;
1414
1415        let mut ts_opts = doc! {
1416            "timeField": &ts_config.time_field,
1417        };
1418
1419        if let Some(ref meta) = ts_config.meta_field {
1420            ts_opts.insert("metaField", meta);
1421        }
1422
1423        match ts_config.granularity {
1424            super::timeseries::TimeSeriesGranularity::Seconds => {
1425                ts_opts.insert("granularity", "seconds");
1426            }
1427            super::timeseries::TimeSeriesGranularity::Minutes => {
1428                ts_opts.insert("granularity", "minutes");
1429            }
1430            super::timeseries::TimeSeriesGranularity::Hours => {
1431                ts_opts.insert("granularity", "hours");
1432            }
1433            super::timeseries::TimeSeriesGranularity::Custom {
1434                bucket_max_span_seconds,
1435                bucket_rounding_seconds,
1436            } => {
1437                ts_opts.insert("bucketMaxSpanSeconds", i64::from(bucket_max_span_seconds));
1438                ts_opts.insert("bucketRoundingSeconds", i64::from(bucket_rounding_seconds));
1439            }
1440        }
1441
1442        let mut create_opts = doc! {
1443            "create": &self.config.collection,
1444            "timeseries": ts_opts,
1445        };
1446
1447        if let Some(ttl) = ts_config.expire_after_seconds {
1448            let ttl = i64::try_from(ttl).map_err(|_| {
1449                ConnectorError::ConfigurationError(
1450                    "time series expire_after_seconds exceeds MongoDB's signed 64-bit range".into(),
1451                )
1452            })?;
1453            create_opts.insert("expireAfterSeconds", ttl);
1454        }
1455
1456        // A concurrent creator is safe only when it created the same collection shape.
1457        match db.run_command(create_opts).await {
1458            Ok(_) => {
1459                info!(
1460                    collection = %self.config.collection,
1461                    time_field = %ts_config.time_field,
1462                    granularity = %ts_config.granularity,
1463                    "created time series collection"
1464                );
1465            }
1466            Err(e) => {
1467                if !is_namespace_exists(&e) {
1468                    return Err(ConnectorError::ConnectionFailed(format!(
1469                        "create time series collection: {e}"
1470                    )));
1471                }
1472                self.validate_existing_timeseries_collection(db, ts_config)
1473                    .await?;
1474                debug!(
1475                    collection = %self.config.collection,
1476                    "matching time series collection already exists"
1477                );
1478            }
1479        }
1480
1481        Ok(())
1482    }
1483
1484    async fn validate_existing_timeseries_collection(
1485        &self,
1486        db: &mongodb::Database,
1487        expected: &super::timeseries::TimeSeriesConfig,
1488    ) -> Result<(), ConnectorError> {
1489        use futures_util::TryStreamExt;
1490        use mongodb::bson::doc;
1491
1492        let mut collections = db
1493            .list_collections()
1494            .filter(doc! { "name": &self.config.collection })
1495            .await
1496            .map_err(|error| {
1497                ConnectorError::ConnectionFailed(format!(
1498                    "inspect existing MongoDB collection '{}': {error}",
1499                    self.config.collection
1500                ))
1501            })?;
1502        let spec = collections
1503            .try_next()
1504            .await
1505            .map_err(|error| {
1506                ConnectorError::ConnectionFailed(format!(
1507                    "read existing MongoDB collection '{}': {error}",
1508                    self.config.collection
1509                ))
1510            })?
1511            .ok_or_else(|| {
1512                ConnectorError::ConfigurationError(format!(
1513                    "MongoDB reported NamespaceExists for collection '{}', but its metadata was not returned",
1514                    self.config.collection
1515                ))
1516            })?;
1517
1518        validate_existing_timeseries_spec(&spec, expected)
1519    }
1520
1521    /// Extracts a CDC envelope field that may be a JSON string (from Utf8 Arrow
1522    /// columns) or already a JSON object. Parses strings into objects for BSON
1523    /// conversion.
1524    fn parse_cdc_field<'a>(
1525        val: &'a serde_json::Value,
1526        field: &str,
1527    ) -> Result<std::borrow::Cow<'a, serde_json::Value>, ConnectorError> {
1528        let v = val.get(field).ok_or_else(|| {
1529            ConnectorError::ConfigurationError(format!("CDC event missing {field} field"))
1530        })?;
1531        match v {
1532            serde_json::Value::Object(_) => Ok(std::borrow::Cow::Borrowed(v)),
1533            serde_json::Value::String(s) => {
1534                let parsed: serde_json::Value = serde_json::from_str(s).map_err(|e| {
1535                    ConnectorError::ConfigurationError(format!("parse {field} JSON: {e}"))
1536                })?;
1537                Ok(std::borrow::Cow::Owned(parsed))
1538            }
1539            _ => Err(ConnectorError::ConfigurationError(format!(
1540                "{field} must be a JSON object or JSON string, got {v}"
1541            ))),
1542        }
1543    }
1544
1545    /// Writes JSON value documents to `MongoDB` using the configured write mode.
1546    ///
1547    /// Accepts `serde_json::Value` directly (no intermediate string round-trip).
1548    /// Insert/upsert from documents already in BSON (no JSON hop).
1549    async fn write_bson_docs(
1550        &self,
1551        docs: Vec<mongodb::bson::Document>,
1552        from_changelog: bool,
1553        encoded_bytes: u64,
1554    ) -> Result<(), ConnectorError> {
1555        use mongodb::bson::Document;
1556        use mongodb::options::{DeleteOneModel, InsertOneModel, ReplaceOneModel, WriteModel};
1557
1558        let collection = self
1559            .collection
1560            .as_ref()
1561            .ok_or_else(|| ConnectorError::Internal("collection not initialized".to_string()))?;
1562        let namespace = collection.namespace();
1563        let mut models = Vec::with_capacity(docs.len());
1564        let mut counts = BulkCounts::default();
1565        let mut model_bytes = encoded_bytes;
1566
1567        match &self.config.write_mode {
1568            WriteMode::Upsert { key_fields } => {
1569                for document in &docs {
1570                    for key in key_fields {
1571                        match document.get(key) {
1572                            Some(value) if *value != mongodb::bson::Bson::Null => {}
1573                            _ => {
1574                                return Err(ConnectorError::ConfigurationError(format!(
1575                                    "MongoDB upsert document requires a non-null key field '{key}'"
1576                                )));
1577                            }
1578                        }
1579                    }
1580                }
1581            }
1582            WriteMode::Insert | WriteMode::CdcReplay => {}
1583        }
1584
1585        match &self.config.write_mode {
1586            WriteMode::Insert => {
1587                counts.inserts = docs.len() as u64;
1588                models.extend(docs.into_iter().map(|document| {
1589                    WriteModel::InsertOne(
1590                        InsertOneModel::builder()
1591                            .namespace(namespace.clone())
1592                            .document(document)
1593                            .build(),
1594                    )
1595                }));
1596            }
1597
1598            WriteMode::Upsert { ref key_fields } => {
1599                for mut bson_doc in docs {
1600                    // Only a collapsed changelog carries a synthesized `_op` (U/D): route D to a
1601                    // delete. A plain upsert keeps its columns verbatim (a user `_op` is not a delete).
1602                    let is_delete = from_changelog && matches!(bson_doc.get_str("_op"), Ok("D"));
1603                    if from_changelog {
1604                        bson_doc.remove("_op");
1605                    }
1606                    let mut filter = Document::new();
1607                    for key in key_fields {
1608                        let value = bson_doc.get(key).ok_or_else(|| {
1609                            ConnectorError::ConfigurationError(format!(
1610                                "MongoDB upsert document is missing key field '{key}'"
1611                            ))
1612                        })?;
1613                        filter.insert(key, value.clone());
1614                    }
1615                    let filter_bytes = encoded_document_size(
1616                        &filter,
1617                        MAX_STANDARD_DOCUMENT_BYTES,
1618                        "MongoDB upsert filter",
1619                    )?;
1620                    model_bytes = checked_converted_total(
1621                        model_bytes,
1622                        filter_bytes,
1623                        usize::MAX,
1624                        "MongoDB sink bulk",
1625                    )?;
1626                    if is_delete {
1627                        models.push(WriteModel::DeleteOne(
1628                            DeleteOneModel::builder()
1629                                .namespace(namespace.clone())
1630                                .filter(filter)
1631                                .build(),
1632                        ));
1633                        counts.deletes = counts.deletes.saturating_add(1);
1634                    } else {
1635                        models.push(WriteModel::ReplaceOne(
1636                            ReplaceOneModel::builder()
1637                                .namespace(namespace.clone())
1638                                .filter(filter)
1639                                .replacement(bson_doc)
1640                                .upsert(true)
1641                                .build(),
1642                        ));
1643                        counts.upserts = counts.upserts.saturating_add(1);
1644                    }
1645                    ensure_working_set(
1646                        0,
1647                        model_bytes,
1648                        models.len(),
1649                        0,
1650                        MAX_SINK_WORKING_SET_BYTES,
1651                        "MongoDB sink bulk",
1652                    )?;
1653                }
1654            }
1655
1656            WriteMode::CdcReplay => {
1657                return Err(ConnectorError::Internal(
1658                    "CDC replay must use prepared CDC writes".to_string(),
1659                ));
1660            }
1661        }
1662
1663        self.execute_bulk_models(models, counts, "MongoDB sink bulk_write")
1664            .await?;
1665        Ok(())
1666    }
1667
1668    async fn execute_bulk_models(
1669        &self,
1670        models: Vec<mongodb::options::WriteModel>,
1671        counts: BulkCounts,
1672        context: &str,
1673    ) -> Result<(), ConnectorError> {
1674        if models.is_empty() {
1675            return Ok(());
1676        }
1677        let client = self
1678            .client
1679            .as_ref()
1680            .ok_or_else(|| ConnectorError::Internal("client not initialized".to_string()))?;
1681        let driver_timeout = self.driver_timeout();
1682        let deadline = tokio::time::Instant::now() + driver_timeout;
1683        let operation_client = client.clone();
1684        match await_mongo_sink_operation(&self.task_owner, deadline, async move {
1685            operation_client.bulk_write(models).ordered(true).await
1686        })
1687        .await?
1688        {
1689            MongoOperationOutcome::Completed(Ok(_)) => {}
1690            MongoOperationOutcome::Completed(Err(error)) => {
1691                self.metrics.record_error();
1692                return Err(classify_mongo_bulk_failure(
1693                    context,
1694                    MongoBulkFailure::Driver(&error),
1695                ));
1696            }
1697            MongoOperationOutcome::Deadline => {
1698                self.metrics.record_error();
1699                return Err(classify_mongo_bulk_failure(
1700                    context,
1701                    MongoBulkFailure::Deadline(driver_timeout),
1702                ));
1703            }
1704            MongoOperationOutcome::TaskFailed(error) => {
1705                self.metrics.record_error();
1706                return Err(ConnectorError::outcome_unknown(
1707                    format!(
1708                        "{context} task terminated before its MongoDB outcome was observed: {error}"
1709                    ),
1710                    false,
1711                ));
1712            }
1713        }
1714        self.metrics.record_bulk_write();
1715        self.metrics.record_inserts(counts.inserts);
1716        self.metrics.record_upserts(counts.upserts);
1717        self.metrics.record_deletes(counts.deletes);
1718        Ok(())
1719    }
1720
1721    #[allow(clippy::too_many_lines)]
1722    fn prepare_cdc_writes(
1723        docs: &[serde_json::Value],
1724        converted_limit: usize,
1725        expected_namespace: &str,
1726    ) -> Result<(Vec<CdcWrite>, u64), ConnectorError> {
1727        use mongodb::bson::{doc, Bson, Document};
1728
1729        let mut writes = Vec::with_capacity(docs.len());
1730        let mut bytes = 0;
1731
1732        for value in docs {
1733            let namespace = value
1734                .get("_namespace")
1735                .and_then(serde_json::Value::as_str)
1736                .ok_or_else(|| {
1737                    ConnectorError::ConfigurationError(
1738                        "MongoDB CDC replay event requires a non-null string '_namespace'".into(),
1739                    )
1740                })?;
1741            if namespace != expected_namespace {
1742                return Err(ConnectorError::ConfigurationError(format!(
1743                    "MongoDB CDC replay namespace '{namespace}' does not match fixed target \
1744                     '{expected_namespace}'"
1745                )));
1746            }
1747            let op = value
1748                .get("_op")
1749                .and_then(serde_json::Value::as_str)
1750                .ok_or_else(|| {
1751                    ConnectorError::ConfigurationError(
1752                        "MongoDB CDC replay event requires a non-null string '_op'".into(),
1753                    )
1754                })?;
1755
1756            let write = match op {
1757                "I" | "R" => {
1758                    let key = Self::parse_cdc_field(value, "_document_key")?;
1759                    let full_document = Self::parse_cdc_field(value, "_full_document")?;
1760                    let key = json_to_bson_document(key.as_ref())?;
1761                    let filter = validate_cdc_document_key(&key)?;
1762                    let replacement = json_to_bson_document(full_document.as_ref())?;
1763                    validate_cdc_replacement_key(&key, &replacement)?;
1764                    account_bson_document(
1765                        &mut bytes,
1766                        &filter,
1767                        converted_limit,
1768                        "MongoDB CDC document key",
1769                    )?;
1770                    account_bson_document(
1771                        &mut bytes,
1772                        &replacement,
1773                        converted_limit,
1774                        "MongoDB CDC replacement document",
1775                    )?;
1776                    if op == "I" {
1777                        CdcWrite::Insert {
1778                            filter,
1779                            replacement,
1780                        }
1781                    } else {
1782                        CdcWrite::Replace {
1783                            filter,
1784                            replacement,
1785                        }
1786                    }
1787                }
1788                "U" => {
1789                    let key = Self::parse_cdc_field(value, "_document_key")?;
1790                    let key = json_to_bson_document(key.as_ref())?;
1791                    let filter = validate_cdc_document_key(&key)?;
1792
1793                    if value
1794                        .get("_full_document")
1795                        .is_some_and(|document| !document.is_null())
1796                    {
1797                        let full_document = Self::parse_cdc_field(value, "_full_document")?;
1798                        let replacement = json_to_bson_document(full_document.as_ref())?;
1799                        validate_cdc_replacement_key(&key, &replacement)?;
1800                        account_bson_document(
1801                            &mut bytes,
1802                            &filter,
1803                            converted_limit,
1804                            "MongoDB CDC document key",
1805                        )?;
1806                        account_bson_document(
1807                            &mut bytes,
1808                            &replacement,
1809                            converted_limit,
1810                            "MongoDB CDC replacement document",
1811                        )?;
1812                        writes.push(CdcWrite::Replace {
1813                            filter,
1814                            replacement,
1815                        });
1816                        continue;
1817                    }
1818
1819                    let description = Self::parse_cdc_field(value, "_update_desc")?;
1820                    if let Some(disambiguated) = description
1821                        .get("disambiguated_paths")
1822                        .or_else(|| description.get("disambiguatedPaths"))
1823                    {
1824                        let paths = disambiguated.as_object().ok_or_else(|| {
1825                            ConnectorError::ConfigurationError(
1826                                "MongoDB CDC disambiguated_paths must be an object".into(),
1827                            )
1828                        })?;
1829                        if !paths.is_empty() {
1830                            return Err(ConnectorError::ConfigurationError(
1831                                "MongoDB CDC replay cannot safely apply ambiguous field paths; \
1832                                 use a full-document update mode"
1833                                    .into(),
1834                            ));
1835                        }
1836                    }
1837
1838                    let mut update = Document::new();
1839                    if let Some(updated) = description
1840                        .get("updated_fields")
1841                        .or_else(|| description.get("updatedFields"))
1842                    {
1843                        update.insert("$set", Bson::Document(json_to_bson_document(updated)?));
1844                    }
1845
1846                    if let Some(removed) = description
1847                        .get("removed_fields")
1848                        .or_else(|| description.get("removedFields"))
1849                    {
1850                        let fields = removed.as_array().ok_or_else(|| {
1851                            ConnectorError::ConfigurationError(
1852                                "MongoDB CDC removed_fields must be an array".into(),
1853                            )
1854                        })?;
1855                        if !fields.is_empty() {
1856                            let mut unset = Document::new();
1857                            for field in fields {
1858                                let field = field.as_str().ok_or_else(|| {
1859                                    ConnectorError::ConfigurationError(
1860                                        "MongoDB CDC removed_fields entries must be strings".into(),
1861                                    )
1862                                })?;
1863                                unset.insert(field, "");
1864                            }
1865                            update.insert("$unset", unset);
1866                        }
1867                    }
1868
1869                    if let Some(truncated) = description
1870                        .get("truncated_arrays")
1871                        .or_else(|| description.get("truncatedArrays"))
1872                    {
1873                        let arrays = truncated.as_array().ok_or_else(|| {
1874                            ConnectorError::ConfigurationError(
1875                                "MongoDB CDC truncated_arrays must be an array".into(),
1876                            )
1877                        })?;
1878                        if !arrays.is_empty() {
1879                            let mut push = Document::new();
1880                            for array in arrays {
1881                                let field = array
1882                                    .get("field")
1883                                    .and_then(serde_json::Value::as_str)
1884                                    .ok_or_else(|| {
1885                                        ConnectorError::ConfigurationError(
1886                                            "MongoDB CDC truncated array requires 'field'".into(),
1887                                        )
1888                                    })?;
1889                                let new_size = array
1890                                    .get("new_size")
1891                                    .or_else(|| array.get("newSize"))
1892                                    .and_then(serde_json::Value::as_u64)
1893                                    .and_then(|size| i64::try_from(size).ok())
1894                                    .ok_or_else(|| {
1895                                        ConnectorError::ConfigurationError(
1896                                            "MongoDB CDC truncated array requires an i64 'new_size'"
1897                                                .into(),
1898                                        )
1899                                    })?;
1900                                push.insert(
1901                                    field,
1902                                    doc! { "$each": Bson::Array(Vec::new()), "$slice": new_size },
1903                                );
1904                            }
1905                            update.insert("$push", push);
1906                        }
1907                    }
1908
1909                    account_bson_document(
1910                        &mut bytes,
1911                        &filter,
1912                        converted_limit,
1913                        "MongoDB CDC document key",
1914                    )?;
1915                    if update.is_empty() {
1916                        CdcWrite::Noop
1917                    } else {
1918                        account_bson_document(
1919                            &mut bytes,
1920                            &update,
1921                            converted_limit,
1922                            "MongoDB CDC update document",
1923                        )?;
1924                        CdcWrite::Update { filter, update }
1925                    }
1926                }
1927                "D" => {
1928                    let key = Self::parse_cdc_field(value, "_document_key")?;
1929                    let key = json_to_bson_document(key.as_ref())?;
1930                    let filter = validate_cdc_document_key(&key)?;
1931                    account_bson_document(
1932                        &mut bytes,
1933                        &filter,
1934                        converted_limit,
1935                        "MongoDB CDC document key",
1936                    )?;
1937                    CdcWrite::Delete { filter }
1938                }
1939                "DROP" | "RENAME" | "INVALIDATE" | "DROP_DATABASE" => {
1940                    return Err(ConnectorError::ConfigurationError(format!(
1941                        "MongoDB CDC replay cannot apply lifecycle operation '{op}' to fixed \
1942                         destination '{expected_namespace}'"
1943                    )));
1944                }
1945                other => {
1946                    return Err(ConnectorError::ConfigurationError(format!(
1947                        "MongoDB CDC replay does not support operation '{other}'"
1948                    )));
1949                }
1950            };
1951            writes.push(write);
1952        }
1953
1954        Ok((writes, bytes))
1955    }
1956
1957    async fn execute_cdc_writes(&self, writes: Vec<CdcWrite>) -> Result<(), ConnectorError> {
1958        let collection = self
1959            .collection
1960            .as_ref()
1961            .ok_or_else(|| ConnectorError::Internal("collection not initialized".to_string()))?;
1962        let namespace = collection.namespace();
1963        let (models, counts) = cdc_bulk_models(&namespace, writes);
1964        self.execute_bulk_models(models, counts, "MongoDB CDC bulk_write")
1965            .await
1966    }
1967}
1968
1969#[derive(Clone, Copy)]
1970enum MongoBulkFailure<'a> {
1971    Driver(&'a mongodb::error::Error),
1972    Deadline(Duration),
1973}
1974
1975#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1976enum MongoBulkFailureShape {
1977    PreCommandTransient,
1978    PreCommandTerminal,
1979    Transport,
1980    Command,
1981    WriteRejected,
1982    WriteConcern,
1983    Bulk {
1984        partial: bool,
1985        write_errors: bool,
1986        write_concern_errors: bool,
1987    },
1988    Deadline,
1989    Unknown,
1990}
1991
1992#[derive(Clone, Copy, Debug)]
1993struct MongoBulkFailureFacts {
1994    no_writes: bool,
1995    retryable_signal: bool,
1996    shape: MongoBulkFailureShape,
1997}
1998
1999#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2000enum MongoBulkDisposition {
2001    DefinitelyNotApplied { retryable: bool },
2002    OutcomeUnknown { retryable: bool },
2003}
2004
2005fn mongo_bulk_failure_facts(error: &mongodb::error::Error) -> MongoBulkFailureFacts {
2006    use mongodb::error::{
2007        ErrorKind, WriteFailure, NO_WRITES_PERFORMED, RETRYABLE_ERROR, RETRYABLE_WRITE_ERROR,
2008        SYSTEM_OVERLOADED_ERROR,
2009    };
2010
2011    let retryable_label = error.contains_label(RETRYABLE_WRITE_ERROR)
2012        || (error.contains_label(SYSTEM_OVERLOADED_ERROR) && error.contains_label(RETRYABLE_ERROR));
2013    let transient_cause = mongo_error_chain_has_transient_cause(error);
2014    let shape = match error.kind.as_ref() {
2015        ErrorKind::ServerSelection { .. } | ErrorKind::DnsResolve { .. } => {
2016            MongoBulkFailureShape::PreCommandTransient
2017        }
2018        ErrorKind::InvalidArgument { .. }
2019        | ErrorKind::Authentication { .. }
2020        | ErrorKind::BsonSerialization(_)
2021        | ErrorKind::SessionsNotSupported
2022        | ErrorKind::InvalidTlsConfig { .. }
2023        | ErrorKind::IncompatibleServer { .. }
2024        | ErrorKind::Shutdown => MongoBulkFailureShape::PreCommandTerminal,
2025        ErrorKind::Io(_) | ErrorKind::ConnectionPoolCleared { .. } => {
2026            MongoBulkFailureShape::Transport
2027        }
2028        ErrorKind::Command(_) => MongoBulkFailureShape::Command,
2029        ErrorKind::Write(WriteFailure::WriteError(_)) => MongoBulkFailureShape::WriteRejected,
2030        ErrorKind::Write(WriteFailure::WriteConcernError(_)) => MongoBulkFailureShape::WriteConcern,
2031        ErrorKind::BulkWrite(bulk) => MongoBulkFailureShape::Bulk {
2032            partial: bulk.partial_result.is_some(),
2033            write_errors: !bulk.write_errors.is_empty(),
2034            write_concern_errors: !bulk.write_concern_errors.is_empty(),
2035        },
2036        _ => MongoBulkFailureShape::Unknown,
2037    };
2038
2039    MongoBulkFailureFacts {
2040        no_writes: error.contains_label(NO_WRITES_PERFORMED),
2041        retryable_signal: retryable_label || transient_cause,
2042        shape,
2043    }
2044}
2045
2046fn mongo_error_chain_has_transient_cause(error: &mongodb::error::Error) -> bool {
2047    use std::error::Error as _;
2048
2049    use mongodb::error::ErrorKind;
2050
2051    let mut current = Some(error);
2052    while let Some(error) = current {
2053        if matches!(
2054            error.kind.as_ref(),
2055            ErrorKind::Io(_)
2056                | ErrorKind::ConnectionPoolCleared { .. }
2057                | ErrorKind::ServerSelection { .. }
2058                | ErrorKind::DnsResolve { .. }
2059        ) {
2060            return true;
2061        }
2062        current = error
2063            .source()
2064            .and_then(|source| source.downcast_ref::<mongodb::error::Error>());
2065    }
2066    false
2067}
2068
2069fn classify_mongo_bulk_facts(facts: MongoBulkFailureFacts) -> MongoBulkDisposition {
2070    use MongoBulkDisposition::{DefinitelyNotApplied, OutcomeUnknown};
2071    use MongoBulkFailureShape::{
2072        Bulk, Command, Deadline, PreCommandTerminal, PreCommandTransient, Transport, Unknown,
2073        WriteConcern, WriteRejected,
2074    };
2075
2076    let partial_result = matches!(facts.shape, Bulk { partial: true, .. });
2077    // A nested NoWritesPerformed label only describes the failed wire batch. Earlier wire
2078    // batches are still applied when the driver reports a partial result on the wrapper error.
2079    if facts.no_writes && !partial_result {
2080        let retryable = match facts.shape {
2081            PreCommandTerminal
2082            | Bulk {
2083                write_errors: true, ..
2084            } => false,
2085            PreCommandTransient | Transport => true,
2086            _ => facts.retryable_signal,
2087        };
2088        return DefinitelyNotApplied { retryable };
2089    }
2090
2091    match facts.shape {
2092        PreCommandTransient => DefinitelyNotApplied { retryable: true },
2093        PreCommandTerminal => DefinitelyNotApplied { retryable: false },
2094        WriteRejected => DefinitelyNotApplied {
2095            retryable: facts.retryable_signal,
2096        },
2097        Bulk {
2098            partial,
2099            write_errors: true,
2100            write_concern_errors,
2101        } => {
2102            if partial || write_concern_errors {
2103                OutcomeUnknown { retryable: false }
2104            } else {
2105                DefinitelyNotApplied { retryable: false }
2106            }
2107        }
2108        Transport | Deadline => OutcomeUnknown { retryable: true },
2109        Command | Bulk { .. } | WriteConcern | Unknown => OutcomeUnknown {
2110            retryable: facts.retryable_signal,
2111        },
2112    }
2113}
2114
2115fn classify_mongo_bulk_failure(context: &str, failure: MongoBulkFailure<'_>) -> ConnectorError {
2116    let (facts, detail) = match failure {
2117        MongoBulkFailure::Driver(error) => (mongo_bulk_failure_facts(error), error.to_string()),
2118        MongoBulkFailure::Deadline(timeout) => (
2119            MongoBulkFailureFacts {
2120                no_writes: false,
2121                retryable_signal: true,
2122                shape: MongoBulkFailureShape::Deadline,
2123            },
2124            format!("timed out after {timeout:?}"),
2125        ),
2126    };
2127
2128    match classify_mongo_bulk_facts(facts) {
2129        MongoBulkDisposition::DefinitelyNotApplied { retryable: true } => {
2130            ConnectorError::WriteError(format!(
2131                "{context} failed without applying any ordered bulk write: {detail}"
2132            ))
2133        }
2134        MongoBulkDisposition::DefinitelyNotApplied { retryable: false } => {
2135            ConnectorError::ConfigurationError(format!(
2136                "{context} was rejected without applying any ordered bulk write: {detail}"
2137            ))
2138        }
2139        MongoBulkDisposition::OutcomeUnknown { retryable } => ConnectorError::outcome_unknown(
2140            format!(
2141                "{context} failed after dispatch; MongoDB may have applied part or all of the \
2142                 ordered bulk: {detail}"
2143            ),
2144            retryable,
2145        ),
2146    }
2147}
2148
2149#[cfg(test)]
2150mod tests {
2151    use super::*;
2152    use arrow_array::{Int64Array, StringArray};
2153    use arrow_schema::{Field, Schema};
2154    use futures_util::FutureExt as _;
2155
2156    fn test_schema() -> SchemaRef {
2157        Arc::new(Schema::new(vec![
2158            Field::new("id", DataType::Int64, false),
2159            Field::new("name", DataType::Utf8, false),
2160        ]))
2161    }
2162
2163    fn test_batch(n: usize) -> RecordBatch {
2164        #[allow(clippy::cast_possible_wrap)]
2165        let ids: Vec<i64> = (0..n as i64).collect();
2166        let names: Vec<String> = (0..n).map(|i| format!("user_{i}")).collect();
2167
2168        RecordBatch::try_new(
2169            test_schema(),
2170            vec![
2171                Arc::new(Int64Array::from(ids)),
2172                Arc::new(StringArray::from(names)),
2173            ],
2174        )
2175        .unwrap()
2176    }
2177
2178    fn test_config() -> MongoDbSinkConfig {
2179        MongoDbSinkConfig::new("mongodb://localhost:27017", "db", "coll")
2180    }
2181
2182    #[test]
2183    fn test_new_sink() {
2184        let config = MongoDbSinkConfig::new("mongodb://localhost:27017", "db", "coll");
2185        let sink = MongoDbSink::new(test_schema(), config, None);
2186        assert_eq!(sink.buffered_rows(), 0);
2187    }
2188
2189    #[tokio::test(flavor = "current_thread")]
2190    async fn cancelled_wait_before_bulk_task_first_poll_keeps_operation_tracked() {
2191        let sink = MongoDbSink::new(test_schema(), test_config(), None);
2192        let terminal = sink.terminal_task_tracker().unwrap();
2193        let polled = Arc::new(std::sync::atomic::AtomicBool::new(false));
2194        let task_polled = Arc::clone(&polled);
2195        let release = Arc::new(tokio::sync::Notify::new());
2196        let task_release = Arc::clone(&release);
2197
2198        let wait = await_mongo_sink_operation(
2199            &sink.task_owner,
2200            tokio::time::Instant::now() + Duration::from_secs(60),
2201            async move {
2202                task_polled.store(true, std::sync::atomic::Ordering::Release);
2203                task_release.notified().await;
2204            },
2205        );
2206        assert!(
2207            wait.now_or_never().is_none(),
2208            "operation wait must still be pending"
2209        );
2210        assert!(
2211            !polled.load(std::sync::atomic::Ordering::Acquire),
2212            "the spawned bulk task must not have reached its first poll"
2213        );
2214
2215        drop(sink);
2216        assert!(!terminal.is_terminated());
2217        tokio::task::yield_now().await;
2218        assert!(polled.load(std::sync::atomic::Ordering::Acquire));
2219
2220        release.notify_one();
2221        tokio::time::timeout(Duration::from_secs(1), terminal.wait_terminated())
2222            .await
2223            .expect("tracker must resolve only after the detached operation exits");
2224    }
2225
2226    #[tokio::test]
2227    async fn bulk_deadline_keeps_generation_non_terminal_until_operation_exit() {
2228        let sink = MongoDbSink::new(test_schema(), test_config(), None);
2229        let terminal = sink.terminal_task_tracker().unwrap();
2230        let release = Arc::new(tokio::sync::Notify::new());
2231        let task_release = Arc::clone(&release);
2232
2233        let outcome =
2234            await_mongo_sink_operation(&sink.task_owner, tokio::time::Instant::now(), async move {
2235                task_release.notified().await;
2236            })
2237            .await
2238            .unwrap();
2239        assert!(matches!(outcome, MongoOperationOutcome::Deadline));
2240
2241        drop(sink);
2242        assert!(!terminal.is_terminated());
2243        release.notify_one();
2244        tokio::time::timeout(Duration::from_secs(1), terminal.wait_terminated())
2245            .await
2246            .expect("deadline must not publish terminal state before the operation exits");
2247    }
2248
2249    #[test]
2250    fn mongo_bulk_failure_classification_table() {
2251        use MongoBulkDisposition::{DefinitelyNotApplied, OutcomeUnknown};
2252        use MongoBulkFailureShape::{
2253            Bulk, Command, Deadline, Transport, Unknown, WriteConcern, WriteRejected,
2254        };
2255
2256        let facts = |shape, no_writes, retryable_signal| MongoBulkFailureFacts {
2257            no_writes,
2258            retryable_signal,
2259            shape,
2260        };
2261        let bulk = |partial, write_errors, write_concern_errors| Bulk {
2262            partial,
2263            write_errors,
2264            write_concern_errors,
2265        };
2266
2267        let cases = [
2268            (
2269                "no writes performed",
2270                facts(Transport, true, true),
2271                DefinitelyNotApplied { retryable: true },
2272            ),
2273            (
2274                "partial permanent rejection",
2275                facts(bulk(true, true, false), false, false),
2276                OutcomeUnknown { retryable: false },
2277            ),
2278            (
2279                "partial transport failure",
2280                // The nested retry wrote nothing, but an earlier wire batch still succeeded.
2281                facts(bulk(true, false, false), true, true),
2282                OutcomeUnknown { retryable: true },
2283            ),
2284            (
2285                "deadline",
2286                facts(Deadline, false, true),
2287                OutcomeUnknown { retryable: true },
2288            ),
2289            (
2290                "first ordered write rejected",
2291                facts(bulk(false, true, false), false, false),
2292                DefinitelyNotApplied { retryable: false },
2293            ),
2294            (
2295                "unknown terminal failure",
2296                facts(Unknown, false, false),
2297                OutcomeUnknown { retryable: false },
2298            ),
2299            (
2300                "retryable write concern failure",
2301                facts(WriteConcern, false, true),
2302                OutcomeUnknown { retryable: true },
2303            ),
2304            (
2305                "retryable server rejection",
2306                facts(Command, false, true),
2307                OutcomeUnknown { retryable: true },
2308            ),
2309            (
2310                "per-item write rejection",
2311                facts(WriteRejected, false, false),
2312                DefinitelyNotApplied { retryable: false },
2313            ),
2314        ];
2315
2316        for (name, facts, expected) in cases {
2317            assert_eq!(classify_mongo_bulk_facts(facts), expected, "{name}");
2318        }
2319    }
2320
2321    #[test]
2322    fn later_chunk_failure_preserves_partial_application() {
2323        let completed = WriteResult::new(7, 256);
2324        let error = mongo_partial_batch_error(
2325            &completed,
2326            ConnectorError::ConfigurationError("later chunk rejected".into()),
2327        );
2328        assert!(error.is_outcome_unknown());
2329        assert!(!error.is_transient());
2330        assert!(error.to_string().contains("7 records and 256 bytes"));
2331
2332        let before_output = mongo_partial_batch_error(
2333            &WriteResult::new(0, 0),
2334            ConnectorError::ConfigurationError("rejected before output".into()),
2335        );
2336        assert!(matches!(
2337            before_output,
2338            ConnectorError::ConfigurationError(_)
2339        ));
2340    }
2341
2342    #[test]
2343    fn mongo_transport_and_deadline_errors_require_retirement() {
2344        let driver_error: mongodb::error::Error =
2345            std::io::Error::new(std::io::ErrorKind::ConnectionReset, "reset").into();
2346        let transport = classify_mongo_bulk_failure(
2347            "MongoDB sink bulk_write",
2348            MongoBulkFailure::Driver(&driver_error),
2349        );
2350        assert!(transport.is_outcome_unknown());
2351        assert!(transport.is_transient());
2352
2353        let deadline = classify_mongo_bulk_failure(
2354            "MongoDB sink bulk_write",
2355            MongoBulkFailure::Deadline(Duration::from_secs(1)),
2356        );
2357        assert!(deadline.is_outcome_unknown());
2358        assert!(deadline.is_transient());
2359        assert!(deadline.to_string().contains("timed out after 1s"));
2360    }
2361
2362    #[test]
2363    fn mongo_driver_partial_result_is_classified_as_applied() {
2364        let mut bulk = mongodb::error::BulkWriteError::default();
2365        bulk.partial_result = Some(mongodb::error::PartialBulkWriteResult::Summary(
2366            mongodb::results::SummaryBulkWriteResult::default(),
2367        ));
2368        let driver_error: mongodb::error::Error = mongodb::error::ErrorKind::BulkWrite(bulk).into();
2369
2370        assert_eq!(
2371            classify_mongo_bulk_facts(mongo_bulk_failure_facts(&driver_error)),
2372            MongoBulkDisposition::OutcomeUnknown { retryable: false }
2373        );
2374    }
2375
2376    #[test]
2377    fn unsupported_arrow_types_fail_schema_validation() {
2378        for data_type in [DataType::Binary, DataType::UInt64] {
2379            let schema = Arc::new(Schema::new(vec![Field::new(
2380                "value",
2381                data_type.clone(),
2382                false,
2383            )]));
2384            let error = MongoDbSink::validate_schema(&schema, &test_config()).unwrap_err();
2385            assert!(error.to_string().contains("unsupported Arrow type"));
2386        }
2387    }
2388
2389    #[test]
2390    fn engine_schema_replaces_programmatic_schema_and_validates_upsert_keys() {
2391        let engine_schema = Arc::new(Schema::new(vec![
2392            Field::new("tenant", DataType::Utf8, false),
2393            Field::new("sequence", DataType::Int64, false),
2394        ]));
2395        let mut config = ConnectorConfig::new("mongodb-sink");
2396        config.set("connection.uri", "mongodb://localhost:27017");
2397        config.set("database", "db");
2398        config.set("collection", "out");
2399        config.set("write.mode", "upsert");
2400        config.set("write.mode.key_fields", "tenant");
2401        config.set(
2402            "_arrow_schema",
2403            crate::config::encode_arrow_schema_ipc(engine_schema.as_ref()),
2404        );
2405
2406        let mut sink = MongoDbSink::new(test_schema(), test_config(), None);
2407        sink.apply_connector_config(&config).unwrap();
2408        assert_eq!(sink.schema, engine_schema);
2409
2410        config.set("write.mode.key_fields", "missing");
2411        let error = sink.apply_connector_config(&config).unwrap_err();
2412        assert!(error.to_string().contains("missing"));
2413    }
2414
2415    #[test]
2416    fn test_sink_contract_insert() {
2417        let config = test_config();
2418        let sink = MongoDbSink::new(test_schema(), config, None);
2419        let contract = sink.contract(&ConnectorConfig::new("mongodb")).unwrap();
2420        assert_eq!(contract.consistency, SinkConsistency::DurableAtLeastOnce);
2421        assert_eq!(contract.topology, SinkTopology::MultiWriter);
2422        assert_eq!(contract.input_mode, SinkInputMode::AppendOnly);
2423        assert_eq!(sink.suggested_write_timeout(), Duration::from_secs(30));
2424    }
2425
2426    #[test]
2427    fn test_sink_contract_upsert() {
2428        let mut config = test_config();
2429        config.write_mode = WriteMode::Upsert {
2430            key_fields: vec!["id".to_string()],
2431        };
2432        let sink = MongoDbSink::new(test_schema(), config, None);
2433        let contract = sink.contract(&ConnectorConfig::new("mongodb")).unwrap();
2434        assert_eq!(contract.topology, SinkTopology::Singleton);
2435        assert_eq!(contract.input_mode, SinkInputMode::FullChangelog);
2436    }
2437
2438    #[test]
2439    fn test_sink_contract_cdc_replay() {
2440        let mut config = test_config();
2441        config.write_mode = WriteMode::CdcReplay;
2442        let sink = MongoDbSink::new(super::super::mongodb_cdc_envelope_schema(), config, None);
2443        let contract = sink.contract(&ConnectorConfig::new("mongodb")).unwrap();
2444        assert_eq!(contract.topology, SinkTopology::Singleton);
2445        assert_eq!(contract.input_mode, SinkInputMode::FullChangelog);
2446    }
2447
2448    #[test]
2449    fn batches_to_bson_are_direct_and_working_set_bounded() {
2450        let batches = vec![test_batch(3)];
2451        let retained = retained_batch_bytes(&batches[0]);
2452        let (docs, byte_estimate) = MongoDbSink::batches_to_bson_docs(
2453            &batches,
2454            retained,
2455            MAX_SINK_WORKING_SET_BYTES,
2456            MAX_STANDARD_DOCUMENT_BYTES,
2457        )
2458        .unwrap();
2459        assert_eq!(docs.len(), 3);
2460        assert!(byte_estimate > 0);
2461        assert_eq!(docs[0].get_i64("id").unwrap(), 0);
2462        assert_eq!(docs[0].get_str("name").unwrap(), "user_0");
2463    }
2464
2465    #[test]
2466    fn arrow_to_bson_maps_scalar_types() {
2467        use mongodb::bson::Bson;
2468        let ts = arrow_array::TimestampMillisecondArray::from(vec![Some(1_700_000_000_000), None]);
2469        assert!(
2470            matches!(arrow_value_to_bson(&ts, 0).unwrap(), Bson::DateTime(dt) if dt.timestamp_millis() == 1_700_000_000_000)
2471        );
2472        assert_eq!(arrow_value_to_bson(&ts, 1).unwrap(), Bson::Null);
2473        assert_eq!(
2474            arrow_value_to_bson(&Int64Array::from(vec![42]), 0).unwrap(),
2475            Bson::Int64(42)
2476        );
2477        assert_eq!(
2478            arrow_value_to_bson(&StringArray::from(vec!["x"]), 0).unwrap(),
2479            Bson::String("x".to_string())
2480        );
2481    }
2482
2483    #[test]
2484    fn timestamp_conversion_floors_pre_epoch_values_and_rejects_overflow() {
2485        let micros = arrow_array::TimestampMicrosecondArray::from(vec![-1]);
2486        assert_eq!(timestamp_millis(&micros, 0).unwrap(), -1);
2487        let nanos = arrow_array::TimestampNanosecondArray::from(vec![-1]);
2488        assert_eq!(timestamp_millis(&nanos, 0).unwrap(), -1);
2489        let seconds = arrow_array::TimestampSecondArray::from(vec![i64::MAX]);
2490        assert!(timestamp_millis(&seconds, 0).is_err());
2491    }
2492
2493    #[test]
2494    fn take_buffer_resets_all_accounting() {
2495        let config = MongoDbSinkConfig::default();
2496        let mut sink = MongoDbSink::new(test_schema(), config, None);
2497
2498        let batch = test_batch(5);
2499        let retained = retained_batch_bytes(&batch);
2500        sink.buffer.push(batch);
2501        sink.buffered_rows = 5;
2502        sink.buffered_retained_bytes = retained;
2503
2504        let (pending, pending_retained) = sink.take_buffer();
2505        assert_eq!(pending.len(), 1);
2506        assert_eq!(pending_retained, retained);
2507        assert_eq!(sink.buffered_rows, 0);
2508        assert_eq!(sink.buffered_retained_bytes, 0);
2509        assert!(sink.buffer.is_empty());
2510    }
2511
2512    #[test]
2513    fn configured_flush_interval_is_runtime_timer_authority() {
2514        let mut config = test_config();
2515        config.flush_interval_ms = 37;
2516        let sink = MongoDbSink::new(test_schema(), config, None);
2517        assert_eq!(sink.flush_interval(), Duration::from_millis(37));
2518    }
2519
2520    #[test]
2521    fn runtime_write_budget_derives_driver_headroom_and_clamps_client_timeouts() {
2522        let mut connector = ConnectorConfig::new("mongodb-sink");
2523        connector.set("sink.write.timeout.ms", "500");
2524        let timeout = MongoDbSink::configured_write_timeout(&connector).unwrap();
2525        let mut sink = MongoDbSink::new(test_schema(), test_config(), None);
2526        sink.write_timeout = timeout;
2527
2528        assert_eq!(sink.suggested_write_timeout(), Duration::from_millis(500));
2529        assert_eq!(sink.driver_timeout(), Duration::from_millis(400));
2530        assert_eq!(
2531            clamp_client_timeout(Some(Duration::from_secs(2)), sink.driver_timeout()),
2532            Duration::from_millis(400)
2533        );
2534        assert_eq!(
2535            clamp_client_timeout(Some(Duration::from_millis(50)), sink.driver_timeout()),
2536            Duration::from_millis(50)
2537        );
2538        assert_eq!(
2539            clamp_client_timeout(Some(Duration::ZERO), sink.driver_timeout()),
2540            Duration::from_millis(400)
2541        );
2542
2543        connector.set("sink.write.timeout.ms", "99");
2544        assert!(MongoDbSink::configured_write_timeout(&connector).is_err());
2545    }
2546
2547    #[test]
2548    fn mongodb_tls_defaults_to_verified_and_rejects_insecure_mode() {
2549        use mongodb::options::{ClientOptions, Tls, TlsOptions};
2550
2551        let mut defaults = ClientOptions::default();
2552        harden_mongodb_tls(&mut defaults).unwrap();
2553        assert!(matches!(defaults.tls, Some(Tls::Enabled(_))));
2554
2555        let mut explicit_plaintext = ClientOptions::default();
2556        explicit_plaintext.tls = Some(Tls::Disabled);
2557        harden_mongodb_tls(&mut explicit_plaintext).unwrap();
2558        assert_eq!(explicit_plaintext.tls, Some(Tls::Disabled));
2559
2560        let mut insecure = ClientOptions::default();
2561        insecure.tls = Some(Tls::Enabled(
2562            TlsOptions::builder()
2563                .allow_invalid_certificates(true)
2564                .build(),
2565        ));
2566        let error = harden_mongodb_tls(&mut insecure).unwrap_err();
2567        assert!(error.to_string().contains("tlsInsecure"));
2568    }
2569
2570    #[test]
2571    fn namespace_exists_detection_uses_server_error_code() {
2572        assert!(is_namespace_exists_code(48));
2573        assert!(!is_namespace_exists_code(47));
2574        assert!(!is_namespace_exists_code(49));
2575    }
2576
2577    #[test]
2578    fn retained_limit_counts_variable_width_memory_and_allows_exact_boundary() {
2579        let narrow = RecordBatch::try_new(
2580            test_schema(),
2581            vec![
2582                Arc::new(Int64Array::from(vec![1])),
2583                Arc::new(StringArray::from(vec!["x"])),
2584            ],
2585        )
2586        .unwrap();
2587        let wide_value = "x".repeat(4096);
2588        let wide = RecordBatch::try_new(
2589            test_schema(),
2590            vec![
2591                Arc::new(Int64Array::from(vec![2])),
2592                Arc::new(StringArray::from(vec![wide_value.as_str()])),
2593            ],
2594        )
2595        .unwrap();
2596        let narrow_bytes = retained_batch_bytes(&narrow);
2597        let wide_bytes = retained_batch_bytes(&wide);
2598        assert!(wide_bytes > narrow_bytes);
2599
2600        let exact_limit = narrow_bytes + wide_bytes;
2601        assert!(!requires_preflush(narrow_bytes, wide_bytes, exact_limit).unwrap());
2602        assert!(requires_preflush(narrow_bytes, wide_bytes, exact_limit - 1).unwrap());
2603        assert!(requires_preflush(usize::MAX, 1, usize::MAX).unwrap());
2604    }
2605
2606    #[test]
2607    fn converted_limit_allows_exact_boundary_and_rejects_crossing() {
2608        assert_eq!(checked_converted_total(5, 7, 12, "test").unwrap(), 12);
2609        let error = checked_converted_total(5, 8, 12, "test").unwrap_err();
2610        assert!(!error.is_transient());
2611    }
2612
2613    #[test]
2614    fn one_working_set_budget_covers_retained_models_and_staging() {
2615        let retained = 1_024;
2616        let encoded = 2_048;
2617        let models = 3;
2618        let staging = 512;
2619        let exact = retained
2620            + encoded * MATERIALIZED_BYTE_CHARGE
2621            + models * WRITE_MODEL_OVERHEAD_BYTES
2622            + staging;
2623        assert_eq!(
2624            working_set_charge(retained, encoded as u64, models, staging),
2625            Some(exact)
2626        );
2627        ensure_working_set(retained, encoded as u64, models, staging, exact, "test").unwrap();
2628        let error =
2629            ensure_working_set(retained, encoded as u64, models, staging, exact - 1, "test")
2630                .unwrap_err();
2631        assert!(error.to_string().contains("working set"));
2632        assert!(working_set_charge(usize::MAX, 1, 1, 1).is_none());
2633    }
2634
2635    #[test]
2636    fn bson_document_limit_uses_exact_encoded_size() {
2637        let document = mongodb::bson::doc! { "id": 1_i64, "name": "value" };
2638        let exact = mongodb::bson::to_vec(&document).unwrap().len();
2639        assert_eq!(
2640            encoded_document_size(&document, exact, "test document").unwrap(),
2641            exact
2642        );
2643        let error = encoded_document_size(&document, exact - 1, "test document").unwrap_err();
2644        assert!(!error.is_transient());
2645        assert!(error.to_string().contains("BSON document"));
2646    }
2647
2648    #[test]
2649    fn time_series_conversion_enforces_four_mib_document_limit() {
2650        let value = "x".repeat(MAX_TIMESERIES_DOCUMENT_BYTES);
2651        let batch = RecordBatch::try_new(
2652            test_schema(),
2653            vec![
2654                Arc::new(Int64Array::from(vec![1])),
2655                Arc::new(StringArray::from(vec![value])),
2656            ],
2657        )
2658        .unwrap();
2659        let retained = retained_batch_bytes(&batch);
2660        let error = MongoDbSink::batches_to_bson_docs(
2661            &[batch],
2662            retained,
2663            usize::MAX,
2664            MAX_TIMESERIES_DOCUMENT_BYTES,
2665        )
2666        .unwrap_err();
2667        assert!(error.to_string().contains("4194304"));
2668    }
2669
2670    #[tokio::test]
2671    async fn oversized_batch_rejection_preserves_existing_buffer() {
2672        let mut sink = MongoDbSink::new(test_schema(), test_config(), None);
2673        sink.state = ConnectorState::Running;
2674        let existing = test_batch(1);
2675        sink.write_batch_with_retained_limit(&existing, usize::MAX)
2676            .await
2677            .unwrap();
2678        let rows_before = sink.buffered_rows;
2679        let bytes_before = sink.buffered_retained_bytes;
2680        let batches_before = sink.buffer.len();
2681
2682        let incoming = test_batch(2);
2683        let incoming_bytes = retained_batch_bytes(&incoming);
2684        let error = sink
2685            .write_batch_with_retained_limit(&incoming, incoming_bytes - 1)
2686            .await
2687            .expect_err("oversized batch must fail before admission");
2688
2689        assert!(!error.is_transient());
2690        assert_eq!(sink.buffered_rows, rows_before);
2691        assert_eq!(sink.buffered_retained_bytes, bytes_before);
2692        assert_eq!(sink.buffer.len(), batches_before);
2693    }
2694
2695    #[tokio::test]
2696    async fn automatic_flush_error_clears_buffer_and_accounting() {
2697        let mut sink = MongoDbSink::new(test_schema(), test_config(), None);
2698        sink.state = ConnectorState::Running;
2699        let error = sink
2700            .write_batch_with_retained_limit(&test_batch(MAX_DOCUMENTS_PER_FLUSH), usize::MAX)
2701            .await
2702            .expect_err("missing collection must fail the automatic flush");
2703
2704        assert!(matches!(error, ConnectorError::Internal(_)));
2705        assert!(sink.buffer.is_empty());
2706        assert_eq!(sink.buffered_rows, 0);
2707        assert_eq!(sink.buffered_retained_bytes, 0);
2708    }
2709
2710    #[tokio::test]
2711    async fn lifecycle_and_schema_are_checked_before_write() {
2712        let mut sink = MongoDbSink::new(test_schema(), test_config(), None);
2713        let state_error = sink.write_batch(&test_batch(1)).await.unwrap_err();
2714        assert!(matches!(state_error, ConnectorError::InvalidState { .. }));
2715
2716        sink.state = ConnectorState::Running;
2717        let other_schema = Arc::new(Schema::new(vec![Field::new(
2718            "other",
2719            DataType::Int64,
2720            false,
2721        )]));
2722        let other_batch =
2723            RecordBatch::try_new(other_schema, vec![Arc::new(Int64Array::from(vec![1]))]).unwrap();
2724        let schema_error = sink.write_batch(&other_batch).await.unwrap_err();
2725        assert!(matches!(schema_error, ConnectorError::SchemaMismatch(_)));
2726    }
2727
2728    #[tokio::test]
2729    async fn close_releases_resources_but_returns_pending_flush_error() {
2730        let mut sink = MongoDbSink::new(test_schema(), test_config(), None);
2731        sink.state = ConnectorState::Running;
2732        sink.write_batch_with_retained_limit(&test_batch(1), usize::MAX)
2733            .await
2734            .unwrap();
2735
2736        let error = sink.close().await.expect_err("pending flush must fail");
2737        assert!(matches!(error, ConnectorError::Internal(_)));
2738        assert_eq!(sink.state, ConnectorState::Closed);
2739        assert!(sink.buffer.is_empty());
2740        assert_eq!(sink.buffered_rows, 0);
2741        assert_eq!(sink.buffered_retained_bytes, 0);
2742        assert!(sink.collection.is_none());
2743        assert!(sink.client.is_none());
2744    }
2745
2746    #[test]
2747    fn cdc_insert_is_prepared_as_document_keyed_idempotent_upsert() {
2748        let rows = vec![serde_json::json!({
2749            "_namespace": "db.coll",
2750            "_op": "I",
2751            "_document_key": r#"{"_id":"a"}"#,
2752            "_full_document": r#"{"_id":"a","value":1}"#,
2753            "_update_desc": null
2754        })];
2755        let (writes, bytes) =
2756            MongoDbSink::prepare_cdc_writes(&rows, MAX_SINK_WORKING_SET_BYTES, "db.coll").unwrap();
2757        assert!(bytes > 0);
2758        match &writes[0] {
2759            CdcWrite::Insert {
2760                filter,
2761                replacement,
2762            } => {
2763                assert_eq!(
2764                    filter.get_document("_id").unwrap().get_str("$eq").unwrap(),
2765                    "a"
2766                );
2767                assert_eq!(replacement.get_i32("value").unwrap(), 1);
2768            }
2769            _ => panic!("insert must be a keyed upsert plan"),
2770        }
2771    }
2772
2773    #[test]
2774    fn cdc_replay_requires_id_but_accepts_complete_sharded_and_document_keys() {
2775        let missing_id = vec![serde_json::json!({
2776            "_namespace": "db.coll",
2777            "_op": "D",
2778            "_document_key": r#"{"tenant":"a"}"#,
2779            "_full_document": null,
2780            "_update_desc": null
2781        })];
2782        let error =
2783            MongoDbSink::prepare_cdc_writes(&missing_id, MAX_SINK_WORKING_SET_BYTES, "db.coll")
2784                .unwrap_err();
2785        assert!(error.to_string().contains("must contain '_id'"));
2786
2787        for key in [
2788            r#"{"_id":"a","tenant":"t"}"#,
2789            r#"{"_id":{"tenant":"t","sequence":1}}"#,
2790        ] {
2791            let rows = vec![serde_json::json!({
2792                "_namespace": "db.coll",
2793                "_op": "D",
2794                "_document_key": key,
2795                "_full_document": null,
2796                "_update_desc": null
2797            })];
2798            MongoDbSink::prepare_cdc_writes(&rows, MAX_SINK_WORKING_SET_BYTES, "db.coll").unwrap();
2799        }
2800    }
2801
2802    #[test]
2803    fn cdc_replay_rejects_cross_namespace_rows() {
2804        let rows = vec![serde_json::json!({
2805            "_namespace": "source.events",
2806            "_op": "D",
2807            "_document_key": r#"{"_id":"a"}"#,
2808            "_full_document": null,
2809            "_update_desc": null
2810        })];
2811        let error =
2812            MongoDbSink::prepare_cdc_writes(&rows, MAX_SINK_WORKING_SET_BYTES, "target.events")
2813                .unwrap_err();
2814        assert!(error.to_string().contains("fixed target"));
2815    }
2816
2817    #[test]
2818    fn cdc_replay_rejects_replacement_key_drift() {
2819        let rows = vec![serde_json::json!({
2820            "_namespace": "db.coll",
2821            "_op": "I",
2822            "_document_key": r#"{"_id":"a","tenant":"source"}"#,
2823            "_full_document": r#"{"_id":"a","tenant":"other","value":1}"#,
2824            "_update_desc": null
2825        })];
2826        let error = MongoDbSink::prepare_cdc_writes(&rows, MAX_SINK_WORKING_SET_BYTES, "db.coll")
2827            .unwrap_err();
2828        assert!(error.to_string().contains("tenant"), "{error}");
2829    }
2830
2831    #[test]
2832    fn cdc_bulk_models_preserve_mixed_operation_order() {
2833        use mongodb::options::WriteModel;
2834
2835        let writes = vec![
2836            CdcWrite::Insert {
2837                filter: mongodb::bson::doc! { "_id": "a" },
2838                replacement: mongodb::bson::doc! { "_id": "a", "v": 1 },
2839            },
2840            CdcWrite::Update {
2841                filter: mongodb::bson::doc! { "_id": "a" },
2842                update: mongodb::bson::doc! { "$set": { "v": 2 } },
2843            },
2844            CdcWrite::Noop,
2845            CdcWrite::Delete {
2846                filter: mongodb::bson::doc! { "_id": "a" },
2847            },
2848            CdcWrite::Replace {
2849                filter: mongodb::bson::doc! { "_id": "b" },
2850                replacement: mongodb::bson::doc! { "_id": "b", "v": 3 },
2851            },
2852        ];
2853        let (models, counts) = cdc_bulk_models(&mongodb::Namespace::new("db", "out"), writes);
2854
2855        assert_eq!(models.len(), 4);
2856        assert!(matches!(&models[0], WriteModel::ReplaceOne(model) if model.upsert == Some(true)));
2857        assert!(matches!(&models[1], WriteModel::UpdateOne(_)));
2858        assert!(matches!(&models[2], WriteModel::DeleteOne(_)));
2859        assert!(matches!(&models[3], WriteModel::ReplaceOne(model) if model.upsert == Some(true)));
2860        assert_eq!(counts.inserts, 1);
2861        assert_eq!(counts.upserts, 2);
2862        assert_eq!(counts.deletes, 1);
2863    }
2864
2865    #[test]
2866    fn cdc_update_accepts_source_shape_and_preserves_array_truncation() {
2867        let update_description = serde_json::json!({
2868            "updated_fields": {"name": "new"},
2869            "removed_fields": ["obsolete"],
2870            "truncated_arrays": [{"field": "items", "new_size": 2}]
2871        });
2872        let rows = vec![serde_json::json!({
2873            "_namespace": "db.coll",
2874            "_op": "U",
2875            "_document_key": r#"{"_id":"a"}"#,
2876            "_full_document": null,
2877            "_update_desc": update_description.to_string()
2878        })];
2879        let (writes, _) =
2880            MongoDbSink::prepare_cdc_writes(&rows, MAX_SINK_WORKING_SET_BYTES, "db.coll").unwrap();
2881        match &writes[0] {
2882            CdcWrite::Update { update, .. } => {
2883                assert!(update.contains_key("$set"));
2884                assert!(update.contains_key("$unset"));
2885                assert!(update.contains_key("$push"));
2886            }
2887            _ => panic!("update event must produce an update plan"),
2888        }
2889    }
2890
2891    #[test]
2892    fn cdc_unknown_operation_fails_closed() {
2893        for operation in ["FUTURE_OP", "DROP", "RENAME", "INVALIDATE", "DROP_DATABASE"] {
2894            let rows = vec![serde_json::json!({
2895                "_namespace": "db.coll",
2896                "_op": operation
2897            })];
2898            let error =
2899                MongoDbSink::prepare_cdc_writes(&rows, MAX_SINK_WORKING_SET_BYTES, "db.coll")
2900                    .unwrap_err();
2901            assert!(!error.is_transient());
2902            assert!(error.to_string().contains(operation), "{error}");
2903        }
2904    }
2905
2906    #[test]
2907    fn cdc_ambiguous_update_paths_fail_closed() {
2908        let update_description = serde_json::json!({
2909            "updated_fields": {"a.b": 1},
2910            "removed_fields": [],
2911            "truncated_arrays": [],
2912            "disambiguated_paths": {"a.b": ["a.b"]}
2913        });
2914        let rows = vec![serde_json::json!({
2915            "_namespace": "db.coll",
2916            "_op": "U",
2917            "_document_key": r#"{"_id":"a"}"#,
2918            "_full_document": null,
2919            "_update_desc": update_description.to_string()
2920        })];
2921
2922        let error = MongoDbSink::prepare_cdc_writes(&rows, MAX_SINK_WORKING_SET_BYTES, "db.coll")
2923            .unwrap_err();
2924        assert!(!error.is_transient());
2925        assert!(error.to_string().contains("ambiguous field paths"));
2926    }
2927
2928    #[test]
2929    fn cdc_full_document_update_uses_idempotent_replacement() {
2930        let update_description = serde_json::json!({
2931            "updated_fields": {"a.b": 1},
2932            "removed_fields": [],
2933            "truncated_arrays": [],
2934            "disambiguated_paths": {"a.b": ["a.b"]}
2935        });
2936        let rows = vec![serde_json::json!({
2937            "_namespace": "db.coll",
2938            "_op": "U",
2939            "_document_key": r#"{"_id":"a"}"#,
2940            "_full_document": r#"{"_id":"a","a.b":1}"#,
2941            "_update_desc": update_description.to_string()
2942        })];
2943
2944        let (writes, _) =
2945            MongoDbSink::prepare_cdc_writes(&rows, MAX_SINK_WORKING_SET_BYTES, "db.coll").unwrap();
2946        assert!(matches!(&writes[0], CdcWrite::Replace { .. }));
2947    }
2948}