1use 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
35const 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
178pub 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 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 #[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 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 #[must_use]
413 pub fn config(&self) -> &MongoDbSinkConfig {
414 &self.config
415 }
416
417 #[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 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 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 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 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 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 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 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
739fn 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
781fn 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
983fn 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 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 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 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_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 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 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 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 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 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 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 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 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(µs, 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}