Skip to main content

faucet_source_mongodb_cdc/
stream.rs

1//! MongoDB change-stream executor.
2
3use 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/// Effective start position resolved for a given fetch cycle.
18#[derive(Clone, Debug, PartialEq)]
19pub(crate) enum StartPosition {
20    /// Server default (current cluster time).
21    Now,
22    /// `start_at_operation_time`.
23    AtOperationTime(Timestamp),
24    /// `resume_after(token)`.
25    ResumeAfter(ResumeToken),
26}
27
28/// Resolve the effective start position from config + any persisted bookmark.
29///
30/// Precedence: explicit `resume_token`/`timestamp` win over a persisted
31/// bookmark; `now`/`earliest` yield to a persisted bookmark.
32pub(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                // Earliest retained oplog entry (errors at open if rolled).
52                Ok(StartPosition::AtOperationTime(Timestamp {
53                    time: 1,
54                    increment: 1,
55                }))
56            } else {
57                Ok(StartPosition::Now)
58            }
59        }
60    }
61}
62
63/// A configured MongoDB CDC source.
64pub struct MongoCdcSource {
65    config: MongoCdcSourceConfig,
66    client: Client,
67    state_key_value: String,
68    pending_bookmark: Mutex<Option<Bookmark>>,
69}
70
71impl MongoCdcSource {
72    /// Connect, validate config + topology, and build the source.
73    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
96/// Run `hello` and assert the deployment supports change streams.
97async 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
114/// Classify a `hello` response: replica-set (`setName`) or sharded (`msg=isdbgrid`).
115pub(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
123/// Map the config post-image mode to the driver type (`None` = don't request).
124pub(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
134/// Map the config pre-image mode to the driver type (`None` = don't request).
135pub(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
146/// Build the server-side pipeline: operation-type `$match` + user stages.
147pub(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        // Round-trip-validate the token now so a corrupt bookmark fails fast.
203        b.to_token()?;
204        *self.pending_bookmark.lock().await = Some(b);
205        Ok(())
206    }
207
208    fn supports_exactly_once(&self) -> bool {
209        // Durable resumeToken + deterministic replay from it + per-event
210        // (per-page) bookmarks — the requirements for exactly-once delivery.
211        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        // Run `hello` once under the probe timeout, then derive two probes from
241        // the outcome: `connection` (could we reach + auth to the server?) and
242        // `topology` (does it support change streams?). On a connection
243        // failure/timeout the topology probe cannot run, so only `connection`
244        // is reported.
245        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    /// Open the change stream and drain it with an idle-timeout terminator.
292    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            // Open the change stream scoped to collection, database, or cluster.
309            // Each arm resolves to a ChangeStream<ChangeStreamEvent<Document>>.
310            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            // Bookmark of the last record currently in `buffer`. `None` only when
385            // the buffer is empty (no events seen this cycle).
386            let mut last_bookmark: Option<Value> = None;
387
388            // Graceful shutdown is handled one layer up: `run_stream`'s page loop
389            // `biased`-`select!`s the pipeline cancel token (Pipeline::with_cancel,
390            // #146) against polling this stream for its next page, so a cancel
391            // mid-idle-wait stops at the page boundary and flushes the sinks — no
392            // source-side signal handling needed. Nothing is persisted until a
393            // page yields, so a dropped in-flight buffer is simply re-fetched
394            // (the resume token has not advanced).
395            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                        // An invalidate closes the stream server-side: flush the
407                        // page (including the invalidate event) and end.
408                        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                    // Idle (timeout) or cursor closed: flush whatever we have and
426                    // end this fetch cycle.
427                    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        // An explicit ResumeToken in config must win over any persisted
473        // bookmark and resolve to ResumeAfter(token).
474        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        // The resolved position must round-trip back to the configured token,
485        // not the pending bookmark's token.
486        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        // operation_types prepends a $match; each aggregation_pipeline object
557        // stage is appended in order.
558        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        // First is the operation-type $match.
571        assert!(
572            p[0].get_document("$match")
573                .unwrap()
574                .contains_key("operationType")
575        );
576        // User object stages preserved verbatim, in order.
577        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        // No operation_types → no leading $match; only the user object stage.
584        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    // dataset_uri is a pure-config method; MongoCdcSource requires a live
628    // MongoDB async constructor, so we verify the logic directly using config
629    // deserialization (which is sync).
630    #[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}