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/// Enforce the in-memory staging cap before buffering one more change record.
64///
65/// `current_len` is the number of records already buffered this fetch cycle;
66/// `max_staged` is the configured cap (`None` = unbounded). Returns a typed
67/// [`FaucetError::Source`] when adding one more record would exceed the cap,
68/// so the run aborts cleanly instead of risking an OOM-kill on an unbounded
69/// `batch_size: 0` (or `fetch_all`) drain. Mirrors `postgres-cdc` /
70/// `mysql-cdc`'s `push_staged` guard.
71pub(crate) fn check_staging_cap(
72    current_len: usize,
73    max_staged: Option<usize>,
74) -> Result<(), FaucetError> {
75    if let Some(max) = max_staged
76        && current_len >= max
77    {
78        return Err(FaucetError::Source(format!(
79            "mongodb-cdc: in-memory change buffer exceeded max_staged_records ({max}); \
80             aborting to avoid unbounded memory growth. Raise max_staged_records or \
81             reduce batch_size so pages flush more often."
82        )));
83    }
84    Ok(())
85}
86
87/// A configured MongoDB CDC source.
88pub struct MongoCdcSource {
89    config: MongoCdcSourceConfig,
90    client: Client,
91    state_key_value: String,
92    pending_bookmark: Mutex<Option<Bookmark>>,
93}
94
95impl MongoCdcSource {
96    /// Connect, validate config + topology, and build the source.
97    pub async fn new(config: MongoCdcSourceConfig) -> Result<Self, FaucetError> {
98        config.validate()?;
99        let client = Client::with_uri_str(&config.connection_uri)
100            .await
101            .map_err(|e| FaucetError::Source(format!("MongoDB connection failed: {e}")))?;
102        ensure_changestream_capable(&client).await?;
103        if config.full_document_before_change != crate::config::FullDocumentBeforeChange::Off {
104            tracing::warn!(
105                "mongodb-cdc: full_document_before_change requires MongoDB 6.0+ and the \
106                 collection's changeStreamPreAndPostImages enabled; the server will error \
107                 at stream open if unavailable"
108            );
109        }
110        let state_key_value = state_key(&config.scope);
111        Ok(Self {
112            config,
113            client,
114            state_key_value,
115            pending_bookmark: Mutex::new(None),
116        })
117    }
118}
119
120/// Run `hello` and assert the deployment supports change streams.
121async fn ensure_changestream_capable(client: &Client) -> Result<(), FaucetError> {
122    let hello = client
123        .database("admin")
124        .run_command(bson::doc! { "hello": 1 })
125        .await
126        .map_err(|e| FaucetError::Source(format!("mongodb-cdc hello failed: {e}")))?;
127    if is_changestream_topology(&hello) {
128        Ok(())
129    } else {
130        Err(FaucetError::Source(
131            "mongodb-cdc: Change Streams require a replica set or sharded cluster; \
132             the target appears to be a standalone mongod"
133                .into(),
134        ))
135    }
136}
137
138/// Classify a `hello` response: replica-set (`setName`) or sharded (`msg=isdbgrid`).
139pub(crate) fn is_changestream_topology(hello: &Document) -> bool {
140    hello.get_str("setName").is_ok()
141        || hello
142            .get_str("msg")
143            .map(|m| m == "isdbgrid")
144            .unwrap_or(false)
145}
146
147/// Map the config post-image mode to the driver type (`None` = don't request).
148pub(crate) fn full_document_type(fd: crate::config::FullDocument) -> Option<FullDocumentType> {
149    use crate::config::FullDocument as F;
150    match fd {
151        F::Off => None,
152        F::WhenAvailable => Some(FullDocumentType::WhenAvailable),
153        F::Required => Some(FullDocumentType::Required),
154        F::UpdateLookup => Some(FullDocumentType::UpdateLookup),
155    }
156}
157
158/// Map the config pre-image mode to the driver type (`None` = don't request).
159pub(crate) fn full_document_before_type(
160    fd: crate::config::FullDocumentBeforeChange,
161) -> Option<FullDocumentBeforeChangeType> {
162    use crate::config::FullDocumentBeforeChange as F;
163    match fd {
164        F::Off => None,
165        F::WhenAvailable => Some(FullDocumentBeforeChangeType::WhenAvailable),
166        F::Required => Some(FullDocumentBeforeChangeType::Required),
167    }
168}
169
170/// Build the server-side pipeline: operation-type `$match` + user stages.
171pub(crate) fn build_pipeline(config: &MongoCdcSourceConfig) -> Result<Vec<Document>, FaucetError> {
172    let mut pipeline: Vec<Document> = Vec::new();
173    if !config.operation_types.is_empty() {
174        pipeline.push(bson::doc! {
175            "$match": { "operationType": { "$in": config.operation_types.clone() } }
176        });
177    }
178    for (i, stage) in config.aggregation_pipeline.iter().enumerate() {
179        let b = bson::to_bson(stage)
180            .map_err(|e| FaucetError::Config(format!("mongodb-cdc: pipeline stage {i}: {e}")))?;
181        match b {
182            bson::Bson::Document(d) => pipeline.push(d),
183            other => {
184                return Err(FaucetError::Config(format!(
185                    "mongodb-cdc: aggregation_pipeline[{i}] must be an object, got {other:?}"
186                )));
187            }
188        }
189    }
190    Ok(pipeline)
191}
192
193#[async_trait]
194impl Source for MongoCdcSource {
195    async fn fetch_with_context(
196        &self,
197        ctx: &HashMap<String, Value>,
198    ) -> Result<Vec<Value>, FaucetError> {
199        use futures::StreamExt;
200        let mut pages = self.stream_pages(ctx, 0);
201        let mut all = Vec::new();
202        while let Some(page) = pages.next().await {
203            all.extend(page?.records);
204        }
205        Ok(all)
206    }
207
208    fn stream_pages<'a>(
209        &'a self,
210        ctx: &'a HashMap<String, Value>,
211        _batch_size: usize,
212    ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
213        self.stream_pages_impl(ctx, self.config.batch_size)
214    }
215
216    fn config_schema(&self) -> Value {
217        serde_json::to_value(schemars::schema_for!(MongoCdcSourceConfig)).unwrap_or(Value::Null)
218    }
219
220    fn state_key(&self) -> Option<String> {
221        Some(self.state_key_value.clone())
222    }
223
224    async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
225        let b = Bookmark::from_value(bookmark)?;
226        // Round-trip-validate the token now so a corrupt bookmark fails fast.
227        b.to_token()?;
228        *self.pending_bookmark.lock().await = Some(b);
229        Ok(())
230    }
231
232    async fn capture_resume_position(&self) -> Result<Option<Value>, FaucetError> {
233        let token = self.capture_now_token().await?;
234        Ok(Some(Bookmark::from_token(&token)?.to_value()?))
235    }
236
237    fn supports_exactly_once(&self) -> bool {
238        // Durable resumeToken + deterministic replay from it + per-event
239        // (per-page) bookmarks — the requirements for exactly-once delivery.
240        true
241    }
242
243    fn connector_name(&self) -> &'static str {
244        "mongodb-cdc"
245    }
246
247    fn dataset_uri(&self) -> String {
248        use crate::config::Scope;
249        let base = faucet_core::redact_uri_credentials(&self.config.connection_uri);
250        match &self.config.scope {
251            Scope::Collection {
252                database,
253                collection,
254            } => {
255                format!("{base}/{database}/{collection}")
256            }
257            Scope::Database { database } => format!("{base}/{database}"),
258            Scope::Cluster => base,
259        }
260    }
261
262    async fn check(
263        &self,
264        ctx: &faucet_core::check::CheckContext,
265    ) -> Result<faucet_core::check::CheckReport, FaucetError> {
266        use faucet_core::check::{CheckReport, Probe};
267        let start = std::time::Instant::now();
268
269        // Run `hello` once under the probe timeout, then derive two probes from
270        // the outcome: `connection` (could we reach + auth to the server?) and
271        // `topology` (does it support change streams?). On a connection
272        // failure/timeout the topology probe cannot run, so only `connection`
273        // is reported.
274        let hello = tokio::time::timeout(
275            ctx.timeout,
276            self.client
277                .database("admin")
278                .run_command(bson::doc! { "hello": 1 }),
279        )
280        .await;
281
282        let hello = match hello {
283            Ok(Ok(doc)) => doc,
284            Ok(Err(e)) => {
285                return Ok(CheckReport::single(Probe::fail_hint(
286                    "connection",
287                    start.elapsed(),
288                    format!("could not run hello: {e}"),
289                    "verify the URI is reachable and credentials are valid",
290                )));
291            }
292            Err(_elapsed) => {
293                return Ok(CheckReport::single(Probe::fail_hint(
294                    "connection",
295                    start.elapsed(),
296                    "connection timed out",
297                    "the server did not respond within the check timeout",
298                )));
299            }
300        };
301
302        let connection = Probe::pass("connection", start.elapsed());
303        let topology = if is_changestream_topology(&hello) {
304            Probe::pass("topology", start.elapsed())
305        } else {
306            Probe::fail_hint(
307                "topology",
308                start.elapsed(),
309                "target is a standalone mongod",
310                "Change Streams require a replica set or sharded cluster",
311            )
312        };
313        Ok(CheckReport {
314            probes: vec![connection, topology],
315        })
316    }
317}
318
319impl MongoCdcSource {
320    /// Open the change stream and drain it with an idle-timeout terminator.
321    fn stream_pages_impl<'a>(
322        &'a self,
323        _ctx: &'a HashMap<String, Value>,
324        batch_size: usize,
325    ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
326        let idle_timeout = self.config.idle_timeout;
327        let max_await = std::time::Duration::from_millis(self.config.max_await_time_ms);
328        let per_batch = batch_size != 0;
329        let max_staged = self.config.max_staged_records;
330
331        Box::pin(async_stream::try_stream! {
332            use futures::StreamExt;
333
334            let pending = self.pending_bookmark.lock().await.take();
335            let start = resolve_start(&self.config.start_from, pending.as_ref())?;
336            let pipeline = build_pipeline(&self.config)?;
337
338            // Open the change stream scoped to collection, database, or cluster.
339            // Each arm resolves to a ChangeStream<ChangeStreamEvent<Document>>.
340            let change_stream: mongodb::change_stream::ChangeStream<
341                mongodb::change_stream::event::ChangeStreamEvent<Document>,
342            > = match &self.config.scope {
343                crate::config::Scope::Collection { database, collection } => {
344                    let coll: mongodb::Collection<Document> =
345                        self.client.database(database).collection(collection);
346                    let mut w = coll
347                        .watch()
348                        .pipeline(pipeline)
349                        .max_await_time(max_await);
350                    if let Some(fd) = full_document_type(self.config.full_document) {
351                        w = w.full_document(fd);
352                    }
353                    if let Some(fb) =
354                        full_document_before_type(self.config.full_document_before_change)
355                    {
356                        w = w.full_document_before_change(fb);
357                    }
358                    w = match &start {
359                        StartPosition::Now => w,
360                        StartPosition::AtOperationTime(ts) => w.start_at_operation_time(*ts),
361                        StartPosition::ResumeAfter(tok) => w.resume_after(tok.clone()),
362                    };
363                    w.await
364                        .map_err(|e| FaucetError::Source(format!("mongodb-cdc watch failed: {e}")))?
365                }
366                crate::config::Scope::Database { database } => {
367                    let db = self.client.database(database);
368                    let mut w = db
369                        .watch()
370                        .pipeline(pipeline)
371                        .max_await_time(max_await);
372                    if let Some(fd) = full_document_type(self.config.full_document) {
373                        w = w.full_document(fd);
374                    }
375                    if let Some(fb) =
376                        full_document_before_type(self.config.full_document_before_change)
377                    {
378                        w = w.full_document_before_change(fb);
379                    }
380                    w = match &start {
381                        StartPosition::Now => w,
382                        StartPosition::AtOperationTime(ts) => w.start_at_operation_time(*ts),
383                        StartPosition::ResumeAfter(tok) => w.resume_after(tok.clone()),
384                    };
385                    w.await
386                        .map_err(|e| FaucetError::Source(format!("mongodb-cdc watch failed: {e}")))?
387                }
388                crate::config::Scope::Cluster => {
389                    let mut w = self.client
390                        .watch()
391                        .pipeline(pipeline)
392                        .max_await_time(max_await);
393                    if let Some(fd) = full_document_type(self.config.full_document) {
394                        w = w.full_document(fd);
395                    }
396                    if let Some(fb) =
397                        full_document_before_type(self.config.full_document_before_change)
398                    {
399                        w = w.full_document_before_change(fb);
400                    }
401                    w = match &start {
402                        StartPosition::Now => w,
403                        StartPosition::AtOperationTime(ts) => w.start_at_operation_time(*ts),
404                        StartPosition::ResumeAfter(tok) => w.resume_after(tok.clone()),
405                    };
406                    w.await
407                        .map_err(|e| FaucetError::Source(format!("mongodb-cdc watch failed: {e}")))?
408                }
409            };
410            let mut change_stream = std::pin::pin!(change_stream);
411
412            let chunk = if per_batch { batch_size } else { usize::MAX };
413            let mut buffer: Vec<Value> = Vec::new();
414            // Bookmark of the last record currently in `buffer`. `None` only when
415            // the buffer is empty (no events seen this cycle).
416            let mut last_bookmark: Option<Value> = None;
417
418            // Graceful shutdown is handled one layer up: `run_stream`'s page loop
419            // `biased`-`select!`s the pipeline cancel token (Pipeline::with_cancel,
420            // #146) against polling this stream for its next page, so a cancel
421            // mid-idle-wait stops at the page boundary and flushes the sinks — no
422            // source-side signal handling needed. Nothing is persisted until a
423            // page yields, so a dropped in-flight buffer is simply re-fetched
424            // (the resume token has not advanced).
425            loop {
426                match tokio::time::timeout(idle_timeout, change_stream.next()).await {
427                    Ok(Some(Ok(event))) => {
428                        let bookmark = Bookmark::from_token(&event.id)?;
429                        let is_invalidate = matches!(
430                            event.operation_type,
431                            mongodb::change_stream::event::OperationType::Invalidate
432                        );
433                        // OOM safety valve: with `batch_size: 0` (or `fetch_all`)
434                        // the buffer is never flushed mid-cycle, so a
435                        // high-throughput stream would grow it without bound.
436                        // Abort with a typed error before staging one more record
437                        // past the cap rather than risk an OOM-kill.
438                        check_staging_cap(buffer.len(), max_staged)?;
439                        buffer.push(to_envelope(&event, &bookmark)?);
440                        last_bookmark = Some(bookmark.to_value()?);
441
442                        // An invalidate closes the stream server-side: flush the
443                        // page (including the invalidate event) and end.
444                        if is_invalidate {
445                            yield StreamPage {
446                                records: std::mem::take(&mut buffer),
447                                bookmark: last_bookmark.take(),
448                            };
449                            break;
450                        }
451                        if per_batch && buffer.len() >= chunk {
452                            yield StreamPage {
453                                records: std::mem::take(&mut buffer),
454                                bookmark: last_bookmark.take(),
455                            };
456                        }
457                    }
458                    Ok(Some(Err(e))) => {
459                        Err(FaucetError::Source(format!("mongodb-cdc stream error: {e}")))?;
460                    }
461                    // Idle (timeout) or cursor closed: flush whatever we have and
462                    // end this fetch cycle.
463                    Ok(None) | Err(_) => {
464                        if !buffer.is_empty() {
465                            yield StreamPage {
466                                records: std::mem::take(&mut buffer),
467                                bookmark: last_bookmark.take(),
468                            };
469                        }
470                        break;
471                    }
472                }
473            }
474
475            tracing::info!(connector = "mongodb-cdc", "change stream fetch cycle complete");
476        })
477    }
478
479    /// Open a change stream at "now", read its `postBatchResumeToken` without
480    /// consuming any events, then drop the stream. Used by the replication
481    /// snapshot→CDC handoff to anchor the stream before the bulk snapshot.
482    async fn capture_now_token(&self) -> Result<ResumeToken, FaucetError> {
483        use crate::config::Scope;
484        use futures::StreamExt;
485        let max_await = std::time::Duration::from_millis(self.config.max_await_time_ms);
486        let cs: mongodb::change_stream::ChangeStream<
487            mongodb::change_stream::event::ChangeStreamEvent<Document>,
488        > = match &self.config.scope {
489            Scope::Collection {
490                database,
491                collection,
492            } => self
493                .client
494                .database(database)
495                .collection::<Document>(collection)
496                .watch()
497                .max_await_time(max_await)
498                .await
499                .map_err(|e| {
500                    FaucetError::Source(format!("mongodb-cdc capture watch failed: {e}"))
501                })?,
502            Scope::Database { database } => self
503                .client
504                .database(database)
505                .watch()
506                .max_await_time(max_await)
507                .await
508                .map_err(|e| {
509                    FaucetError::Source(format!("mongodb-cdc capture watch failed: {e}"))
510                })?,
511            Scope::Cluster => self
512                .client
513                .watch()
514                .max_await_time(max_await)
515                .await
516                .map_err(|e| {
517                    FaucetError::Source(format!("mongodb-cdc capture watch failed: {e}"))
518                })?,
519        };
520        let mut cs = std::pin::pin!(cs);
521        // The initial aggregate response usually carries a postBatchResumeToken.
522        // If the driver hasn't cached one yet, one short poll forces a getMore
523        // that returns it.
524        if let Some(tok) = cs.resume_token() {
525            return Ok(tok);
526        }
527        let _ = tokio::time::timeout(
528            max_await.max(std::time::Duration::from_millis(500)),
529            cs.next(),
530        )
531        .await;
532        cs.resume_token().ok_or_else(|| {
533            FaucetError::Source(
534                "mongodb-cdc: could not capture a resume token (no postBatchResumeToken returned)"
535                    .into(),
536            )
537        })
538    }
539}
540
541#[cfg(test)]
542mod tests {
543    use super::*;
544    use serde_json::json;
545
546    #[test]
547    fn explicit_timestamp_overrides_bookmark() {
548        let pending = Bookmark {
549            resume_token: json!({ "_data": "AA" }),
550        };
551        let pos = resolve_start(
552            &StartFrom::Timestamp {
553                timestamp_secs: 100,
554            },
555            Some(&pending),
556        )
557        .unwrap();
558        assert_eq!(
559            pos,
560            StartPosition::AtOperationTime(Timestamp {
561                time: 100,
562                increment: 0
563            })
564        );
565    }
566
567    #[test]
568    fn explicit_resume_token_overrides_bookmark() {
569        // An explicit ResumeToken in config must win over any persisted
570        // bookmark and resolve to ResumeAfter(token).
571        let pending = Bookmark {
572            resume_token: json!({ "_data": "PENDING" }),
573        };
574        let pos = resolve_start(
575            &StartFrom::ResumeToken {
576                token: json!({ "_data": "8264AB00" }),
577            },
578            Some(&pending),
579        )
580        .unwrap();
581        // The resolved position must round-trip back to the configured token,
582        // not the pending bookmark's token.
583        match pos {
584            StartPosition::ResumeAfter(tok) => {
585                let b = Bookmark::from_token(&tok).unwrap();
586                assert_eq!(b.resume_token["_data"], json!("8264AB00"));
587            }
588            other => panic!("expected ResumeAfter, got {other:?}"),
589        }
590    }
591
592    #[test]
593    fn now_yields_to_bookmark() {
594        let pending = Bookmark {
595            resume_token: json!({ "_data": "8264AB00" }),
596        };
597        let pos = resolve_start(&StartFrom::Now, Some(&pending)).unwrap();
598        assert!(matches!(pos, StartPosition::ResumeAfter(_)));
599    }
600
601    #[test]
602    fn now_without_bookmark_is_now() {
603        assert_eq!(
604            resolve_start(&StartFrom::Now, None).unwrap(),
605            StartPosition::Now
606        );
607    }
608
609    #[test]
610    fn earliest_without_bookmark_uses_operation_time() {
611        assert_eq!(
612            resolve_start(&StartFrom::Earliest, None).unwrap(),
613            StartPosition::AtOperationTime(Timestamp {
614                time: 1,
615                increment: 1
616            })
617        );
618    }
619
620    #[test]
621    fn staging_cap_none_is_unbounded() {
622        // With no cap, an arbitrarily large buffer never aborts.
623        assert!(check_staging_cap(0, None).is_ok());
624        assert!(check_staging_cap(1_000_000, None).is_ok());
625    }
626
627    #[test]
628    fn staging_cap_allows_up_to_limit() {
629        // Buffering record N is allowed while the current length is below the
630        // cap (i.e. up to `max` records total).
631        assert!(check_staging_cap(0, Some(2)).is_ok());
632        assert!(check_staging_cap(1, Some(2)).is_ok());
633    }
634
635    #[test]
636    fn staging_cap_aborts_at_limit() {
637        // Once `current_len` reaches the cap, staging one more record must
638        // abort with a typed FaucetError::Source naming max_staged_records.
639        let err = check_staging_cap(2, Some(2)).unwrap_err();
640        assert!(matches!(err, FaucetError::Source(_)));
641        assert!(format!("{err}").contains("max_staged_records"));
642
643        // And it stays an error for any length beyond the cap.
644        assert!(check_staging_cap(3, Some(2)).is_err());
645    }
646
647    #[test]
648    fn staging_cap_drives_accumulation_past_limit() {
649        // Simulate the buffer-accumulation loop with batch_size: 0 (no
650        // mid-cycle flush) and a cap of 3: the 4th record must abort.
651        let mut buffer: Vec<u8> = Vec::new();
652        let max = Some(3usize);
653        let mut last: Result<(), FaucetError> = Ok(());
654        for i in 0..10u8 {
655            if let Err(e) = check_staging_cap(buffer.len(), max) {
656                last = Err(e);
657                break;
658            }
659            buffer.push(i);
660        }
661        assert_eq!(buffer.len(), 3, "buffer stops growing at the cap");
662        let err = last.unwrap_err();
663        assert!(matches!(err, FaucetError::Source(_)));
664        assert!(format!("{err}").contains("max_staged_records"));
665    }
666
667    #[test]
668    fn topology_classification() {
669        assert!(is_changestream_topology(&bson::doc! { "setName": "rs0" }));
670        assert!(is_changestream_topology(&bson::doc! { "msg": "isdbgrid" }));
671        assert!(!is_changestream_topology(&bson::doc! { "ok": 1.0 }));
672    }
673
674    #[test]
675    fn pipeline_prepends_operation_match() {
676        let c: MongoCdcSourceConfig = serde_json::from_value(json!({
677            "connection_uri": "mongodb://h/?replicaSet=rs0",
678            "scope": { "type": "cluster" },
679            "operation_types": ["insert", "delete"]
680        }))
681        .unwrap();
682        let p = build_pipeline(&c).unwrap();
683        assert_eq!(p.len(), 1);
684        assert!(p[0].contains_key("$match"));
685    }
686
687    #[test]
688    fn pipeline_rejects_non_object_stage() {
689        let c: MongoCdcSourceConfig = serde_json::from_value(json!({
690            "connection_uri": "mongodb://h/?replicaSet=rs0",
691            "scope": { "type": "cluster" },
692            "aggregation_pipeline": [[1, 2, 3]]
693        }))
694        .unwrap();
695        assert!(matches!(build_pipeline(&c), Err(FaucetError::Config(_))));
696    }
697
698    #[test]
699    fn pipeline_appends_object_stages_after_match() {
700        // operation_types prepends a $match; each aggregation_pipeline object
701        // stage is appended in order.
702        let c: MongoCdcSourceConfig = serde_json::from_value(json!({
703            "connection_uri": "mongodb://h/?replicaSet=rs0",
704            "scope": { "type": "cluster" },
705            "operation_types": ["insert"],
706            "aggregation_pipeline": [
707                { "$match": { "fullDocument.tier": "gold" } },
708                { "$project": { "fullDocument._id": 1 } }
709            ]
710        }))
711        .unwrap();
712        let p = build_pipeline(&c).unwrap();
713        assert_eq!(p.len(), 3, "operation $match + two user stages");
714        // First is the operation-type $match.
715        assert!(
716            p[0].get_document("$match")
717                .unwrap()
718                .contains_key("operationType")
719        );
720        // User object stages preserved verbatim, in order.
721        assert!(p[1].contains_key("$match"));
722        assert!(p[2].contains_key("$project"));
723    }
724
725    #[test]
726    fn pipeline_object_stages_without_operation_match() {
727        // No operation_types → no leading $match; only the user object stage.
728        let c: MongoCdcSourceConfig = serde_json::from_value(json!({
729            "connection_uri": "mongodb://h/?replicaSet=rs0",
730            "scope": { "type": "cluster" },
731            "aggregation_pipeline": [ { "$project": { "_id": 1 } } ]
732        }))
733        .unwrap();
734        let p = build_pipeline(&c).unwrap();
735        assert_eq!(p.len(), 1);
736        assert!(p[0].contains_key("$project"));
737    }
738
739    #[test]
740    fn full_document_type_mapping() {
741        use crate::config::FullDocument as F;
742        assert!(full_document_type(F::Off).is_none());
743        assert!(matches!(
744            full_document_type(F::WhenAvailable),
745            Some(FullDocumentType::WhenAvailable)
746        ));
747        assert!(matches!(
748            full_document_type(F::Required),
749            Some(FullDocumentType::Required)
750        ));
751        assert!(matches!(
752            full_document_type(F::UpdateLookup),
753            Some(FullDocumentType::UpdateLookup)
754        ));
755    }
756
757    #[test]
758    fn full_document_before_type_mapping() {
759        use crate::config::FullDocumentBeforeChange as F;
760        assert!(full_document_before_type(F::Off).is_none());
761        assert!(matches!(
762            full_document_before_type(F::WhenAvailable),
763            Some(FullDocumentBeforeChangeType::WhenAvailable)
764        ));
765        assert!(matches!(
766            full_document_before_type(F::Required),
767            Some(FullDocumentBeforeChangeType::Required)
768        ));
769    }
770
771    // dataset_uri is a pure-config method; MongoCdcSource requires a live
772    // MongoDB async constructor, so we verify the logic directly using config
773    // deserialization (which is sync).
774    #[test]
775    fn dataset_uri_cluster_scope() {
776        let c: MongoCdcSourceConfig = serde_json::from_value(json!({
777            "connection_uri": "mongodb://u:p@h:27017/?replicaSet=rs0",
778            "scope": { "type": "cluster" }
779        }))
780        .unwrap();
781        use crate::config::Scope;
782        let base = faucet_core::redact_uri_credentials(&c.connection_uri);
783        let uri = match &c.scope {
784            Scope::Collection {
785                database,
786                collection,
787            } => format!("{base}/{database}/{collection}"),
788            Scope::Database { database } => format!("{base}/{database}"),
789            Scope::Cluster => base,
790        };
791        assert_eq!(uri, "mongodb://h:27017/?replicaSet=rs0");
792    }
793
794    #[test]
795    fn dataset_uri_collection_scope() {
796        let c: MongoCdcSourceConfig = serde_json::from_value(json!({
797            "connection_uri": "mongodb://u:p@h:27017/?replicaSet=rs0",
798            "scope": { "type": "collection", "database": "mydb", "collection": "orders" }
799        }))
800        .unwrap();
801        use crate::config::Scope;
802        let base = faucet_core::redact_uri_credentials(&c.connection_uri);
803        let uri = match &c.scope {
804            Scope::Collection {
805                database,
806                collection,
807            } => format!("{base}/{database}/{collection}"),
808            Scope::Database { database } => format!("{base}/{database}"),
809            Scope::Cluster => base,
810        };
811        assert_eq!(uri, "mongodb://h:27017/?replicaSet=rs0/mydb/orders");
812    }
813}