1use 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 "output.mode",
32 "snapshot.mode",
33 "full.document.mode",
34 "objectid.columns",
35 "document.json.column",
36 "pipeline",
37 "max.buffered.bytes",
38 "laminar.source.name",
39 "_arrow_schema",
40 "_primary_key_columns",
41];
42
43pub(super) const DEFAULT_MAX_BUFFERED_BYTES: usize = 64 * 1024 * 1024;
44pub(super) const MAX_PIPELINE_STAGES: usize = 64;
45pub(super) const MAX_PIPELINE_JSON_BYTES: usize = 256 * 1024;
46const MIN_BUFFERED_BYTES: usize = 1024 * 1024;
47const MAX_BUFFERED_BYTES: usize = 4 * 1024 * 1024 * 1024;
48const ESTIMATED_BUFFERED_ITEM_BYTES: usize = 64 * 1024;
49const MAX_READER_CHANNEL_ITEMS: usize = 4096;
50const MAX_CURSOR_BATCH_ITEMS: u32 = 1000;
51
52const SINK_CONFIG_KEYS: &[&str] = &[
53 "auto.create",
54 "connection.uri",
55 "database",
56 "collection",
57 "flush.interval.ms",
58 "write.mode",
59 "write.mode.key_fields",
60 "replay.source.namespace",
61 "timeseries.time_field",
62 "timeseries.meta_field",
63 "timeseries.granularity",
64 "timeseries.bucket_max_span_seconds",
65 "timeseries.bucket_rounding_seconds",
66 "timeseries.expire_after_seconds",
67 "delivery.guarantee",
68 "sink.write.timeout.ms",
69 "_arrow_schema",
70];
71
72const REMOVED_SINK_CONFIG_KEYS: &[&str] = &["batch.size", "write_concern.timeout_ms"];
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
79#[serde(rename_all = "snake_case")]
80pub enum FullDocumentMode {
81 #[default]
84 Delta,
85
86 #[serde(rename = "required")]
90 RequirePostImage,
91}
92
93str_enum!(FullDocumentMode, lowercase, ConnectorError, "unknown full document mode",
94 Delta => "delta";
95 RequirePostImage => "required"
96);
97
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
100#[serde(rename_all = "snake_case")]
101pub enum SourceOutputMode {
102 #[default]
104 History,
105 Document,
107}
108
109str_enum!(SourceOutputMode, lowercase, ConnectorError, "unknown output mode",
110 History => "history";
111 Document => "document"
112);
113
114#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
116#[serde(rename_all = "snake_case")]
117pub enum SnapshotMode {
118 #[default]
120 Never,
121 Initial,
123}
124
125str_enum!(SnapshotMode, lowercase, ConnectorError, "unknown snapshot mode",
126 Never => "never";
127 Initial => "initial"
128);
129
130#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
132#[serde(deny_unknown_fields)]
133pub struct MongoDbSourceConfig {
134 pub connection_uri: String,
136
137 pub database: String,
139
140 pub collection: String,
142
143 #[serde(default)]
145 pub output_mode: SourceOutputMode,
146
147 #[serde(default)]
149 pub snapshot_mode: SnapshotMode,
150
151 pub full_document_mode: FullDocumentMode,
153
154 #[serde(default)]
156 pub objectid_columns: Vec<String>,
157
158 #[serde(default)]
160 pub document_json_column: Option<String>,
161
162 #[serde(default)]
164 pub pipeline: Vec<serde_json::Value>,
165
166 #[serde(default = "default_max_buffered_bytes")]
168 pub max_buffered_bytes: usize,
169}
170
171fn default_max_buffered_bytes() -> usize {
172 DEFAULT_MAX_BUFFERED_BYTES
173}
174
175impl Default for MongoDbSourceConfig {
176 fn default() -> Self {
177 Self {
178 connection_uri: "mongodb://localhost:27017".to_string(),
179 database: String::new(),
180 collection: String::new(),
181 output_mode: SourceOutputMode::default(),
182 snapshot_mode: SnapshotMode::default(),
183 full_document_mode: FullDocumentMode::default(),
184 objectid_columns: Vec::new(),
185 document_json_column: None,
186 pipeline: Vec::new(),
187 max_buffered_bytes: default_max_buffered_bytes(),
188 }
189 }
190}
191
192impl MongoDbSourceConfig {
193 #[must_use]
195 pub fn new(connection_uri: &str, database: &str, collection: &str) -> Self {
196 Self {
197 connection_uri: connection_uri.to_string(),
198 database: database.to_string(),
199 collection: collection.to_string(),
200 ..Self::default()
201 }
202 }
203
204 pub(crate) fn reader_channel_capacity(&self) -> usize {
206 (self.max_buffered_bytes / ESTIMATED_BUFFERED_ITEM_BYTES).clamp(1, MAX_READER_CHANNEL_ITEMS)
207 }
208
209 pub(crate) fn cursor_batch_size(&self) -> u32 {
211 u32::try_from(self.reader_channel_capacity())
212 .unwrap_or(MAX_CURSOR_BATCH_ITEMS)
213 .min(MAX_CURSOR_BATCH_ITEMS)
214 }
215
216 pub fn validate(&self) -> Result<(), ConnectorError> {
222 crate::config::require_non_empty(&self.connection_uri, "connection_uri")?;
223 crate::config::require_non_empty(&self.database, "database")?;
224 crate::config::require_non_empty(&self.collection, "collection")?;
225 if self.collection == "*" {
226 return Err(ConnectorError::ConfigurationError(
227 "MongoDB CDC collection must name one fixed collection; database-wide wildcard \
228 streams cannot be bound to an immutable collection UUID"
229 .into(),
230 ));
231 }
232
233 if !(MIN_BUFFERED_BYTES..=MAX_BUFFERED_BYTES).contains(&self.max_buffered_bytes) {
234 return Err(ConnectorError::ConfigurationError(format!(
235 "max.buffered.bytes must be between {MIN_BUFFERED_BYTES} and \
236 {MAX_BUFFERED_BYTES}"
237 )));
238 }
239
240 validate_pipeline(&self.pipeline)?;
241 self.validate_output_shape()
242 }
243
244 fn validate_output_shape(&self) -> Result<(), ConnectorError> {
245 if self.snapshot_mode == SnapshotMode::Initial && !self.pipeline.is_empty() {
246 return Err(ConnectorError::ConfigurationError(
247 "MongoDB CDC snapshot.mode=initial cannot use a change-stream pipeline; its \
248 $match stages filter change events, not the copied documents"
249 .into(),
250 ));
251 }
252 match self.output_mode {
253 SourceOutputMode::History => {
254 if !self.objectid_columns.is_empty() || self.document_json_column.is_some() {
255 return Err(ConnectorError::ConfigurationError(
256 "MongoDB CDC objectid.columns and document.json.column require \
257 output.mode=document"
258 .into(),
259 ));
260 }
261 }
262 SourceOutputMode::Document => {
263 if self.full_document_mode != FullDocumentMode::RequirePostImage {
264 return Err(ConnectorError::ConfigurationError(
265 "MongoDB CDC output.mode=document requires full.document.mode=required: \
266 a keyed put needs the exact post-image of every update"
267 .into(),
268 ));
269 }
270 if !self.pipeline.is_empty() {
271 return Err(ConnectorError::ConfigurationError(
272 "MongoDB CDC output.mode=document cannot use a change-stream pipeline; \
273 filtering changes would leave stale documents in the target"
274 .into(),
275 ));
276 }
277 let mut seen = std::collections::HashSet::new();
278 for column in self
279 .objectid_columns
280 .iter()
281 .chain(self.document_json_column.iter())
282 {
283 if column.trim().is_empty() || !seen.insert(column.as_str()) {
284 return Err(ConnectorError::ConfigurationError(format!(
285 "MongoDB CDC projection column '{column}' is empty or listed twice"
286 )));
287 }
288 }
289 }
290 }
291 Ok(())
292 }
293
294 pub fn from_config(config: &ConnectorConfig) -> Result<Self, ConnectorError> {
300 if let Some(key) = REMOVED_SOURCE_CONFIG_KEYS
301 .iter()
302 .find(|key| config.get(key).is_some())
303 {
304 let reason = if *key == "max.buffered.events" {
305 "retained ownership is bounded by max.buffered.bytes instead"
306 } else {
307 "the connector did not execute it"
308 };
309 return Err(ConnectorError::ConfigurationError(format!(
310 "MongoDB CDC property '{key}' is not supported: {reason}"
311 )));
312 }
313
314 config.reject_unknown_properties(SOURCE_CONFIG_KEYS, "MongoDB CDC")?;
315
316 let mut cfg = Self {
317 connection_uri: config.require("connection.uri")?.to_string(),
318 database: config.require("database")?.to_string(),
319 collection: config.require("collection")?.to_string(),
320 ..Self::default()
321 };
322
323 if let Some(mode) = config.get_parsed::<SourceOutputMode>("output.mode")? {
324 cfg.output_mode = mode;
325 }
326 if let Some(mode) = config.get_parsed::<SnapshotMode>("snapshot.mode")? {
327 cfg.snapshot_mode = mode;
328 }
329 if let Some(mode) = config.get_parsed::<FullDocumentMode>("full.document.mode")? {
330 cfg.full_document_mode = mode;
331 }
332 if let Some(columns) = config.get("objectid.columns") {
333 cfg.objectid_columns = columns.split(',').map(|c| c.trim().to_string()).collect();
334 }
335 cfg.document_json_column = config
336 .get("document.json.column")
337 .map(|column| column.trim().to_string());
338 if let Some(pipeline) = config.get("pipeline") {
339 cfg.pipeline = parse_pipeline_property(pipeline)?;
340 }
341 if let Some(max) = config.get_parsed::<usize>("max.buffered.bytes")? {
342 cfg.max_buffered_bytes = max;
343 }
344
345 cfg.validate()?;
346 Ok(cfg)
347 }
348}
349
350fn canonicalize_json(value: serde_json::Value) -> serde_json::Value {
351 match value {
352 serde_json::Value::Array(values) => {
353 serde_json::Value::Array(values.into_iter().map(canonicalize_json).collect())
354 }
355 serde_json::Value::Object(object) => {
356 let mut fields: Vec<_> = object.into_iter().collect();
357 fields.sort_unstable_by(|left, right| left.0.cmp(&right.0));
358 serde_json::Value::Object(
359 fields
360 .into_iter()
361 .map(|(key, value)| (key, canonicalize_json(value)))
362 .collect(),
363 )
364 }
365 scalar => scalar,
366 }
367}
368
369pub(crate) fn canonical_pipeline_json(pipeline: &[serde_json::Value]) -> String {
370 let canonical = canonicalize_json(serde_json::Value::Array(pipeline.to_vec()));
371 serde_json::to_string(&canonical).expect("serde_json::Value serialization cannot fail")
372}
373
374fn normalized_pipeline(
375 pipeline: &[serde_json::Value],
376) -> Result<Vec<serde_json::Value>, ConnectorError> {
377 if pipeline.len() > MAX_PIPELINE_STAGES {
378 return Err(ConnectorError::ConfigurationError(format!(
379 "MongoDB CDC pipeline has {} stages; the maximum is {MAX_PIPELINE_STAGES}",
380 pipeline.len()
381 )));
382 }
383
384 let encoded = serde_json::to_vec(pipeline).map_err(|error| {
385 ConnectorError::ConfigurationError(format!(
386 "MongoDB CDC pipeline cannot be encoded as JSON: {error}"
387 ))
388 })?;
389 if encoded.len() > MAX_PIPELINE_JSON_BYTES {
390 return Err(ConnectorError::ConfigurationError(format!(
391 "MongoDB CDC pipeline is {} bytes; the maximum is {MAX_PIPELINE_JSON_BYTES}",
392 encoded.len()
393 )));
394 }
395
396 let canonical: Vec<_> = pipeline.iter().cloned().map(canonicalize_json).collect();
397 for (i, stage) in canonical.iter().enumerate() {
398 let obj = stage.as_object().ok_or_else(|| {
399 ConnectorError::ConfigurationError(format!("pipeline stage {i} must be a JSON object"))
400 })?;
401 if obj.len() != 1 || !obj.contains_key("$match") {
402 return Err(ConnectorError::ConfigurationError(format!(
403 "pipeline stage {i} is unsafe: MongoDB CDC only supports $match stages because \
404 projection/replacement stages can alter the resume token"
405 )));
406 }
407 if !obj["$match"].is_object() {
408 return Err(ConnectorError::ConfigurationError(format!(
409 "pipeline stage {i} must contain a JSON object as $match"
410 )));
411 }
412 mongodb::bson::to_document(stage).map_err(|error| {
413 ConnectorError::ConfigurationError(format!(
414 "pipeline stage {i} cannot be represented as BSON: {error}"
415 ))
416 })?;
417 }
418
419 let canonical_bytes = canonical_pipeline_json(&canonical).len();
420 if canonical_bytes > MAX_PIPELINE_JSON_BYTES {
421 return Err(ConnectorError::ConfigurationError(format!(
422 "canonical MongoDB CDC pipeline is {canonical_bytes} bytes; the maximum is \
423 {MAX_PIPELINE_JSON_BYTES}"
424 )));
425 }
426 Ok(canonical)
427}
428
429fn parse_pipeline_property(raw: &str) -> Result<Vec<serde_json::Value>, ConnectorError> {
430 if raw.len() > MAX_PIPELINE_JSON_BYTES {
431 return Err(ConnectorError::ConfigurationError(format!(
432 "MongoDB CDC pipeline property is {} bytes; the maximum is \
433 {MAX_PIPELINE_JSON_BYTES}",
434 raw.len()
435 )));
436 }
437 let value: serde_json::Value = serde_json::from_str(raw).map_err(|error| {
438 ConnectorError::ConfigurationError(format!(
439 "MongoDB CDC pipeline must be valid JSON: {error}"
440 ))
441 })?;
442 let serde_json::Value::Array(pipeline) = value else {
443 return Err(ConnectorError::ConfigurationError(
444 "MongoDB CDC pipeline must be a JSON array".into(),
445 ));
446 };
447 normalized_pipeline(&pipeline)
448}
449
450fn validate_pipeline(pipeline: &[serde_json::Value]) -> Result<(), ConnectorError> {
452 normalized_pipeline(pipeline).map(|_| ())
453}
454
455impl MongoDbSourceConfig {
456 pub(crate) fn normalize_pipeline(&mut self) -> Result<(), ConnectorError> {
457 self.pipeline = normalized_pipeline(&self.pipeline)?;
458 Ok(())
459 }
460}
461
462fn validate_sink_properties(config: &ConnectorConfig) -> Result<(), ConnectorError> {
463 if let Some(key) = REMOVED_SINK_CONFIG_KEYS
464 .iter()
465 .find(|key| config.get(key).is_some())
466 {
467 let replacement = match *key {
468 "batch.size" => "fixed memory and MongoDB wire limits govern batching",
469 "write_concern.timeout_ms" => {
470 "sink.write.timeout.ms governs the complete write deadline"
471 }
472 _ => unreachable!("removed sink key must have a migration message"),
473 };
474 return Err(ConnectorError::ConfigurationError(format!(
475 "MongoDB sink property '{key}' is not supported; {replacement}"
476 )));
477 }
478 config.reject_unknown_properties(SINK_CONFIG_KEYS, "MongoDB sink")?;
479
480 Ok(())
481}
482
483fn replay_source_namespace(
484 config: &ConnectorConfig,
485 write_mode: &WriteMode,
486) -> Result<Option<String>, ConnectorError> {
487 let Some(namespace) = config.get("replay.source.namespace") else {
488 return Ok(None);
489 };
490 if !matches!(write_mode, WriteMode::CdcReplay) {
491 return Err(ConnectorError::ConfigurationError(
492 "MongoDB sink property 'replay.source.namespace' is only valid for \
493 write.mode=cdc_replay"
494 .into(),
495 ));
496 }
497 match namespace.split_once('.') {
498 Some((database, collection)) if !database.is_empty() && !collection.is_empty() => {
499 Ok(Some(namespace.to_string()))
500 }
501 _ => Err(ConnectorError::ConfigurationError(format!(
502 "replay.source.namespace '{namespace}' must be '<database>.<collection>'"
503 ))),
504 }
505}
506
507#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
509#[serde(deny_unknown_fields)]
510pub struct MongoDbSinkConfig {
511 pub connection_uri: String,
513
514 pub database: String,
516
517 pub collection: String,
519
520 #[serde(default)]
522 pub collection_kind: CollectionKind,
523
524 #[serde(default)]
526 pub auto_create: bool,
527
528 #[serde(default)]
530 pub write_mode: WriteMode,
531
532 #[serde(default = "default_flush_interval_ms")]
534 pub flush_interval_ms: u64,
535
536 #[serde(default)]
539 pub replay_source_namespace: Option<String>,
540}
541
542fn default_flush_interval_ms() -> u64 {
543 250
544}
545
546impl Default for MongoDbSinkConfig {
547 fn default() -> Self {
548 Self {
549 connection_uri: "mongodb://localhost:27017".to_string(),
550 database: String::new(),
551 collection: String::new(),
552 collection_kind: CollectionKind::default(),
553 auto_create: false,
554 write_mode: WriteMode::default(),
555 flush_interval_ms: default_flush_interval_ms(),
556 replay_source_namespace: None,
557 }
558 }
559}
560
561impl MongoDbSinkConfig {
562 #[must_use]
564 pub fn new(connection_uri: &str, database: &str, collection: &str) -> Self {
565 Self {
566 connection_uri: connection_uri.to_string(),
567 database: database.to_string(),
568 collection: collection.to_string(),
569 ..Self::default()
570 }
571 }
572
573 #[must_use]
575 pub fn flush_interval(&self) -> Duration {
576 Duration::from_millis(self.flush_interval_ms)
577 }
578
579 pub fn validate(&self) -> Result<(), ConnectorError> {
585 crate::config::require_non_empty(&self.connection_uri, "connection_uri")?;
586 crate::config::require_non_empty(&self.database, "database")?;
587 crate::config::require_non_empty(&self.collection, "collection")?;
588 if self.collection == "*" {
589 return Err(ConnectorError::ConfigurationError(
590 "MongoDB sink collection must name one fixed destination".into(),
591 ));
592 }
593
594 if self.flush_interval_ms == 0 {
595 return Err(ConnectorError::ConfigurationError(
596 "flush_interval_ms must be > 0".to_string(),
597 ));
598 }
599 if let WriteMode::Upsert { key_fields } = &self.write_mode {
600 let mut unique = std::collections::HashSet::with_capacity(key_fields.len());
601 for key in key_fields {
602 if key.trim().is_empty() {
603 return Err(ConnectorError::ConfigurationError(
604 "write.mode.key_fields must not contain empty fields".to_string(),
605 ));
606 }
607 if matches!(key.as_str(), "_op" | "__weight") {
608 return Err(ConnectorError::ConfigurationError(format!(
609 "write.mode.key_fields cannot use engine metadata field '{key}'"
610 )));
611 }
612 if key.starts_with('$') || key.contains('.') {
613 return Err(ConnectorError::ConfigurationError(format!(
614 "write.mode.key_fields entry '{key}' must be a top-level MongoDB field and cannot start with '$' or contain '.'"
615 )));
616 }
617 if !unique.insert(key.as_str()) {
618 return Err(ConnectorError::ConfigurationError(format!(
619 "write.mode.key_fields contains duplicate field '{key}'"
620 )));
621 }
622 }
623 if unique.is_empty() {
624 return Err(ConnectorError::ConfigurationError(
625 "write.mode.key_fields must contain at least one field".to_string(),
626 ));
627 }
628 }
629
630 if let CollectionKind::TimeSeries(time_series) = &self.collection_kind {
632 time_series.validate()?;
633 super::write_model::validate_timeseries_write_mode(&self.write_mode)?;
634 }
635
636 Ok(())
637 }
638
639 pub fn from_config(config: &ConnectorConfig) -> Result<Self, ConnectorError> {
645 validate_sink_properties(config)?;
646
647 let mut cfg = Self {
648 connection_uri: config.require("connection.uri")?.to_string(),
649 database: config.require("database")?.to_string(),
650 collection: config.require("collection")?.to_string(),
651 ..Self::default()
652 };
653
654 cfg.auto_create = config.get_parsed::<bool>("auto.create")?.unwrap_or(false);
655 if let Some(interval) = config.get_parsed::<u64>("flush.interval.ms")? {
656 cfg.flush_interval_ms = interval;
657 }
658 if let Some(mode) = config.get("write.mode") {
659 cfg.write_mode = match mode {
660 "insert" => WriteMode::Insert,
661 "cdc_replay" | "cdc-replay" => WriteMode::CdcReplay,
662 "upsert" => {
663 let keys = config
664 .require("write.mode.key_fields")?
665 .split(',')
666 .map(|s| s.trim().to_string())
667 .collect();
668 WriteMode::Upsert { key_fields: keys }
669 }
670 other => {
671 return Err(ConnectorError::ConfigurationError(format!(
672 "unknown write.mode: {other}"
673 )));
674 }
675 };
676 }
677
678 cfg.replay_source_namespace = replay_source_namespace(config, &cfg.write_mode)?;
679 if config.get("write.mode.key_fields").is_some()
680 && !matches!(&cfg.write_mode, WriteMode::Upsert { .. })
681 {
682 return Err(ConnectorError::ConfigurationError(
683 "MongoDB sink property 'write.mode.key_fields' is only valid for write.mode=upsert"
684 .to_string(),
685 ));
686 }
687 if let Some(time_field) = config.get("timeseries.time_field") {
688 if time_field.trim().is_empty() {
689 return Err(ConnectorError::ConfigurationError(
690 "timeseries.time_field must not be empty".to_string(),
691 ));
692 }
693 let meta_field = config
694 .get("timeseries.meta_field")
695 .map(str::trim)
696 .filter(|value| !value.is_empty())
697 .map(String::from);
698 let granularity = if let Some(gran_str) = config.get("timeseries.granularity") {
699 match gran_str.to_lowercase().as_str() {
700 "seconds" => TimeSeriesGranularity::Seconds,
701 "minutes" => TimeSeriesGranularity::Minutes,
702 "hours" => TimeSeriesGranularity::Hours,
703 "custom" => {
704 let span =
705 config.require_parsed::<u32>("timeseries.bucket_max_span_seconds")?;
706 let rounding =
707 config.require_parsed::<u32>("timeseries.bucket_rounding_seconds")?;
708 TimeSeriesGranularity::custom(span, rounding)?
709 }
710 other => {
711 return Err(ConnectorError::ConfigurationError(format!(
712 "unknown timeseries granularity: {other}"
713 )));
714 }
715 }
716 } else {
717 TimeSeriesGranularity::Seconds
718 };
719
720 let expire_after_seconds =
721 config.get_parsed::<u64>("timeseries.expire_after_seconds")?;
722
723 cfg.collection_kind = CollectionKind::TimeSeries(TimeSeriesConfig {
724 time_field: time_field.to_string(),
725 meta_field,
726 granularity,
727 expire_after_seconds,
728 });
729 } else if let Some(key) = [
730 "timeseries.meta_field",
731 "timeseries.granularity",
732 "timeseries.bucket_max_span_seconds",
733 "timeseries.bucket_rounding_seconds",
734 "timeseries.expire_after_seconds",
735 ]
736 .into_iter()
737 .find(|key| config.get(key).is_some())
738 {
739 return Err(ConnectorError::ConfigurationError(format!(
740 "MongoDB sink property '{key}' requires 'timeseries.time_field'"
741 )));
742 }
743
744 if !matches!(
745 &cfg.collection_kind,
746 CollectionKind::TimeSeries(TimeSeriesConfig {
747 granularity: TimeSeriesGranularity::Custom { .. },
748 ..
749 })
750 ) {
751 if let Some(key) = [
752 "timeseries.bucket_max_span_seconds",
753 "timeseries.bucket_rounding_seconds",
754 ]
755 .into_iter()
756 .find(|key| config.get(key).is_some())
757 {
758 return Err(ConnectorError::ConfigurationError(format!(
759 "MongoDB sink property '{key}' is only valid for timeseries.granularity=custom"
760 )));
761 }
762 }
763
764 cfg.validate()?;
765 Ok(cfg)
766 }
767}
768
769#[cfg(test)]
770mod tests;