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