laminar_connectors/mongodb/config/
mod.rs1use std::time::Duration;
11
12use crate::config::ConnectorConfig;
13use crate::error::ConnectorError;
14
15use super::timeseries::{CollectionKind, TimeSeriesConfig, TimeSeriesGranularity};
16use super::write_model::WriteMode;
17
18const REMOVED_SOURCE_CONFIG_KEYS: &[&str] = &[
19 "batch.size",
20 "max.buffered.events",
21 "max.await.time.ms",
22 "resume.token.store",
23 "split.large.events",
24 "max.poll.records",
25];
26
27const SOURCE_CONFIG_KEYS: &[&str] = &[
28 "connection.uri",
29 "database",
30 "collection",
31 "full.document.mode",
32 "pipeline",
33 "max.buffered.bytes",
34 "laminar.source.name",
35 "_arrow_schema",
36 "_primary_key_columns",
37];
38
39pub(super) const DEFAULT_MAX_BUFFERED_BYTES: usize = 64 * 1024 * 1024;
40pub(super) const MAX_PIPELINE_STAGES: usize = 64;
41pub(super) const MAX_PIPELINE_JSON_BYTES: usize = 256 * 1024;
42const MIN_BUFFERED_BYTES: usize = 1024 * 1024;
43const MAX_BUFFERED_BYTES: usize = 4 * 1024 * 1024 * 1024;
44const ESTIMATED_BUFFERED_ITEM_BYTES: usize = 64 * 1024;
45const MAX_READER_CHANNEL_ITEMS: usize = 4096;
46const MAX_CURSOR_BATCH_ITEMS: u32 = 1000;
47
48const SINK_CONFIG_KEYS: &[&str] = &[
49 "connection.uri",
50 "database",
51 "collection",
52 "flush.interval.ms",
53 "write.mode",
54 "write.mode.key_fields",
55 "timeseries.time_field",
56 "timeseries.meta_field",
57 "timeseries.granularity",
58 "timeseries.bucket_max_span_seconds",
59 "timeseries.bucket_rounding_seconds",
60 "timeseries.expire_after_seconds",
61 "delivery.guarantee",
62 "sink.write.timeout.ms",
63 "_arrow_schema",
64];
65
66const REMOVED_SINK_CONFIG_KEYS: &[&str] = &["batch.size", "write_concern.timeout_ms"];
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
73#[serde(rename_all = "snake_case")]
74pub enum FullDocumentMode {
75 #[default]
78 Delta,
79
80 #[serde(rename = "required")]
84 RequirePostImage,
85}
86
87str_enum!(FullDocumentMode, lowercase, ConnectorError, "unknown full document mode",
88 Delta => "delta";
89 RequirePostImage => "required"
90);
91
92#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
94#[serde(deny_unknown_fields)]
95pub struct MongoDbSourceConfig {
96 pub connection_uri: String,
98
99 pub database: String,
101
102 pub collection: String,
104
105 pub full_document_mode: FullDocumentMode,
107
108 #[serde(default)]
110 pub pipeline: Vec<serde_json::Value>,
111
112 #[serde(default = "default_max_buffered_bytes")]
114 pub max_buffered_bytes: usize,
115}
116
117fn default_max_buffered_bytes() -> usize {
118 DEFAULT_MAX_BUFFERED_BYTES
119}
120
121impl Default for MongoDbSourceConfig {
122 fn default() -> Self {
123 Self {
124 connection_uri: "mongodb://localhost:27017".to_string(),
125 database: String::new(),
126 collection: String::new(),
127 full_document_mode: FullDocumentMode::default(),
128 pipeline: Vec::new(),
129 max_buffered_bytes: default_max_buffered_bytes(),
130 }
131 }
132}
133
134impl MongoDbSourceConfig {
135 #[must_use]
137 pub fn new(connection_uri: &str, database: &str, collection: &str) -> Self {
138 Self {
139 connection_uri: connection_uri.to_string(),
140 database: database.to_string(),
141 collection: collection.to_string(),
142 ..Self::default()
143 }
144 }
145
146 pub(crate) fn reader_channel_capacity(&self) -> usize {
148 (self.max_buffered_bytes / ESTIMATED_BUFFERED_ITEM_BYTES).clamp(1, MAX_READER_CHANNEL_ITEMS)
149 }
150
151 pub(crate) fn cursor_batch_size(&self) -> u32 {
153 u32::try_from(self.reader_channel_capacity())
154 .unwrap_or(MAX_CURSOR_BATCH_ITEMS)
155 .min(MAX_CURSOR_BATCH_ITEMS)
156 }
157
158 pub fn validate(&self) -> Result<(), ConnectorError> {
164 crate::config::require_non_empty(&self.connection_uri, "connection_uri")?;
165 crate::config::require_non_empty(&self.database, "database")?;
166 crate::config::require_non_empty(&self.collection, "collection")?;
167 if self.collection == "*" {
168 return Err(ConnectorError::ConfigurationError(
169 "MongoDB CDC collection must name one fixed collection; database-wide wildcard \
170 streams cannot be bound to an immutable collection UUID"
171 .into(),
172 ));
173 }
174
175 if !(MIN_BUFFERED_BYTES..=MAX_BUFFERED_BYTES).contains(&self.max_buffered_bytes) {
176 return Err(ConnectorError::ConfigurationError(format!(
177 "max.buffered.bytes must be between {MIN_BUFFERED_BYTES} and \
178 {MAX_BUFFERED_BYTES}"
179 )));
180 }
181
182 validate_pipeline(&self.pipeline)?;
183
184 Ok(())
185 }
186
187 pub fn from_config(config: &ConnectorConfig) -> Result<Self, ConnectorError> {
193 if let Some(key) = REMOVED_SOURCE_CONFIG_KEYS
194 .iter()
195 .find(|key| config.get(key).is_some())
196 {
197 let reason = if *key == "max.buffered.events" {
198 "retained ownership is bounded by max.buffered.bytes instead"
199 } else {
200 "the connector did not execute it"
201 };
202 return Err(ConnectorError::ConfigurationError(format!(
203 "MongoDB CDC property '{key}' is not supported: {reason}"
204 )));
205 }
206
207 config.reject_unknown_properties(SOURCE_CONFIG_KEYS, "MongoDB CDC")?;
208
209 let mut cfg = Self {
210 connection_uri: config.require("connection.uri")?.to_string(),
211 database: config.require("database")?.to_string(),
212 collection: config.require("collection")?.to_string(),
213 ..Self::default()
214 };
215
216 if let Some(mode) = config.get_parsed::<FullDocumentMode>("full.document.mode")? {
217 cfg.full_document_mode = mode;
218 }
219 if let Some(pipeline) = config.get("pipeline") {
220 cfg.pipeline = parse_pipeline_property(pipeline)?;
221 }
222 if let Some(max) = config.get_parsed::<usize>("max.buffered.bytes")? {
223 cfg.max_buffered_bytes = max;
224 }
225
226 cfg.validate()?;
227 Ok(cfg)
228 }
229}
230
231fn canonicalize_json(value: serde_json::Value) -> serde_json::Value {
232 match value {
233 serde_json::Value::Array(values) => {
234 serde_json::Value::Array(values.into_iter().map(canonicalize_json).collect())
235 }
236 serde_json::Value::Object(object) => {
237 let mut fields: Vec<_> = object.into_iter().collect();
238 fields.sort_unstable_by(|left, right| left.0.cmp(&right.0));
239 serde_json::Value::Object(
240 fields
241 .into_iter()
242 .map(|(key, value)| (key, canonicalize_json(value)))
243 .collect(),
244 )
245 }
246 scalar => scalar,
247 }
248}
249
250pub(crate) fn canonical_pipeline_json(pipeline: &[serde_json::Value]) -> String {
251 let canonical = canonicalize_json(serde_json::Value::Array(pipeline.to_vec()));
252 serde_json::to_string(&canonical).expect("serde_json::Value serialization cannot fail")
253}
254
255fn normalized_pipeline(
256 pipeline: &[serde_json::Value],
257) -> Result<Vec<serde_json::Value>, ConnectorError> {
258 if pipeline.len() > MAX_PIPELINE_STAGES {
259 return Err(ConnectorError::ConfigurationError(format!(
260 "MongoDB CDC pipeline has {} stages; the maximum is {MAX_PIPELINE_STAGES}",
261 pipeline.len()
262 )));
263 }
264
265 let encoded = serde_json::to_vec(pipeline).map_err(|error| {
266 ConnectorError::ConfigurationError(format!(
267 "MongoDB CDC pipeline cannot be encoded as JSON: {error}"
268 ))
269 })?;
270 if encoded.len() > MAX_PIPELINE_JSON_BYTES {
271 return Err(ConnectorError::ConfigurationError(format!(
272 "MongoDB CDC pipeline is {} bytes; the maximum is {MAX_PIPELINE_JSON_BYTES}",
273 encoded.len()
274 )));
275 }
276
277 let canonical: Vec<_> = pipeline.iter().cloned().map(canonicalize_json).collect();
278 for (i, stage) in canonical.iter().enumerate() {
279 let obj = stage.as_object().ok_or_else(|| {
280 ConnectorError::ConfigurationError(format!("pipeline stage {i} must be a JSON object"))
281 })?;
282 if obj.len() != 1 || !obj.contains_key("$match") {
283 return Err(ConnectorError::ConfigurationError(format!(
284 "pipeline stage {i} is unsafe: MongoDB CDC only supports $match stages because \
285 projection/replacement stages can alter the resume token"
286 )));
287 }
288 if !obj["$match"].is_object() {
289 return Err(ConnectorError::ConfigurationError(format!(
290 "pipeline stage {i} must contain a JSON object as $match"
291 )));
292 }
293 mongodb::bson::to_document(stage).map_err(|error| {
294 ConnectorError::ConfigurationError(format!(
295 "pipeline stage {i} cannot be represented as BSON: {error}"
296 ))
297 })?;
298 }
299
300 let canonical_bytes = canonical_pipeline_json(&canonical).len();
301 if canonical_bytes > MAX_PIPELINE_JSON_BYTES {
302 return Err(ConnectorError::ConfigurationError(format!(
303 "canonical MongoDB CDC pipeline is {canonical_bytes} bytes; the maximum is \
304 {MAX_PIPELINE_JSON_BYTES}"
305 )));
306 }
307 Ok(canonical)
308}
309
310fn parse_pipeline_property(raw: &str) -> Result<Vec<serde_json::Value>, ConnectorError> {
311 if raw.len() > MAX_PIPELINE_JSON_BYTES {
312 return Err(ConnectorError::ConfigurationError(format!(
313 "MongoDB CDC pipeline property is {} bytes; the maximum is \
314 {MAX_PIPELINE_JSON_BYTES}",
315 raw.len()
316 )));
317 }
318 let value: serde_json::Value = serde_json::from_str(raw).map_err(|error| {
319 ConnectorError::ConfigurationError(format!(
320 "MongoDB CDC pipeline must be valid JSON: {error}"
321 ))
322 })?;
323 let serde_json::Value::Array(pipeline) = value else {
324 return Err(ConnectorError::ConfigurationError(
325 "MongoDB CDC pipeline must be a JSON array".into(),
326 ));
327 };
328 normalized_pipeline(&pipeline)
329}
330
331fn validate_pipeline(pipeline: &[serde_json::Value]) -> Result<(), ConnectorError> {
333 normalized_pipeline(pipeline).map(|_| ())
334}
335
336impl MongoDbSourceConfig {
337 pub(crate) fn normalize_pipeline(&mut self) -> Result<(), ConnectorError> {
338 self.pipeline = normalized_pipeline(&self.pipeline)?;
339 Ok(())
340 }
341}
342
343#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
345#[serde(deny_unknown_fields)]
346pub struct MongoDbSinkConfig {
347 pub connection_uri: String,
349
350 pub database: String,
352
353 pub collection: String,
355
356 #[serde(default)]
358 pub collection_kind: CollectionKind,
359
360 #[serde(default)]
362 pub write_mode: WriteMode,
363
364 #[serde(default = "default_flush_interval_ms")]
366 pub flush_interval_ms: u64,
367}
368
369fn default_flush_interval_ms() -> u64 {
370 250
371}
372
373impl Default for MongoDbSinkConfig {
374 fn default() -> Self {
375 Self {
376 connection_uri: "mongodb://localhost:27017".to_string(),
377 database: String::new(),
378 collection: String::new(),
379 collection_kind: CollectionKind::default(),
380 write_mode: WriteMode::default(),
381 flush_interval_ms: default_flush_interval_ms(),
382 }
383 }
384}
385
386impl MongoDbSinkConfig {
387 #[must_use]
389 pub fn new(connection_uri: &str, database: &str, collection: &str) -> Self {
390 Self {
391 connection_uri: connection_uri.to_string(),
392 database: database.to_string(),
393 collection: collection.to_string(),
394 ..Self::default()
395 }
396 }
397
398 #[must_use]
400 pub fn flush_interval(&self) -> Duration {
401 Duration::from_millis(self.flush_interval_ms)
402 }
403
404 pub fn validate(&self) -> Result<(), ConnectorError> {
410 crate::config::require_non_empty(&self.connection_uri, "connection_uri")?;
411 crate::config::require_non_empty(&self.database, "database")?;
412 crate::config::require_non_empty(&self.collection, "collection")?;
413 if self.collection == "*" {
414 return Err(ConnectorError::ConfigurationError(
415 "MongoDB sink collection must name one fixed destination".into(),
416 ));
417 }
418
419 if self.flush_interval_ms == 0 {
420 return Err(ConnectorError::ConfigurationError(
421 "flush_interval_ms must be > 0".to_string(),
422 ));
423 }
424 if let WriteMode::Upsert { key_fields } = &self.write_mode {
425 let mut unique = std::collections::HashSet::with_capacity(key_fields.len());
426 for key in key_fields {
427 if key.trim().is_empty() {
428 return Err(ConnectorError::ConfigurationError(
429 "write.mode.key_fields must not contain empty fields".to_string(),
430 ));
431 }
432 if matches!(key.as_str(), "_op" | "__weight") {
433 return Err(ConnectorError::ConfigurationError(format!(
434 "write.mode.key_fields cannot use engine metadata field '{key}'"
435 )));
436 }
437 if key.starts_with('$') || key.contains('.') {
438 return Err(ConnectorError::ConfigurationError(format!(
439 "write.mode.key_fields entry '{key}' must be a top-level MongoDB field and cannot start with '$' or contain '.'"
440 )));
441 }
442 if !unique.insert(key.as_str()) {
443 return Err(ConnectorError::ConfigurationError(format!(
444 "write.mode.key_fields contains duplicate field '{key}'"
445 )));
446 }
447 }
448 if unique.is_empty() {
449 return Err(ConnectorError::ConfigurationError(
450 "write.mode.key_fields must contain at least one field".to_string(),
451 ));
452 }
453 }
454
455 if let CollectionKind::TimeSeries(time_series) = &self.collection_kind {
457 time_series.validate()?;
458 super::write_model::validate_timeseries_write_mode(&self.write_mode)?;
459 }
460
461 Ok(())
462 }
463
464 pub fn from_config(config: &ConnectorConfig) -> Result<Self, ConnectorError> {
470 if let Some(key) = REMOVED_SINK_CONFIG_KEYS
471 .iter()
472 .find(|key| config.get(key).is_some())
473 {
474 let replacement = match *key {
475 "batch.size" => "fixed memory and MongoDB wire limits govern batching",
476 "write_concern.timeout_ms" => {
477 "sink.write.timeout.ms governs the complete write deadline"
478 }
479 _ => unreachable!("removed sink key must have a migration message"),
480 };
481 return Err(ConnectorError::ConfigurationError(format!(
482 "MongoDB sink property '{key}' is not supported; {replacement}"
483 )));
484 }
485 config.reject_unknown_properties(SINK_CONFIG_KEYS, "MongoDB sink")?;
486
487 let mut cfg = Self {
488 connection_uri: config.require("connection.uri")?.to_string(),
489 database: config.require("database")?.to_string(),
490 collection: config.require("collection")?.to_string(),
491 ..Self::default()
492 };
493
494 if let Some(interval) = config.get_parsed::<u64>("flush.interval.ms")? {
495 cfg.flush_interval_ms = interval;
496 }
497 if let Some(mode) = config.get("write.mode") {
498 cfg.write_mode = match mode {
499 "insert" => WriteMode::Insert,
500 "cdc_replay" | "cdc-replay" => WriteMode::CdcReplay,
501 "upsert" => {
502 let keys = config
503 .require("write.mode.key_fields")?
504 .split(',')
505 .map(|s| s.trim().to_string())
506 .collect();
507 WriteMode::Upsert { key_fields: keys }
508 }
509 other => {
510 return Err(ConnectorError::ConfigurationError(format!(
511 "unknown write.mode: {other}"
512 )));
513 }
514 };
515 }
516
517 if config.get("write.mode.key_fields").is_some()
518 && !matches!(&cfg.write_mode, WriteMode::Upsert { .. })
519 {
520 return Err(ConnectorError::ConfigurationError(
521 "MongoDB sink property 'write.mode.key_fields' is only valid for write.mode=upsert"
522 .to_string(),
523 ));
524 }
525 if let Some(time_field) = config.get("timeseries.time_field") {
526 if time_field.trim().is_empty() {
527 return Err(ConnectorError::ConfigurationError(
528 "timeseries.time_field must not be empty".to_string(),
529 ));
530 }
531 let meta_field = config
532 .get("timeseries.meta_field")
533 .map(str::trim)
534 .filter(|value| !value.is_empty())
535 .map(String::from);
536 let granularity = if let Some(gran_str) = config.get("timeseries.granularity") {
537 match gran_str.to_lowercase().as_str() {
538 "seconds" => TimeSeriesGranularity::Seconds,
539 "minutes" => TimeSeriesGranularity::Minutes,
540 "hours" => TimeSeriesGranularity::Hours,
541 "custom" => {
542 let span =
543 config.require_parsed::<u32>("timeseries.bucket_max_span_seconds")?;
544 let rounding =
545 config.require_parsed::<u32>("timeseries.bucket_rounding_seconds")?;
546 TimeSeriesGranularity::custom(span, rounding)?
547 }
548 other => {
549 return Err(ConnectorError::ConfigurationError(format!(
550 "unknown timeseries granularity: {other}"
551 )));
552 }
553 }
554 } else {
555 TimeSeriesGranularity::Seconds
556 };
557
558 let expire_after_seconds =
559 config.get_parsed::<u64>("timeseries.expire_after_seconds")?;
560
561 cfg.collection_kind = CollectionKind::TimeSeries(TimeSeriesConfig {
562 time_field: time_field.to_string(),
563 meta_field,
564 granularity,
565 expire_after_seconds,
566 });
567 } else if let Some(key) = [
568 "timeseries.meta_field",
569 "timeseries.granularity",
570 "timeseries.bucket_max_span_seconds",
571 "timeseries.bucket_rounding_seconds",
572 "timeseries.expire_after_seconds",
573 ]
574 .into_iter()
575 .find(|key| config.get(key).is_some())
576 {
577 return Err(ConnectorError::ConfigurationError(format!(
578 "MongoDB sink property '{key}' requires 'timeseries.time_field'"
579 )));
580 }
581
582 if !matches!(
583 &cfg.collection_kind,
584 CollectionKind::TimeSeries(TimeSeriesConfig {
585 granularity: TimeSeriesGranularity::Custom { .. },
586 ..
587 })
588 ) {
589 if let Some(key) = [
590 "timeseries.bucket_max_span_seconds",
591 "timeseries.bucket_rounding_seconds",
592 ]
593 .into_iter()
594 .find(|key| config.get(key).is_some())
595 {
596 return Err(ConnectorError::ConfigurationError(format!(
597 "MongoDB sink property '{key}' is only valid for timeseries.granularity=custom"
598 )));
599 }
600 }
601
602 cfg.validate()?;
603 Ok(cfg)
604 }
605}
606
607#[cfg(test)]
608mod tests;