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
53mod bulk;
54mod cdc;
55pub(super) mod conversion;
56mod failure;
57mod lifecycle;
58mod schema_resolution;
59mod validation;
60
61#[cfg(test)]
62use bulk::{await_mongo_sink_operation, MongoOperationOutcome};
63use bulk::{cdc_bulk_models, CdcWrite};
64use conversion::{
65 account_bson_document, accumulate_write_result, arrow_value_to_bson, cdc_row_value,
66 checked_converted_total, clamp_client_timeout, encoded_document_size, ensure_working_set,
67 json_to_bson, json_to_bson_document, mongo_partial_batch_error, requires_preflush,
68 retained_batch_bytes, validate_cdc_document_key, validate_cdc_replacement_key,
69};
70#[cfg(test)]
71use conversion::{timestamp_millis, working_set_charge};
72#[cfg(test)]
73use failure::{
74 classify_mongo_bulk_facts, mongo_bulk_failure_facts, MongoBulkDisposition,
75 MongoBulkFailureFacts, MongoBulkFailureShape,
76};
77use failure::{classify_mongo_bulk_failure, MongoBulkFailure};
78pub(super) use validation::harden_mongodb_tls;
79#[cfg(test)]
80use validation::is_namespace_exists_code;
81use validation::{
82 is_namespace_exists, is_supported_mongodb_arrow_type, validate_existing_timeseries_spec,
83};
84
85pub struct MongoDbSink {
89 config: MongoDbSinkConfig,
90 schema: SchemaRef,
91 binding: Option<crate::schema::resolution::SchemaBinding>,
92 state: ConnectorState,
93 buffer: Vec<RecordBatch>,
94 buffered_rows: usize,
95 buffered_retained_bytes: usize,
96 write_timeout: Duration,
97 metrics: MongoDbSinkMetrics,
98
99 task_owner: ConnectorTaskOwner,
101 task_tracker: ConnectorTaskTracker,
102
103 client: Option<mongodb::Client>,
104 collection: Option<mongodb::Collection<mongodb::bson::Document>>,
105}
106
107impl MongoDbSink {
108 #[must_use]
110 pub fn new(
111 schema: SchemaRef,
112 config: MongoDbSinkConfig,
113 registry: Option<&prometheus::Registry>,
114 ) -> Self {
115 let (task_owner, task_tracker) = ConnectorTaskOwner::new();
116 Self {
117 config,
118 schema,
119 binding: None,
120 state: ConnectorState::Created,
121 buffer: Vec::with_capacity(4),
122 buffered_rows: 0,
123 buffered_retained_bytes: 0,
124 write_timeout: DEFAULT_WRITE_TIMEOUT,
125 metrics: MongoDbSinkMetrics::new(registry),
126 task_owner,
127 task_tracker,
128 client: None,
129 collection: None,
130 }
131 }
132
133 pub fn from_config(
139 schema: SchemaRef,
140 config: &ConnectorConfig,
141 ) -> Result<Self, ConnectorError> {
142 let mongo_config = MongoDbSinkConfig::from_config(config)?;
143 Self::validate_schema(&schema, &mongo_config)?;
144 let mut sink = Self::new(schema, mongo_config, None);
145 sink.write_timeout = Self::configured_write_timeout(config)?;
146 Ok(sink)
147 }
148
149 pub(crate) fn from_connector_config(
150 config: &ConnectorConfig,
151 registry: Option<&prometheus::Registry>,
152 ) -> Result<Self, ConnectorError> {
153 let (sink_config, schema, write_timeout) = Self::decode_connector_config(config)?;
154 let mut sink = Self::new(schema, sink_config, registry);
155 sink.write_timeout = write_timeout;
156 Ok(sink)
157 }
158
159 fn decode_connector_config(
160 config: &ConnectorConfig,
161 ) -> Result<(MongoDbSinkConfig, SchemaRef, Duration), ConnectorError> {
162 let sink_config = MongoDbSinkConfig::from_config(config)?;
163 if config.get("_arrow_schema").is_none() {
164 return Err(ConnectorError::ConfigurationError(
165 "MongoDB sink requires the engine-injected '_arrow_schema'".into(),
166 ));
167 }
168 let schema = config.arrow_schema().ok_or_else(|| {
169 ConnectorError::ConfigurationError(
170 "invalid MongoDB sink '_arrow_schema' encoding".into(),
171 )
172 })?;
173 Self::validate_schema(&schema, &sink_config)?;
174 let write_timeout = Self::configured_write_timeout(config)?;
175 Ok((sink_config, schema, write_timeout))
176 }
177
178 fn configured_write_timeout(config: &ConnectorConfig) -> Result<Duration, ConnectorError> {
179 let timeout = config
180 .get_parsed::<u64>("sink.write.timeout.ms")?
181 .map_or(DEFAULT_WRITE_TIMEOUT, Duration::from_millis);
182 if timeout < MIN_WRITE_TIMEOUT {
183 return Err(ConnectorError::ConfigurationError(format!(
184 "MongoDB sink.write.timeout.ms must be at least {}",
185 MIN_WRITE_TIMEOUT.as_millis()
186 )));
187 }
188 Ok(timeout)
189 }
190
191 fn driver_timeout(&self) -> Duration {
192 let headroom = (self.write_timeout / 5).min(MAX_DRIVER_TIMEOUT_HEADROOM);
193 self.write_timeout.checked_sub(headroom).unwrap()
194 }
195
196 fn validate_schema(
197 schema: &SchemaRef,
198 config: &MongoDbSinkConfig,
199 ) -> Result<(), ConnectorError> {
200 if schema.fields().is_empty() {
201 return Err(ConnectorError::ConfigurationError(
202 "MongoDB sink schema must contain at least one field".into(),
203 ));
204 }
205
206 let mut names = std::collections::HashSet::with_capacity(schema.fields().len());
207 for field in schema.fields() {
208 if !names.insert(field.name()) {
209 return Err(ConnectorError::ConfigurationError(format!(
210 "MongoDB sink schema contains duplicate field '{}'",
211 field.name()
212 )));
213 }
214 if !is_supported_mongodb_arrow_type(field.data_type()) {
215 return Err(ConnectorError::ConfigurationError(format!(
216 "MongoDB sink field '{}' has unsupported Arrow type {:?}",
217 field.name(),
218 field.data_type()
219 )));
220 }
221 }
222
223 match &config.write_mode {
224 WriteMode::Upsert { key_fields } => {
225 for key in key_fields {
226 match schema.field_with_name(key) {
227 Ok(field) if !field.is_nullable() => {}
228 Ok(_) => {
229 return Err(ConnectorError::ConfigurationError(format!(
230 "upsert key field '{key}' must be non-nullable"
231 )));
232 }
233 Err(_) => {
234 return Err(ConnectorError::ConfigurationError(format!(
235 "upsert key field '{key}' is not present in MongoDB sink schema"
236 )));
237 }
238 }
239 }
240 }
241 WriteMode::CdcReplay => {
242 for field in conversion::CDC_REPLAY_FIELDS {
243 match schema.field_with_name(field) {
244 Ok(schema_field) if schema_field.data_type() == &DataType::Utf8 => {}
245 Ok(schema_field) => {
246 return Err(ConnectorError::ConfigurationError(format!(
247 "MongoDB CDC replay field '{field}' must be Utf8, got {:?}",
248 schema_field.data_type()
249 )));
250 }
251 Err(_) => {
252 return Err(ConnectorError::ConfigurationError(format!(
253 "MongoDB CDC replay input must be MongoDB history records \
254 (output.mode=history); missing '{field}'"
255 )));
256 }
257 }
258 }
259 for field in ["operation", "database"] {
260 if schema
261 .field_with_name(field)
262 .is_ok_and(arrow_schema::Field::is_nullable)
263 {
264 return Err(ConnectorError::ConfigurationError(format!(
265 "MongoDB CDC replay field '{field}' must be non-nullable"
266 )));
267 }
268 }
269 if let Some(field) = schema.fields().iter().find(|field| {
272 field.name().eq_ignore_ascii_case(WEIGHT_COLUMN)
273 || field.name().eq_ignore_ascii_case("_op")
274 }) {
275 return Err(ConnectorError::ConfigurationError(format!(
276 "MongoDB CDC replay consumes append-only history records; input column \
277 '{}' marks a changelog it cannot apply",
278 field.name()
279 )));
280 }
281 }
282 WriteMode::Insert => {}
283 }
284
285 if let CollectionKind::TimeSeries(time_series) = &config.collection_kind {
286 let time_field = schema
287 .field_with_name(&time_series.time_field)
288 .map_err(|_| {
289 ConnectorError::ConfigurationError(format!(
290 "time series field '{}' is not present in MongoDB sink schema",
291 time_series.time_field
292 ))
293 })?;
294 if !matches!(time_field.data_type(), DataType::Timestamp(..)) {
295 return Err(ConnectorError::ConfigurationError(format!(
296 "MongoDB time series field '{}' must be an Arrow Timestamp",
297 time_series.time_field
298 )));
299 }
300 if time_field.is_nullable() {
301 return Err(ConnectorError::ConfigurationError(format!(
302 "MongoDB time series field '{}' must be non-nullable",
303 time_series.time_field
304 )));
305 }
306 if let Some(meta_field) = &time_series.meta_field {
307 if schema.field_with_name(meta_field).is_err() {
308 return Err(ConnectorError::ConfigurationError(format!(
309 "time series metadata field '{meta_field}' is not present in MongoDB sink schema"
310 )));
311 }
312 }
313 }
314
315 Ok(())
316 }
317
318 fn apply_connector_config(&mut self, config: &ConnectorConfig) -> Result<(), ConnectorError> {
319 let (sink_config, schema, write_timeout) = Self::decode_connector_config(config)?;
320 self.config = sink_config;
321 self.schema = schema;
322 self.write_timeout = write_timeout;
323 Ok(())
324 }
325
326 #[must_use]
328 pub fn config(&self) -> &MongoDbSinkConfig {
329 &self.config
330 }
331
332 #[must_use]
334 pub fn buffered_rows(&self) -> usize {
335 self.buffered_rows
336 }
337
338 fn retain_batch(&mut self, batch: RecordBatch, retained_bytes: usize) {
339 self.buffered_rows = self.buffered_rows.saturating_add(batch.num_rows());
340 self.buffered_retained_bytes = self.buffered_retained_bytes.saturating_add(retained_bytes);
341 self.buffer.push(batch);
342 }
343
344 fn take_buffer(&mut self) -> (Vec<RecordBatch>, usize) {
345 let retained_bytes = self.buffered_retained_bytes;
346 self.buffered_rows = 0;
347 self.buffered_retained_bytes = 0;
348 (std::mem::take(&mut self.buffer), retained_bytes)
349 }
350
351 fn batches_to_cdc_writes(
353 batches: &[RecordBatch],
354 retained_bytes: usize,
355 working_set_limit: usize,
356 expected_namespace: &str,
357 ) -> Result<(Vec<CdcWrite>, u64), ConnectorError> {
358 let row_count = batches.iter().map(RecordBatch::num_rows).sum();
359 let mut writes = Vec::with_capacity(row_count);
360 let mut encoded_bytes = 0;
361
362 for batch in batches {
363 for row_idx in 0..batch.num_rows() {
364 let (value, staging_bytes) = cdc_row_value(batch, row_idx)?;
365 ensure_working_set(
366 retained_bytes,
367 encoded_bytes,
368 writes.len().saturating_add(1),
369 staging_bytes,
370 working_set_limit,
371 "MongoDB CDC flush",
372 )?;
373 let (mut row_writes, row_bytes) = Self::prepare_cdc_writes(
374 std::slice::from_ref(&value),
375 usize::MAX,
376 expected_namespace,
377 )?;
378 let row_bytes = usize::try_from(row_bytes).map_err(|_| {
379 ConnectorError::ConfigurationError(
380 "MongoDB CDC encoded byte count exceeds this platform".into(),
381 )
382 })?;
383 encoded_bytes = checked_converted_total(
384 encoded_bytes,
385 row_bytes,
386 usize::MAX,
387 "MongoDB CDC flush",
388 )?;
389 let write = row_writes.pop().ok_or_else(|| {
390 ConnectorError::Internal("MongoDB CDC row produced no write disposition".into())
391 })?;
392 ensure_working_set(
393 retained_bytes,
394 encoded_bytes,
395 writes.len().saturating_add(1),
396 staging_bytes,
397 working_set_limit,
398 "MongoDB CDC flush",
399 )?;
400 writes.push(write);
401 }
402 }
403
404 Ok((writes, encoded_bytes))
405 }
406
407 fn batches_to_bson_docs(
410 batches: &[RecordBatch],
411 retained_bytes: usize,
412 working_set_limit: usize,
413 document_limit: usize,
414 ) -> Result<(Vec<mongodb::bson::Document>, u64), ConnectorError> {
415 let row_count = batches.iter().map(RecordBatch::num_rows).sum();
416 let mut docs = Vec::with_capacity(row_count);
417 let mut bytes: u64 = 0;
418 for batch in batches {
419 let schema = batch.schema();
420 for row in 0..batch.num_rows() {
421 let mut doc = mongodb::bson::Document::new();
422 for (col_idx, field) in schema.fields().iter().enumerate() {
423 let value = arrow_value_to_bson(batch.column(col_idx), row)?;
424 doc.insert(field.name().clone(), value);
425 }
426 let document_bytes =
427 encoded_document_size(&doc, document_limit, "MongoDB sink document")?;
428 bytes = checked_converted_total(
429 bytes,
430 document_bytes,
431 usize::MAX,
432 "MongoDB sink flush",
433 )?;
434 ensure_working_set(
435 retained_bytes,
436 bytes,
437 docs.len().saturating_add(1),
438 0,
439 working_set_limit,
440 "MongoDB sink flush",
441 )?;
442 docs.push(doc);
443 }
444 }
445 Ok((docs, bytes))
446 }
447
448 async fn flush_inner(&mut self) -> Result<WriteResult, ConnectorError> {
452 if self.buffer.is_empty() {
453 return Ok(WriteResult::new(0, 0));
454 }
455
456 if let Some(expected) = &self.binding {
457 let client = self.client.as_ref().ok_or_else(|| {
458 ConnectorError::ConnectionFailed("MongoDB activated client is absent".into())
459 })?;
460 let current = tokio::time::timeout(
461 self.write_timeout,
462 schema_resolution::inspect(
463 &client.database(&self.config.database),
464 &ConnectorConfig::new("mongodb"),
465 &self.config,
466 self.schema.clone(),
467 ),
468 )
469 .await
470 .map_err(|_| {
471 ConnectorError::Timeout(
472 u64::try_from(self.write_timeout.as_millis()).unwrap_or(u64::MAX),
473 )
474 })??;
475 if expected.value.is_some() && expected.value != current.value {
476 return Err(ConnectorError::SchemaMismatch("MongoDB collection identity or validator drifted before flush; restart with a migrated contract".into()));
477 }
478 self.binding = Some(current);
479 }
480 let (mut pending, retained_bytes) = self.take_buffer();
483 let write_result: Result<(usize, u64), ConnectorError> =
484 if matches!(self.config.write_mode, WriteMode::CdcReplay) {
485 let target = format!("{}.{}", self.config.database, self.config.collection);
486 let source = self
487 .config
488 .replay_source_namespace
489 .as_deref()
490 .unwrap_or(&target);
491 match Self::batches_to_cdc_writes(
492 &pending,
493 retained_bytes,
494 MAX_SINK_WORKING_SET_BYTES,
495 source,
496 ) {
497 Ok((writes, bytes)) => {
498 let count = writes.len();
499 pending.clear();
500 self.execute_cdc_writes(writes)
501 .await
502 .map(|()| (count, bytes))
503 }
504 Err(error) => Err(error),
505 }
506 } else {
507 match Self::collapse_changelog_buffer_for_upsert(&self.config, &mut pending)
508 .and_then(|collapsed| {
509 let retained_bytes = pending.iter().fold(0_usize, |total, batch| {
510 total.saturating_add(retained_batch_bytes(batch))
511 });
512 Self::batches_to_bson_docs(
513 &pending,
514 retained_bytes,
515 MAX_SINK_WORKING_SET_BYTES,
516 if matches!(&self.config.collection_kind, CollectionKind::TimeSeries(_))
517 {
518 MAX_TIMESERIES_DOCUMENT_BYTES
519 } else {
520 MAX_STANDARD_DOCUMENT_BYTES
521 },
522 )
523 .map(|(docs, bytes)| (docs, collapsed, bytes))
524 }) {
525 Ok((docs, collapsed, bytes)) => {
526 let count = docs.len();
527 pending.clear();
528 self.write_bson_docs(docs, collapsed, bytes)
529 .await
530 .map(|()| (count, bytes))
531 }
532 Err(error) => Err(error),
533 }
534 };
535
536 pending.clear();
537 self.buffer = pending;
538
539 let (doc_count, byte_estimate) = write_result?;
540 self.metrics.record_flush(doc_count as u64, byte_estimate);
541 Ok(WriteResult::new(doc_count, byte_estimate))
542 }
543
544 fn collapse_changelog_buffer_for_upsert(
550 config: &MongoDbSinkConfig,
551 buffer: &mut Vec<RecordBatch>,
552 ) -> Result<bool, ConnectorError> {
553 let key_fields = match &config.write_mode {
554 WriteMode::Upsert { key_fields } => key_fields.clone(),
555 _ => return Ok(false),
556 };
557 let Some(first) = buffer.first() else {
560 return Ok(false);
561 };
562 if first.schema().index_of(WEIGHT_COLUMN).is_err() {
563 return Ok(false);
564 }
565 let schema = first.schema();
566 let combined = arrow_select::concat::concat_batches(&schema, buffer.iter())
567 .map_err(|e| ConnectorError::Internal(format!("concat changelog: {e}")))?;
568 *buffer = vec![collapse_changelog(&combined, &key_fields)?];
569 Ok(true)
570 }
571
572 async fn write_batch_with_retained_limit(
573 &mut self,
574 batch: &RecordBatch,
575 retained_limit: usize,
576 ) -> Result<WriteResult, ConnectorError> {
577 if self.state != ConnectorState::Running {
578 return Err(ConnectorError::InvalidState {
579 expected: ConnectorState::Running.to_string(),
580 actual: self.state.to_string(),
581 });
582 }
583 if batch.schema().as_ref() != self.schema.as_ref() {
584 return Err(ConnectorError::SchemaMismatch(format!(
585 "MongoDB sink expected {:?}, got {:?}",
586 self.schema,
587 batch.schema()
588 )));
589 }
590
591 let rows = batch.num_rows();
592 if rows == 0 {
593 return Ok(WriteResult::new(0, 0));
594 }
595
596 let retained_bytes = retained_batch_bytes(batch);
597 let _ = requires_preflush(0, retained_bytes, retained_limit)?;
599
600 let mut offset = 0;
601 let mut result = WriteResult::new(0, 0);
602 while offset < rows {
603 let available_rows = MAX_DOCUMENTS_PER_FLUSH - self.buffered_rows;
604 let chunk_rows = available_rows.min(rows - offset);
605 let chunk = batch.slice(offset, chunk_rows);
606 let chunk_retained_bytes = retained_batch_bytes(&chunk);
607
608 if requires_preflush(
609 self.buffered_retained_bytes,
610 chunk_retained_bytes,
611 retained_limit,
612 )? {
613 let flushed = self
614 .flush_inner()
615 .await
616 .map_err(|error| mongo_partial_batch_error(&result, error))?;
617 accumulate_write_result(&mut result, &flushed);
618 }
619
620 self.retain_batch(chunk, chunk_retained_bytes);
621 offset += chunk_rows;
622 if self.buffered_rows == MAX_DOCUMENTS_PER_FLUSH {
623 let flushed = self
624 .flush_inner()
625 .await
626 .map_err(|error| mongo_partial_batch_error(&result, error))?;
627 accumulate_write_result(&mut result, &flushed);
628 }
629 }
630
631 Ok(result)
632 }
633}
634
635#[cfg(test)]
636mod tests;