1use crate::config::MongoSinkConfig;
4use async_trait::async_trait;
5use faucet_core::FaucetError;
6use futures::StreamExt;
7use mongodb::Client;
8use mongodb::bson::{self, Bson, Document};
9use serde_json::{Map, Value};
10
11const APPLY_CONCURRENCY: usize = 50;
17
18const MAX_COMMIT_RETRIES: usize = 8;
22
23const NAMESPACE_EXISTS_CODE: i32 = 48;
27
28fn json_map_to_bson_filter(map: &Map<String, Value>) -> Result<Document, FaucetError> {
33 MongoSink::value_to_document(&Value::Object(map.clone()))
34}
35
36enum PlannedOp {
39 Upsert(Document, Document),
41 Delete(Document),
43}
44
45enum PreparedWrite {
49 Append(Vec<Document>),
51 Planned(Vec<PlannedOp>),
53}
54
55fn commit_token_filter(scope: &str) -> Document {
64 bson::doc! { "_id": scope }
65}
66
67fn commit_token_doc(scope: &str, token: &str) -> Document {
72 bson::doc! {
73 "_id": scope,
74 faucet_core::idempotency::COMMIT_TOKEN_TOKEN_COL: token,
75 }
76}
77
78fn token_from_commit_doc(doc: &Document) -> Result<String, FaucetError> {
86 match doc.get(faucet_core::idempotency::COMMIT_TOKEN_TOKEN_COL) {
87 Some(Bson::String(s)) => Ok(s.clone()),
88 Some(other) => Err(FaucetError::Sink(format!(
89 "malformed watermark document in '{}': expected a string '{}' field, got {other:?}",
90 faucet_core::idempotency::COMMIT_TOKEN_TABLE,
91 faucet_core::idempotency::COMMIT_TOKEN_TOKEN_COL,
92 ))),
93 None => Err(FaucetError::Sink(format!(
94 "malformed watermark document in '{}': missing the '{}' field",
95 faucet_core::idempotency::COMMIT_TOKEN_TABLE,
96 faucet_core::idempotency::COMMIT_TOKEN_TOKEN_COL,
97 ))),
98 }
99}
100
101fn is_transactions_unsupported(message: &str) -> bool {
114 let m = message.to_ascii_lowercase();
115 m.contains("transaction numbers are only allowed on a replica set member or mongos")
116 || m.contains("does not support retryable writes")
117 || m.contains("transactions are not supported")
118}
119
120fn classify_transaction_error(context: &str, message: &str) -> FaucetError {
126 if is_transactions_unsupported(message) {
127 FaucetError::Sink(format!(
128 "mongodb exactly-once (write_batch_idempotent) requires a replica set or sharded \
129 cluster — transactions are unavailable on a standalone server: {message}"
130 ))
131 } else {
132 FaucetError::Sink(format!("{context}: {message}"))
133 }
134}
135
136fn is_namespace_exists_code(code: Option<i32>) -> bool {
139 code == Some(NAMESPACE_EXISTS_CODE)
140}
141
142fn command_error_code(e: &mongodb::error::Error) -> Option<i32> {
144 match e.kind.as_ref() {
145 mongodb::error::ErrorKind::Command(c) => Some(c.code),
146 _ => None,
147 }
148}
149
150pub struct MongoSink {
155 config: MongoSinkConfig,
156 client: Client,
157 eo_collections_ready: tokio::sync::OnceCell<()>,
163}
164
165impl MongoSink {
166 pub async fn new(config: MongoSinkConfig) -> Result<Self, FaucetError> {
168 faucet_core::validate_batch_size(config.batch_size)?;
169 config.write.validate()?;
173 let client = Client::with_uri_str(&config.connection_uri)
174 .await
175 .map_err(|e| FaucetError::Config(format!("MongoDB connection failed: {e}")))?;
176
177 Ok(Self {
178 config,
179 client,
180 eo_collections_ready: tokio::sync::OnceCell::new(),
181 })
182 }
183
184 fn filter_from_row(row: &Value, key: &[String]) -> Result<Document, FaucetError> {
190 let obj = row
191 .as_object()
192 .ok_or_else(|| FaucetError::Sink("upsert row is not a JSON object".to_string()))?;
193 let mut filter = Map::with_capacity(key.len());
194 for col in key {
195 match obj.get(col) {
196 Some(v) => {
197 filter.insert(col.clone(), v.clone());
198 }
199 None => {
200 return Err(FaucetError::Sink(format!(
201 "upsert row missing key column '{col}' after planning"
202 )));
203 }
204 }
205 }
206 json_map_to_bson_filter(&filter)
207 }
208
209 async fn apply_plan(&self, plan: &faucet_core::WritePlan) -> Result<usize, FaucetError> {
222 let collection = self
223 .client
224 .database(&self.config.database)
225 .collection::<Document>(&self.config.collection);
226
227 use PlannedOp as Op;
230 let ops = self.build_plan_ops(plan)?;
231
232 let applied = ops.len();
233
234 futures::stream::iter(ops.into_iter().map(|op| {
235 let collection = collection.clone();
236 async move {
237 match op {
238 Op::Upsert(filter, replacement) => collection
239 .replace_one(filter, replacement)
240 .upsert(true)
241 .await
242 .map(|_| ())
243 .map_err(|e| {
244 FaucetError::Sink(format!("MongoDB replace_one (upsert) failed: {e}"))
245 }),
246 Op::Delete(filter) => collection
247 .delete_one(filter)
248 .await
249 .map(|_| ())
250 .map_err(|e| FaucetError::Sink(format!("MongoDB delete_one failed: {e}"))),
251 }
252 }
253 }))
254 .buffer_unordered(APPLY_CONCURRENCY)
255 .collect::<Vec<Result<(), FaucetError>>>()
256 .await
257 .into_iter()
258 .collect::<Result<Vec<()>, FaucetError>>()?;
259
260 tracing::info!(
261 applied,
262 upserts = plan.upserts.len(),
263 deletes = plan.deletes.len(),
264 database = %self.config.database,
265 collection = %self.config.collection,
266 "MongoDB upsert/delete write complete"
267 );
268
269 Ok(applied)
270 }
271
272 fn build_plan_ops(&self, plan: &faucet_core::WritePlan) -> Result<Vec<PlannedOp>, FaucetError> {
276 let key = &self.config.write.key;
277 let mut ops: Vec<PlannedOp> = Vec::with_capacity(plan.upserts.len() + plan.deletes.len());
278 for row in &plan.upserts {
279 let filter = Self::filter_from_row(row, key)?;
280 let replacement = Self::value_to_document(row)?;
281 ops.push(PlannedOp::Upsert(filter, replacement));
282 }
283 for kt in &plan.deletes {
284 let filter = json_map_to_bson_filter(&faucet_core::key_to_filter(kt))?;
285 ops.push(PlannedOp::Delete(filter));
286 }
287 Ok(ops)
288 }
289
290 fn commit_token_collection(&self) -> mongodb::Collection<Document> {
293 self.client
294 .database(&self.config.database)
295 .collection::<Document>(faucet_core::idempotency::COMMIT_TOKEN_TABLE)
296 }
297
298 async fn ensure_exactly_once_collections(&self) -> Result<(), FaucetError> {
302 self.eo_collections_ready
303 .get_or_try_init(|| async {
304 let db = self.client.database(&self.config.database);
305 for name in [
306 self.config.collection.as_str(),
307 faucet_core::idempotency::COMMIT_TOKEN_TABLE,
308 ] {
309 match db.create_collection(name).await {
310 Ok(()) => {}
311 Err(e) if is_namespace_exists_code(command_error_code(&e)) => {}
312 Err(e) => {
313 return Err(FaucetError::Sink(format!(
314 "MongoDB create_collection('{name}') failed: {e}"
315 )));
316 }
317 }
318 }
319 Ok(())
320 })
321 .await
322 .map(|_| ())
323 }
324
325 async fn apply_in_transaction(
335 &self,
336 session: &mut mongodb::ClientSession,
337 prepared: PreparedWrite,
338 scope: &str,
339 token: &str,
340 ) -> Result<usize, FaucetError> {
341 let collection = self
342 .client
343 .database(&self.config.database)
344 .collection::<Document>(&self.config.collection);
345
346 let written = match prepared {
347 PreparedWrite::Append(docs) => {
348 let n = docs.len();
349 if !docs.is_empty() {
357 collection
358 .insert_many(docs)
359 .session(&mut *session)
360 .await
361 .map_err(|e| {
362 classify_transaction_error(
363 "MongoDB insert_many (exactly-once) failed",
364 &e.to_string(),
365 )
366 })?;
367 }
368 n
369 }
370 PreparedWrite::Planned(ops) => {
371 let n = ops.len();
372 for op in ops {
373 match op {
374 PlannedOp::Upsert(filter, replacement) => {
375 collection
376 .replace_one(filter, replacement)
377 .upsert(true)
378 .session(&mut *session)
379 .await
380 .map(|_| ())
381 .map_err(|e| {
382 classify_transaction_error(
383 "MongoDB replace_one (exactly-once upsert) failed",
384 &e.to_string(),
385 )
386 })?;
387 }
388 PlannedOp::Delete(filter) => {
389 collection
390 .delete_one(filter)
391 .session(&mut *session)
392 .await
393 .map(|_| ())
394 .map_err(|e| {
395 classify_transaction_error(
396 "MongoDB delete_one (exactly-once) failed",
397 &e.to_string(),
398 )
399 })?;
400 }
401 }
402 }
403 n
404 }
405 };
406
407 self.commit_token_collection()
411 .replace_one(commit_token_filter(scope), commit_token_doc(scope, token))
412 .upsert(true)
413 .session(session)
414 .await
415 .map_err(|e| {
416 classify_transaction_error("MongoDB commit-token upsert failed", &e.to_string())
417 })?;
418
419 Ok(written)
420 }
421
422 fn value_to_document(val: &Value) -> Result<Document, FaucetError> {
426 let bson = bson::to_bson(val)
427 .map_err(|e| FaucetError::Sink(format!("failed to convert JSON to BSON: {e}")))?;
428 match bson {
429 Bson::Document(doc) => Ok(doc),
430 other => Err(FaucetError::Sink(format!(
431 "expected a JSON object, got BSON type: {other:?}"
432 ))),
433 }
434 }
435}
436
437#[async_trait]
438impl faucet_core::Sink for MongoSink {
439 fn config_schema(&self) -> serde_json::Value {
440 serde_json::to_value(faucet_core::schema_for!(MongoSinkConfig))
441 .expect("schema serialization")
442 }
443
444 fn supported_write_modes(&self) -> &'static [faucet_core::WriteMode] {
445 &[
446 faucet_core::WriteMode::Append,
447 faucet_core::WriteMode::Upsert,
448 faucet_core::WriteMode::Delete,
449 ]
450 }
451
452 fn dedups_by_key(&self) -> bool {
453 self.config.write.dedups_by_key()
454 }
455
456 fn dataset_uri(&self) -> String {
457 format!(
458 "{}/{}/{}",
459 faucet_core::redact_uri_credentials(&self.config.connection_uri),
460 self.config.database,
461 self.config.collection
462 )
463 }
464
465 async fn check(
468 &self,
469 ctx: &faucet_core::check::CheckContext,
470 ) -> Result<faucet_core::check::CheckReport, FaucetError> {
471 use faucet_core::check::{CheckReport, Probe};
472
473 let started = std::time::Instant::now();
474 let hint = "check connection_uri / credentials / that the MongoDB server is reachable";
475
476 let db = self.client.database(&self.config.database);
477 let probe =
478 match tokio::time::timeout(ctx.timeout, db.run_command(bson::doc! {"ping": 1})).await {
479 Ok(Ok(_)) => Probe::pass("ping", started.elapsed()),
480 Ok(Err(e)) => Probe::fail_hint("ping", started.elapsed(), e.to_string(), hint),
481 Err(_) => Probe::fail_hint("ping", started.elapsed(), "timed out", hint),
482 };
483 Ok(CheckReport::single(probe))
484 }
485
486 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
495 if records.is_empty() {
496 return Ok(0);
497 }
498
499 if !matches!(self.config.write.write_mode, faucet_core::WriteMode::Append) {
503 let plan = faucet_core::plan_writes(records, &self.config.write);
504 if let Some((idx, msg)) = plan.failed.first() {
505 return Err(FaucetError::Sink(format!(
506 "mongodb {}: row {idx}: {msg}",
507 self.config.write.write_mode.as_str()
508 )));
509 }
510 return self.apply_plan(&plan).await;
511 }
512
513 let collection = self
514 .client
515 .database(&self.config.database)
516 .collection::<Document>(&self.config.collection);
517
518 let effective_chunk = if self.config.batch_size == 0 {
522 records.len()
523 } else {
524 self.config.batch_size
525 };
526
527 let mut total_written = 0usize;
528
529 for chunk in records.chunks(effective_chunk) {
530 let docs: Vec<Document> = chunk
531 .iter()
532 .map(Self::value_to_document)
533 .collect::<Result<Vec<_>, _>>()?;
534
535 let opts = mongodb::options::InsertManyOptions::builder()
536 .ordered(self.config.ordered)
537 .build();
538 collection
539 .insert_many(&docs)
540 .with_options(opts)
541 .await
542 .map_err(|e| FaucetError::Sink(format!("MongoDB insert_many failed: {e}")))?;
543
544 total_written += docs.len();
545 tracing::debug!(batch_size = docs.len(), "MongoDB batch inserted");
546 }
547
548 tracing::info!(
549 records = total_written,
550 database = %self.config.database,
551 collection = %self.config.collection,
552 "MongoDB write complete"
553 );
554
555 Ok(total_written)
556 }
557
558 async fn write_batch_partial(
567 &self,
568 records: &[Value],
569 ) -> Result<Vec<faucet_core::RowOutcome>, FaucetError> {
570 if matches!(self.config.write.write_mode, faucet_core::WriteMode::Append) {
571 self.write_batch(records).await?;
572 return Ok(records.iter().map(|_| Ok(())).collect());
573 }
574
575 let plan = faucet_core::plan_writes(records, &self.config.write);
576 self.apply_plan(&plan).await?;
577
578 let mut outcomes: Vec<faucet_core::RowOutcome> = records.iter().map(|_| Ok(())).collect();
579 for (idx, msg) in &plan.failed {
580 outcomes[*idx] = Err(FaucetError::Sink(format!(
581 "mongodb {}: {msg}",
582 self.config.write.write_mode.as_str()
583 )));
584 }
585 Ok(outcomes)
586 }
587
588 fn supports_idempotent_writes(&self) -> bool {
589 true
590 }
591
592 async fn last_committed_token(&self, scope: &str) -> Result<Option<String>, FaucetError> {
597 let doc = self
598 .commit_token_collection()
599 .find_one(commit_token_filter(scope))
600 .await
601 .map_err(|e| FaucetError::Sink(format!("MongoDB commit-token read failed: {e}")))?;
602 doc.map(|d| token_from_commit_doc(&d)).transpose()
603 }
604
605 async fn write_batch_idempotent(
625 &self,
626 records: &[Value],
627 scope: &str,
628 token: &str,
629 ) -> Result<usize, FaucetError> {
630 self.ensure_exactly_once_collections().await?;
631
632 let prepared = if matches!(self.config.write.write_mode, faucet_core::WriteMode::Append) {
636 PreparedWrite::Append(
637 records
638 .iter()
639 .map(Self::value_to_document)
640 .collect::<Result<Vec<_>, _>>()?,
641 )
642 } else {
643 let plan = faucet_core::plan_writes(records, &self.config.write);
644 if let Some((idx, msg)) = plan.failed.first() {
645 return Err(FaucetError::Sink(format!(
646 "mongodb {}: row {idx}: {msg}",
647 self.config.write.write_mode.as_str()
648 )));
649 }
650 PreparedWrite::Planned(self.build_plan_ops(&plan)?)
651 };
652
653 let mut session = self
654 .client
655 .start_session()
656 .await
657 .map_err(|e| FaucetError::Sink(format!("MongoDB session start failed: {e}")))?;
658 session.start_transaction().await.map_err(|e| {
659 classify_transaction_error("MongoDB transaction start failed", &e.to_string())
660 })?;
661
662 let written = match self
663 .apply_in_transaction(&mut session, prepared, scope, token)
664 .await
665 {
666 Ok(written) => written,
667 Err(e) => {
668 if let Err(abort_err) = session.abort_transaction().await {
671 tracing::debug!(error = %abort_err, "MongoDB abort_transaction failed (best-effort)");
672 }
673 return Err(e);
674 }
675 };
676
677 let mut attempt = 0usize;
682 loop {
683 match session.commit_transaction().await {
684 Ok(()) => break,
685 Err(e)
686 if e.contains_label(mongodb::error::UNKNOWN_TRANSACTION_COMMIT_RESULT)
687 && attempt < MAX_COMMIT_RETRIES =>
688 {
689 attempt += 1;
690 tracing::warn!(
691 attempt,
692 error = %e,
693 "MongoDB commit returned UnknownTransactionCommitResult; retrying commit"
694 );
695 }
696 Err(e) => {
697 return Err(classify_transaction_error(
698 "MongoDB transaction commit failed",
699 &e.to_string(),
700 ));
701 }
702 }
703 }
704
705 tracing::info!(
706 records = written,
707 scope,
708 token,
709 database = %self.config.database,
710 collection = %self.config.collection,
711 "MongoDB exactly-once page committed with watermark"
712 );
713
714 Ok(written)
715 }
716}
717
718#[cfg(test)]
719mod tests {
720 use super::*;
721 use serde_json::json;
722
723 #[test]
728 fn filter_doc_from_key_tuple() {
729 let kt = faucet_core::KeyTuple(vec![
730 ("tenant".to_string(), serde_json::json!("acme")),
731 ("id".to_string(), serde_json::json!(7)),
732 ]);
733 let m = faucet_core::key_to_filter(&kt);
734 assert_eq!(m.get("tenant"), Some(&serde_json::json!("acme")));
735 assert_eq!(m.get("id"), Some(&serde_json::json!(7)));
736 let doc = super::json_map_to_bson_filter(&m).expect("filter converts to bson");
738 assert_eq!(doc.get_str("tenant").unwrap(), "acme");
739 assert_eq!(doc.get_i64("id").unwrap(), 7);
740 }
741
742 #[test]
743 fn filter_from_row_pulls_only_key_columns() {
744 let row = json!({"_id": 5, "name": "a", "extra": true});
745 let doc = MongoSink::filter_from_row(&row, &["_id".to_string()]).expect("filter");
746 assert_eq!(doc.get_i64("_id").unwrap(), 5);
747 assert!(
748 !doc.contains_key("name"),
749 "filter must contain only key columns"
750 );
751 assert!(!doc.contains_key("extra"));
752 }
753
754 #[test]
755 fn value_to_document_object() {
756 let val = json!({"name": "Alice", "age": 30});
757 let doc = MongoSink::value_to_document(&val).unwrap();
758 assert_eq!(doc.get_str("name").unwrap(), "Alice");
759 assert_eq!(doc.get_i64("age").unwrap(), 30);
760 }
761
762 #[test]
763 fn value_to_document_non_object_fails() {
764 let val = json!([1, 2, 3]);
765 let result = MongoSink::value_to_document(&val);
766 assert!(result.is_err());
767 assert!(matches!(result, Err(FaucetError::Sink(_))));
768 }
769
770 #[test]
771 fn value_to_document_string_fails() {
772 let val = json!("not an object");
773 let result = MongoSink::value_to_document(&val);
774 assert!(result.is_err());
775 }
776
777 #[test]
778 fn value_to_document_nested() {
779 let val = json!({"user": {"name": "Bob"}, "tags": ["a", "b"]});
780 let doc = MongoSink::value_to_document(&val).unwrap();
781 let inner = doc.get_document("user").unwrap();
782 assert_eq!(inner.get_str("name").unwrap(), "Bob");
783 }
784
785 #[test]
786 fn value_to_document_empty_object() {
787 let val = json!({});
788 let doc = MongoSink::value_to_document(&val).unwrap();
789 assert!(doc.is_empty());
790 }
791
792 #[test]
793 fn value_to_document_null_fails() {
794 let val = Value::Null;
795 let result = MongoSink::value_to_document(&val);
796 assert!(result.is_err());
797 }
798
799 #[test]
802 fn commit_token_filter_uses_scope_as_id() {
803 let filter = super::commit_token_filter("pipe::row");
804 assert_eq!(filter.len(), 1, "filter must match on _id only");
805 assert_eq!(filter.get_str("_id").unwrap(), "pipe::row");
806 }
807
808 #[test]
809 fn commit_token_doc_shape() {
810 let doc = super::commit_token_doc("pipe::row", "00000000000000000042");
811 assert_eq!(doc.get_str("_id").unwrap(), "pipe::row");
812 assert_eq!(
813 doc.get_str(faucet_core::idempotency::COMMIT_TOKEN_TOKEN_COL)
814 .unwrap(),
815 "00000000000000000042"
816 );
817 assert_eq!(doc.len(), 2, "watermark doc carries only _id + token");
818 }
819
820 #[test]
821 fn commit_token_doc_token_is_opaque() {
822 let token = r##"00000000000000000007#{"lsn":"0/16B3748"}"##;
824 let doc = super::commit_token_doc("s", token);
825 assert_eq!(
826 doc.get_str(faucet_core::idempotency::COMMIT_TOKEN_TOKEN_COL)
827 .unwrap(),
828 token
829 );
830 }
831
832 #[test]
833 fn token_from_commit_doc_reads_string_token() {
834 let doc = super::commit_token_doc("s", "tok-1");
835 assert_eq!(super::token_from_commit_doc(&doc).unwrap(), "tok-1");
836 }
837
838 #[test]
839 fn token_from_commit_doc_missing_field_is_typed_error() {
840 let doc = bson::doc! { "_id": "s" };
841 let err = super::token_from_commit_doc(&doc).unwrap_err();
842 match err {
843 FaucetError::Sink(m) => assert!(m.contains("missing"), "got: {m}"),
844 other => panic!("expected Sink error, got {other:?}"),
845 }
846 }
847
848 #[test]
849 fn token_from_commit_doc_non_string_token_is_typed_error() {
850 let doc = bson::doc! { "_id": "s", "token": 42_i64 };
851 let err = super::token_from_commit_doc(&doc).unwrap_err();
852 match err {
853 FaucetError::Sink(m) => assert!(m.contains("expected a string"), "got: {m}"),
854 other => panic!("expected Sink error, got {other:?}"),
855 }
856 }
857
858 #[test]
859 fn transactions_unsupported_detects_standalone_message() {
860 assert!(super::is_transactions_unsupported(
862 "Command failed: Transaction numbers are only allowed on a replica set member or mongos"
863 ));
864 assert!(super::is_transactions_unsupported(
866 "TRANSACTION NUMBERS ARE ONLY ALLOWED ON A REPLICA SET MEMBER OR MONGOS"
867 ));
868 assert!(super::is_transactions_unsupported(
871 "Kind: Command failed: Error code 20 (IllegalOperation): This MongoDB deployment \
872 does not support retryable writes. Please add retryWrites=false to your \
873 connection string."
874 ));
875 assert!(super::is_transactions_unsupported(
877 "Transactions are not supported by this deployment"
878 ));
879 }
880
881 #[test]
882 fn transactions_unsupported_ignores_other_errors() {
883 assert!(!super::is_transactions_unsupported("duplicate key error"));
884 assert!(!super::is_transactions_unsupported(
885 "connection refused: mongodb://localhost:27017"
886 ));
887 assert!(!super::is_transactions_unsupported(""));
888 }
889
890 #[test]
891 fn classify_transaction_error_maps_standalone_to_replica_set_message() {
892 let orig = "Transaction numbers are only allowed on a replica set member or mongos";
893 let err = super::classify_transaction_error("MongoDB insert_many failed", orig);
894 match err {
895 FaucetError::Sink(m) => {
896 assert!(
897 m.contains("requires a replica set or sharded cluster"),
898 "got: {m}"
899 );
900 assert!(m.contains("write_batch_idempotent"), "got: {m}");
901 assert!(m.contains(orig), "original error must be preserved: {m}");
902 }
903 other => panic!("expected Sink error, got {other:?}"),
904 }
905 }
906
907 #[test]
908 fn classify_transaction_error_keeps_context_for_other_errors() {
909 let err = super::classify_transaction_error("MongoDB commit failed", "network timeout");
910 match err {
911 FaucetError::Sink(m) => {
912 assert_eq!(m, "MongoDB commit failed: network timeout");
913 }
914 other => panic!("expected Sink error, got {other:?}"),
915 }
916 }
917
918 #[test]
919 fn namespace_exists_code_predicate() {
920 assert!(super::is_namespace_exists_code(Some(48)));
921 assert!(!super::is_namespace_exists_code(Some(20)));
922 assert!(!super::is_namespace_exists_code(None));
923 }
924
925 #[tokio::test]
926 async fn new_rejects_out_of_range_batch_size() {
927 let mut config = MongoSinkConfig::new("mongodb://localhost:27017", "db", "c");
928 config.batch_size = faucet_core::MAX_BATCH_SIZE + 1;
929 match MongoSink::new(config).await {
930 Err(faucet_core::FaucetError::Config(m)) => {
931 assert!(m.contains("batch_size"), "got: {m}")
932 }
933 _ => panic!("expected a batch_size Config error"),
934 }
935 }
936}