1use crate::config::{MongoCdcSourceConfig, StartFrom};
4use crate::envelope::to_envelope;
5use crate::state::{Bookmark, state_key};
6use async_trait::async_trait;
7use faucet_core::{FaucetError, Source, Stream, StreamPage};
8use mongodb::Client;
9use mongodb::bson::{self, Document, Timestamp};
10use mongodb::change_stream::event::ResumeToken;
11use mongodb::options::{FullDocumentBeforeChangeType, FullDocumentType};
12use serde_json::Value;
13use std::collections::HashMap;
14use std::pin::Pin;
15use tokio::sync::Mutex;
16
17#[derive(Clone, Debug, PartialEq)]
19pub(crate) enum StartPosition {
20 Now,
22 AtOperationTime(Timestamp),
24 ResumeAfter(ResumeToken),
26}
27
28pub(crate) fn resolve_start(
33 start_from: &StartFrom,
34 pending: Option<&Bookmark>,
35) -> Result<StartPosition, FaucetError> {
36 match start_from {
37 StartFrom::ResumeToken { token } => {
38 let b = Bookmark {
39 resume_token: token.clone(),
40 };
41 Ok(StartPosition::ResumeAfter(b.to_token()?))
42 }
43 StartFrom::Timestamp { timestamp_secs } => Ok(StartPosition::AtOperationTime(Timestamp {
44 time: *timestamp_secs,
45 increment: 0,
46 })),
47 StartFrom::Now | StartFrom::Earliest => {
48 if let Some(b) = pending {
49 Ok(StartPosition::ResumeAfter(b.to_token()?))
50 } else if matches!(start_from, StartFrom::Earliest) {
51 Ok(StartPosition::AtOperationTime(Timestamp {
53 time: 1,
54 increment: 1,
55 }))
56 } else {
57 Ok(StartPosition::Now)
58 }
59 }
60 }
61}
62
63pub struct MongoCdcSource {
65 config: MongoCdcSourceConfig,
66 client: Client,
67 state_key_value: String,
68 pending_bookmark: Mutex<Option<Bookmark>>,
69}
70
71impl MongoCdcSource {
72 pub async fn new(config: MongoCdcSourceConfig) -> Result<Self, FaucetError> {
74 config.validate()?;
75 let client = Client::with_uri_str(&config.connection_uri)
76 .await
77 .map_err(|e| FaucetError::Source(format!("MongoDB connection failed: {e}")))?;
78 ensure_changestream_capable(&client).await?;
79 if config.full_document_before_change != crate::config::FullDocumentBeforeChange::Off {
80 tracing::warn!(
81 "mongodb-cdc: full_document_before_change requires MongoDB 6.0+ and the \
82 collection's changeStreamPreAndPostImages enabled; the server will error \
83 at stream open if unavailable"
84 );
85 }
86 let state_key_value = state_key(&config.scope);
87 Ok(Self {
88 config,
89 client,
90 state_key_value,
91 pending_bookmark: Mutex::new(None),
92 })
93 }
94}
95
96async fn ensure_changestream_capable(client: &Client) -> Result<(), FaucetError> {
98 let hello = client
99 .database("admin")
100 .run_command(bson::doc! { "hello": 1 })
101 .await
102 .map_err(|e| FaucetError::Source(format!("mongodb-cdc hello failed: {e}")))?;
103 if is_changestream_topology(&hello) {
104 Ok(())
105 } else {
106 Err(FaucetError::Source(
107 "mongodb-cdc: Change Streams require a replica set or sharded cluster; \
108 the target appears to be a standalone mongod"
109 .into(),
110 ))
111 }
112}
113
114pub(crate) fn is_changestream_topology(hello: &Document) -> bool {
116 hello.get_str("setName").is_ok()
117 || hello
118 .get_str("msg")
119 .map(|m| m == "isdbgrid")
120 .unwrap_or(false)
121}
122
123pub(crate) fn full_document_type(fd: crate::config::FullDocument) -> Option<FullDocumentType> {
125 use crate::config::FullDocument as F;
126 match fd {
127 F::Off => None,
128 F::WhenAvailable => Some(FullDocumentType::WhenAvailable),
129 F::Required => Some(FullDocumentType::Required),
130 F::UpdateLookup => Some(FullDocumentType::UpdateLookup),
131 }
132}
133
134pub(crate) fn full_document_before_type(
136 fd: crate::config::FullDocumentBeforeChange,
137) -> Option<FullDocumentBeforeChangeType> {
138 use crate::config::FullDocumentBeforeChange as F;
139 match fd {
140 F::Off => None,
141 F::WhenAvailable => Some(FullDocumentBeforeChangeType::WhenAvailable),
142 F::Required => Some(FullDocumentBeforeChangeType::Required),
143 }
144}
145
146pub(crate) fn build_pipeline(config: &MongoCdcSourceConfig) -> Result<Vec<Document>, FaucetError> {
148 let mut pipeline: Vec<Document> = Vec::new();
149 if !config.operation_types.is_empty() {
150 pipeline.push(bson::doc! {
151 "$match": { "operationType": { "$in": config.operation_types.clone() } }
152 });
153 }
154 for (i, stage) in config.aggregation_pipeline.iter().enumerate() {
155 let b = bson::to_bson(stage)
156 .map_err(|e| FaucetError::Config(format!("mongodb-cdc: pipeline stage {i}: {e}")))?;
157 match b {
158 bson::Bson::Document(d) => pipeline.push(d),
159 other => {
160 return Err(FaucetError::Config(format!(
161 "mongodb-cdc: aggregation_pipeline[{i}] must be an object, got {other:?}"
162 )));
163 }
164 }
165 }
166 Ok(pipeline)
167}
168
169#[async_trait]
170impl Source for MongoCdcSource {
171 async fn fetch_with_context(
172 &self,
173 ctx: &HashMap<String, Value>,
174 ) -> Result<Vec<Value>, FaucetError> {
175 use futures::StreamExt;
176 let mut pages = self.stream_pages(ctx, 0);
177 let mut all = Vec::new();
178 while let Some(page) = pages.next().await {
179 all.extend(page?.records);
180 }
181 Ok(all)
182 }
183
184 fn stream_pages<'a>(
185 &'a self,
186 ctx: &'a HashMap<String, Value>,
187 _batch_size: usize,
188 ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
189 self.stream_pages_impl(ctx, self.config.batch_size)
190 }
191
192 fn config_schema(&self) -> Value {
193 serde_json::to_value(schemars::schema_for!(MongoCdcSourceConfig)).unwrap_or(Value::Null)
194 }
195
196 fn state_key(&self) -> Option<String> {
197 Some(self.state_key_value.clone())
198 }
199
200 async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
201 let b = Bookmark::from_value(bookmark)?;
202 b.to_token()?;
204 *self.pending_bookmark.lock().await = Some(b);
205 Ok(())
206 }
207
208 fn supports_exactly_once(&self) -> bool {
209 true
212 }
213
214 fn connector_name(&self) -> &'static str {
215 "mongodb-cdc"
216 }
217
218 fn dataset_uri(&self) -> String {
219 use crate::config::Scope;
220 let base = faucet_core::redact_uri_credentials(&self.config.connection_uri);
221 match &self.config.scope {
222 Scope::Collection {
223 database,
224 collection,
225 } => {
226 format!("{base}/{database}/{collection}")
227 }
228 Scope::Database { database } => format!("{base}/{database}"),
229 Scope::Cluster => base,
230 }
231 }
232
233 async fn check(
234 &self,
235 ctx: &faucet_core::check::CheckContext,
236 ) -> Result<faucet_core::check::CheckReport, FaucetError> {
237 use faucet_core::check::{CheckReport, Probe};
238 let start = std::time::Instant::now();
239
240 let hello = tokio::time::timeout(
246 ctx.timeout,
247 self.client
248 .database("admin")
249 .run_command(bson::doc! { "hello": 1 }),
250 )
251 .await;
252
253 let hello = match hello {
254 Ok(Ok(doc)) => doc,
255 Ok(Err(e)) => {
256 return Ok(CheckReport::single(Probe::fail_hint(
257 "connection",
258 start.elapsed(),
259 format!("could not run hello: {e}"),
260 "verify the URI is reachable and credentials are valid",
261 )));
262 }
263 Err(_elapsed) => {
264 return Ok(CheckReport::single(Probe::fail_hint(
265 "connection",
266 start.elapsed(),
267 "connection timed out",
268 "the server did not respond within the check timeout",
269 )));
270 }
271 };
272
273 let connection = Probe::pass("connection", start.elapsed());
274 let topology = if is_changestream_topology(&hello) {
275 Probe::pass("topology", start.elapsed())
276 } else {
277 Probe::fail_hint(
278 "topology",
279 start.elapsed(),
280 "target is a standalone mongod",
281 "Change Streams require a replica set or sharded cluster",
282 )
283 };
284 Ok(CheckReport {
285 probes: vec![connection, topology],
286 })
287 }
288}
289
290impl MongoCdcSource {
291 fn stream_pages_impl<'a>(
293 &'a self,
294 _ctx: &'a HashMap<String, Value>,
295 batch_size: usize,
296 ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
297 let idle_timeout = self.config.idle_timeout;
298 let max_await = std::time::Duration::from_millis(self.config.max_await_time_ms);
299 let per_batch = batch_size != 0;
300
301 Box::pin(async_stream::try_stream! {
302 use futures::StreamExt;
303
304 let pending = self.pending_bookmark.lock().await.take();
305 let start = resolve_start(&self.config.start_from, pending.as_ref())?;
306 let pipeline = build_pipeline(&self.config)?;
307
308 let change_stream: mongodb::change_stream::ChangeStream<
311 mongodb::change_stream::event::ChangeStreamEvent<Document>,
312 > = match &self.config.scope {
313 crate::config::Scope::Collection { database, collection } => {
314 let coll: mongodb::Collection<Document> =
315 self.client.database(database).collection(collection);
316 let mut w = coll
317 .watch()
318 .pipeline(pipeline)
319 .max_await_time(max_await);
320 if let Some(fd) = full_document_type(self.config.full_document) {
321 w = w.full_document(fd);
322 }
323 if let Some(fb) =
324 full_document_before_type(self.config.full_document_before_change)
325 {
326 w = w.full_document_before_change(fb);
327 }
328 w = match &start {
329 StartPosition::Now => w,
330 StartPosition::AtOperationTime(ts) => w.start_at_operation_time(*ts),
331 StartPosition::ResumeAfter(tok) => w.resume_after(tok.clone()),
332 };
333 w.await
334 .map_err(|e| FaucetError::Source(format!("mongodb-cdc watch failed: {e}")))?
335 }
336 crate::config::Scope::Database { database } => {
337 let db = self.client.database(database);
338 let mut w = db
339 .watch()
340 .pipeline(pipeline)
341 .max_await_time(max_await);
342 if let Some(fd) = full_document_type(self.config.full_document) {
343 w = w.full_document(fd);
344 }
345 if let Some(fb) =
346 full_document_before_type(self.config.full_document_before_change)
347 {
348 w = w.full_document_before_change(fb);
349 }
350 w = match &start {
351 StartPosition::Now => w,
352 StartPosition::AtOperationTime(ts) => w.start_at_operation_time(*ts),
353 StartPosition::ResumeAfter(tok) => w.resume_after(tok.clone()),
354 };
355 w.await
356 .map_err(|e| FaucetError::Source(format!("mongodb-cdc watch failed: {e}")))?
357 }
358 crate::config::Scope::Cluster => {
359 let mut w = self.client
360 .watch()
361 .pipeline(pipeline)
362 .max_await_time(max_await);
363 if let Some(fd) = full_document_type(self.config.full_document) {
364 w = w.full_document(fd);
365 }
366 if let Some(fb) =
367 full_document_before_type(self.config.full_document_before_change)
368 {
369 w = w.full_document_before_change(fb);
370 }
371 w = match &start {
372 StartPosition::Now => w,
373 StartPosition::AtOperationTime(ts) => w.start_at_operation_time(*ts),
374 StartPosition::ResumeAfter(tok) => w.resume_after(tok.clone()),
375 };
376 w.await
377 .map_err(|e| FaucetError::Source(format!("mongodb-cdc watch failed: {e}")))?
378 }
379 };
380 let mut change_stream = std::pin::pin!(change_stream);
381
382 let chunk = if per_batch { batch_size } else { usize::MAX };
383 let mut buffer: Vec<Value> = Vec::new();
384 let mut last_bookmark: Option<Value> = None;
387
388 loop {
396 match tokio::time::timeout(idle_timeout, change_stream.next()).await {
397 Ok(Some(Ok(event))) => {
398 let bookmark = Bookmark::from_token(&event.id)?;
399 let is_invalidate = matches!(
400 event.operation_type,
401 mongodb::change_stream::event::OperationType::Invalidate
402 );
403 buffer.push(to_envelope(&event, &bookmark)?);
404 last_bookmark = Some(bookmark.to_value()?);
405
406 if is_invalidate {
409 yield StreamPage {
410 records: std::mem::take(&mut buffer),
411 bookmark: last_bookmark.take(),
412 };
413 break;
414 }
415 if per_batch && buffer.len() >= chunk {
416 yield StreamPage {
417 records: std::mem::take(&mut buffer),
418 bookmark: last_bookmark.take(),
419 };
420 }
421 }
422 Ok(Some(Err(e))) => {
423 Err(FaucetError::Source(format!("mongodb-cdc stream error: {e}")))?;
424 }
425 Ok(None) | Err(_) => {
428 if !buffer.is_empty() {
429 yield StreamPage {
430 records: std::mem::take(&mut buffer),
431 bookmark: last_bookmark.take(),
432 };
433 }
434 break;
435 }
436 }
437 }
438
439 tracing::info!(connector = "mongodb-cdc", "change stream fetch cycle complete");
440 })
441 }
442}
443
444#[cfg(test)]
445mod tests {
446 use super::*;
447 use serde_json::json;
448
449 #[test]
450 fn explicit_timestamp_overrides_bookmark() {
451 let pending = Bookmark {
452 resume_token: json!({ "_data": "AA" }),
453 };
454 let pos = resolve_start(
455 &StartFrom::Timestamp {
456 timestamp_secs: 100,
457 },
458 Some(&pending),
459 )
460 .unwrap();
461 assert_eq!(
462 pos,
463 StartPosition::AtOperationTime(Timestamp {
464 time: 100,
465 increment: 0
466 })
467 );
468 }
469
470 #[test]
471 fn explicit_resume_token_overrides_bookmark() {
472 let pending = Bookmark {
475 resume_token: json!({ "_data": "PENDING" }),
476 };
477 let pos = resolve_start(
478 &StartFrom::ResumeToken {
479 token: json!({ "_data": "8264AB00" }),
480 },
481 Some(&pending),
482 )
483 .unwrap();
484 match pos {
487 StartPosition::ResumeAfter(tok) => {
488 let b = Bookmark::from_token(&tok).unwrap();
489 assert_eq!(b.resume_token["_data"], json!("8264AB00"));
490 }
491 other => panic!("expected ResumeAfter, got {other:?}"),
492 }
493 }
494
495 #[test]
496 fn now_yields_to_bookmark() {
497 let pending = Bookmark {
498 resume_token: json!({ "_data": "8264AB00" }),
499 };
500 let pos = resolve_start(&StartFrom::Now, Some(&pending)).unwrap();
501 assert!(matches!(pos, StartPosition::ResumeAfter(_)));
502 }
503
504 #[test]
505 fn now_without_bookmark_is_now() {
506 assert_eq!(
507 resolve_start(&StartFrom::Now, None).unwrap(),
508 StartPosition::Now
509 );
510 }
511
512 #[test]
513 fn earliest_without_bookmark_uses_operation_time() {
514 assert_eq!(
515 resolve_start(&StartFrom::Earliest, None).unwrap(),
516 StartPosition::AtOperationTime(Timestamp {
517 time: 1,
518 increment: 1
519 })
520 );
521 }
522
523 #[test]
524 fn topology_classification() {
525 assert!(is_changestream_topology(&bson::doc! { "setName": "rs0" }));
526 assert!(is_changestream_topology(&bson::doc! { "msg": "isdbgrid" }));
527 assert!(!is_changestream_topology(&bson::doc! { "ok": 1.0 }));
528 }
529
530 #[test]
531 fn pipeline_prepends_operation_match() {
532 let c: MongoCdcSourceConfig = serde_json::from_value(json!({
533 "connection_uri": "mongodb://h/?replicaSet=rs0",
534 "scope": { "type": "cluster" },
535 "operation_types": ["insert", "delete"]
536 }))
537 .unwrap();
538 let p = build_pipeline(&c).unwrap();
539 assert_eq!(p.len(), 1);
540 assert!(p[0].contains_key("$match"));
541 }
542
543 #[test]
544 fn pipeline_rejects_non_object_stage() {
545 let c: MongoCdcSourceConfig = serde_json::from_value(json!({
546 "connection_uri": "mongodb://h/?replicaSet=rs0",
547 "scope": { "type": "cluster" },
548 "aggregation_pipeline": [[1, 2, 3]]
549 }))
550 .unwrap();
551 assert!(matches!(build_pipeline(&c), Err(FaucetError::Config(_))));
552 }
553
554 #[test]
555 fn pipeline_appends_object_stages_after_match() {
556 let c: MongoCdcSourceConfig = serde_json::from_value(json!({
559 "connection_uri": "mongodb://h/?replicaSet=rs0",
560 "scope": { "type": "cluster" },
561 "operation_types": ["insert"],
562 "aggregation_pipeline": [
563 { "$match": { "fullDocument.tier": "gold" } },
564 { "$project": { "fullDocument._id": 1 } }
565 ]
566 }))
567 .unwrap();
568 let p = build_pipeline(&c).unwrap();
569 assert_eq!(p.len(), 3, "operation $match + two user stages");
570 assert!(
572 p[0].get_document("$match")
573 .unwrap()
574 .contains_key("operationType")
575 );
576 assert!(p[1].contains_key("$match"));
578 assert!(p[2].contains_key("$project"));
579 }
580
581 #[test]
582 fn pipeline_object_stages_without_operation_match() {
583 let c: MongoCdcSourceConfig = serde_json::from_value(json!({
585 "connection_uri": "mongodb://h/?replicaSet=rs0",
586 "scope": { "type": "cluster" },
587 "aggregation_pipeline": [ { "$project": { "_id": 1 } } ]
588 }))
589 .unwrap();
590 let p = build_pipeline(&c).unwrap();
591 assert_eq!(p.len(), 1);
592 assert!(p[0].contains_key("$project"));
593 }
594
595 #[test]
596 fn full_document_type_mapping() {
597 use crate::config::FullDocument as F;
598 assert!(full_document_type(F::Off).is_none());
599 assert!(matches!(
600 full_document_type(F::WhenAvailable),
601 Some(FullDocumentType::WhenAvailable)
602 ));
603 assert!(matches!(
604 full_document_type(F::Required),
605 Some(FullDocumentType::Required)
606 ));
607 assert!(matches!(
608 full_document_type(F::UpdateLookup),
609 Some(FullDocumentType::UpdateLookup)
610 ));
611 }
612
613 #[test]
614 fn full_document_before_type_mapping() {
615 use crate::config::FullDocumentBeforeChange as F;
616 assert!(full_document_before_type(F::Off).is_none());
617 assert!(matches!(
618 full_document_before_type(F::WhenAvailable),
619 Some(FullDocumentBeforeChangeType::WhenAvailable)
620 ));
621 assert!(matches!(
622 full_document_before_type(F::Required),
623 Some(FullDocumentBeforeChangeType::Required)
624 ));
625 }
626
627 #[test]
631 fn dataset_uri_cluster_scope() {
632 let c: MongoCdcSourceConfig = serde_json::from_value(json!({
633 "connection_uri": "mongodb://u:p@h:27017/?replicaSet=rs0",
634 "scope": { "type": "cluster" }
635 }))
636 .unwrap();
637 use crate::config::Scope;
638 let base = faucet_core::redact_uri_credentials(&c.connection_uri);
639 let uri = match &c.scope {
640 Scope::Collection {
641 database,
642 collection,
643 } => format!("{base}/{database}/{collection}"),
644 Scope::Database { database } => format!("{base}/{database}"),
645 Scope::Cluster => base,
646 };
647 assert_eq!(uri, "mongodb://h:27017/?replicaSet=rs0");
648 }
649
650 #[test]
651 fn dataset_uri_collection_scope() {
652 let c: MongoCdcSourceConfig = serde_json::from_value(json!({
653 "connection_uri": "mongodb://u:p@h:27017/?replicaSet=rs0",
654 "scope": { "type": "collection", "database": "mydb", "collection": "orders" }
655 }))
656 .unwrap();
657 use crate::config::Scope;
658 let base = faucet_core::redact_uri_credentials(&c.connection_uri);
659 let uri = match &c.scope {
660 Scope::Collection {
661 database,
662 collection,
663 } => format!("{base}/{database}/{collection}"),
664 Scope::Database { database } => format!("{base}/{database}"),
665 Scope::Cluster => base,
666 };
667 assert_eq!(uri, "mongodb://h:27017/?replicaSet=rs0/mydb/orders");
668 }
669}