Skip to main content

faucet_sink_mongodb/
sink.rs

1//! MongoDB sink implementation.
2
3use 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
11/// Max in-flight `replace_one` / `delete_one` operations issued concurrently
12/// from a single planned page. The planner has already deduped by key, so
13/// every concurrent op targets a distinct key — there is no intra-batch
14/// ordering hazard. A modest bound keeps round-trips overlapping without
15/// flooding the connection pool.
16const APPLY_CONCURRENCY: usize = 50;
17
18/// Max `commit_transaction()` retries while the error carries the driver's
19/// `UnknownTransactionCommitResult` label (the driver-recommended retry loop,
20/// bounded so a partitioned primary can't wedge the pipeline forever).
21const MAX_COMMIT_RETRIES: usize = 8;
22
23/// MongoDB server error code for `NamespaceExists` (raised by
24/// `create_collection` when the collection already exists — benign for the
25/// idempotent pre-creation of the watermark/data collections).
26const NAMESPACE_EXISTS_CODE: i32 = 48;
27
28/// Convert a JSON object map (a key filter from
29/// [`faucet_core::key_to_filter`]) into a BSON filter [`Document`].
30///
31/// Returns a `Sink` error if the map does not convert to a BSON document.
32fn json_map_to_bson_filter(map: &Map<String, Value>) -> Result<Document, FaucetError> {
33    MongoSink::value_to_document(&Value::Object(map.clone()))
34}
35
36/// A single planned upsert/delete op, pre-converted to BSON so all conversion
37/// errors surface before any write (or transaction) is issued.
38enum PlannedOp {
39    /// `replace_one(filter, replacement).upsert(true)`.
40    Upsert(Document, Document),
41    /// `delete_one(filter)`.
42    Delete(Document),
43}
44
45/// A page fully prepared (planned + converted to BSON) **before** the
46/// exactly-once transaction is opened, so no preparation failure can leave a
47/// dangling transaction.
48enum PreparedWrite {
49    /// Append mode: the whole page as one `insert_many`.
50    Append(Vec<Document>),
51    /// Upsert/delete mode: the planned per-document ops.
52    Planned(Vec<PlannedOp>),
53}
54
55// ---------------------------------------------------------------------------
56// Exactly-once (effectively-once) helpers — pure, unit-testable functions.
57// ---------------------------------------------------------------------------
58
59/// Filter selecting the per-scope watermark document in the
60/// [`_faucet_commit_token`](faucet_core::idempotency::COMMIT_TOKEN_TABLE)
61/// collection. The scope is the document `_id`, so MongoDB's mandatory
62/// unique index on `_id` enforces one watermark per scope for free.
63fn commit_token_filter(scope: &str) -> Document {
64    bson::doc! { "_id": scope }
65}
66
67/// Full watermark document `{ _id: <scope>, token: <token> }` upserted (via
68/// `replace_one(upsert = true)`) inside the same transaction as the page's
69/// data writes. The token is **opaque** — it may contain `#` + JSON payload —
70/// and is stored/returned verbatim, never parsed.
71fn 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
78/// Extract the opaque token string from a watermark document read back by
79/// [`last_committed_token`](faucet_core::Sink::last_committed_token).
80///
81/// A missing or non-string `token` field means the watermark collection was
82/// tampered with (or written by something other than this sink) — surface a
83/// typed error rather than silently treating the scope as uncommitted, which
84/// would replay pages and duplicate data.
85fn 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
101/// True when a server error message indicates the deployment does not support
102/// multi-document transactions — i.e. a standalone `mongod` (transactions
103/// require a replica set or sharded cluster).
104///
105/// The canonical standalone-server message is
106/// `"Transaction numbers are only allowed on a replica set member or mongos"`
107/// (code 20, `IllegalOperation`). The Rust driver *rewrites* that exact server
108/// errmsg into `"This MongoDB deployment does not support retryable writes"`
109/// when rendering the error, so both phrasings are matched; older/alternate
110/// builds phrase it as `"Transactions are not supported"`. Matched
111/// case-insensitively over the rendered error string so the predicate stays
112/// pure and unit-testable.
113fn 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
120/// Map a driver error raised while starting or executing a transaction to a
121/// typed [`FaucetError`]. A standalone-server "transactions unavailable"
122/// failure gets a self-explanatory message (the config error is the
123/// deployment topology, not the data); everything else keeps the original
124/// context + driver message.
125fn 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
136/// True when a `create_collection` command error code means "already exists"
137/// (`NamespaceExists`, code 48) — benign for idempotent pre-creation.
138fn is_namespace_exists_code(code: Option<i32>) -> bool {
139    code == Some(NAMESPACE_EXISTS_CODE)
140}
141
142/// Extract the server command error code from a driver error, if any.
143fn 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
150/// A sink that inserts JSON records into a MongoDB collection.
151///
152/// Each record must be a JSON object. Non-object values produce an error.
153/// Records are inserted in batches using `insert_many`.
154pub struct MongoSink {
155    config: MongoSinkConfig,
156    client: Client,
157    /// One-shot guard for pre-creating the data + watermark collections before
158    /// the first exactly-once transaction (a failed init is retried on the
159    /// next call). Collections referenced inside a multi-document transaction
160    /// must exist up front on MongoDB < 4.4; pre-creating keeps the
161    /// exactly-once path portable and removes a first-run failure mode.
162    eo_collections_ready: tokio::sync::OnceCell<()>,
163}
164
165impl MongoSink {
166    /// Create a new MongoDB sink, establishing the client connection.
167    pub async fn new(config: MongoSinkConfig) -> Result<Self, FaucetError> {
168        faucet_core::validate_batch_size(config.batch_size)?;
169        // Validate write-mode config up front (config-only, so before connecting
170        // is fine): upsert/delete require a non-empty `key`. MongoDB is
171        // schemaless, so there is no column-mapping guard to apply.
172        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    /// Build the match-filter [`Document`] for an upsert row by pulling the
185    /// configured `key` columns out of the row. The planner
186    /// ([`faucet_core::plan_writes`]) has already validated that every key
187    /// column is present and non-null on each upsert row, so a missing column
188    /// here is an internal invariant violation rather than user data error.
189    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    /// Apply a planned page of upserts and deletes to the collection.
210    ///
211    /// Each upsert row is committed with `replace_one(filter, replacement)
212    /// .upsert(true)` and each delete with `delete_one(filter)`. We use the
213    /// per-document `replace_one(upsert)` / `delete_one` primitives (not the
214    /// namespaced `Client::bulk_write`) for compatibility with all supported
215    /// MongoDB server versions; throughput is recovered by issuing the ops
216    /// concurrently via `buffer_unordered`. The planner already deduped keys
217    /// (last-write-wins), so concurrent ops target distinct keys and there is
218    /// no intra-batch ordering hazard.
219    ///
220    /// Returns the number of upserts + deletes applied.
221    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        // Build a single homogeneous op stream of (filter, replacement?) so
228        // upserts and deletes run through one bounded `buffer_unordered`.
229        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    /// Convert a planned page ([`faucet_core::WritePlan`]) into pre-converted
273    /// BSON ops, so every conversion / key-extraction error surfaces before
274    /// any write (or transaction) is issued.
275    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    /// Handle to the per-database watermark collection
291    /// ([`_faucet_commit_token`](faucet_core::idempotency::COMMIT_TOKEN_TABLE)).
292    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    /// Pre-create the data + watermark collections once per sink instance
299    /// (tolerating `NamespaceExists`), so the exactly-once transaction never
300    /// has to create a collection implicitly (unsupported on MongoDB < 4.4).
301    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    /// Run one page's data writes **plus** the watermark upsert inside an
326    /// already-started transaction on `session`. On `Err` the caller aborts
327    /// the transaction (best-effort) so nothing from the page is committed.
328    ///
329    /// A [`mongodb::ClientSession`] must not be used concurrently, so the
330    /// planned upsert/delete ops run **sequentially** here — unlike the
331    /// at-least-once path's `buffer_unordered` fan-out. That is the throughput
332    /// tradeoff of exactly-once on this sink; atomicity requires the single
333    /// session.
334    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                // One page = one transaction: the whole page goes in a single
350                // `insert_many` (no `batch_size` re-chunking on this path), so
351                // the data and its watermark commit as one atomic unit.
352                // `ordered` is irrelevant inside a transaction — any write
353                // error aborts the whole transaction — so the driver default
354                // is used. (`insert_many` rejects an empty slice, and an empty
355                // page still needs its watermark, hence the guard.)
356                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        // The watermark upserts in the SAME transaction as the data, so on
408        // crash either both land or neither does — that is what makes the
409        // replay skip-on-resume produce zero duplicates.
410        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    /// Convert a `serde_json::Value` to a `bson::Document`.
423    ///
424    /// Returns a `Sink` error if the value is not a JSON object.
425    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    /// Non-mutating preflight probe: run the `ping` admin command against the
466    /// configured database via the existing client (probe name `"ping"`).
467    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    /// Write records to MongoDB.
487    ///
488    /// When `config.batch_size > 0` and the input slice is larger than
489    /// `batch_size`, the slice is split into chunks of `batch_size` documents
490    /// and each chunk is sent as a separate `insert_many` call. When
491    /// `config.batch_size == 0`, the entire slice is sent in a single
492    /// `insert_many` request — useful when upstream `StreamPage`s are already
493    /// sized for MongoDB's preferred per-request limits.
494    async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
495        if records.is_empty() {
496            return Ok(0);
497        }
498
499        // Upsert / delete routing: plan the page (dedup last-write-wins, strip
500        // the delete marker) and apply per-document `replace_one(upsert)` /
501        // `delete_one` ops. Append falls through to the `insert_many` fast path.
502        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        // `batch_size = 0` is the "no batching" sentinel: forward whatever
519        // upstream handed us as a single `insert_many`, preserving
520        // `StreamPage` framing. Otherwise re-chunk into `batch_size` slices.
521        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    /// Write a batch and report per-row outcomes.
559    ///
560    /// In append mode this delegates to [`write_batch`](faucet_core::Sink::write_batch) and
561    /// maps a single success onto an all-`Ok(())` vector (the trait default).
562    /// In upsert/delete mode the good rows are applied (upserts + deletes), and
563    /// only the rows whose key could not be extracted (missing / null key) are
564    /// reported as `Err` so the pipeline routes them to the DLQ per-row instead
565    /// of sending the whole page.
566    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    /// Read the current watermark for `scope` from the
593    /// [`_faucet_commit_token`](faucet_core::idempotency::COMMIT_TOKEN_TABLE)
594    /// collection. Returns `Ok(None)` for an unknown scope (or a missing
595    /// collection). The token is opaque and returned verbatim — never parsed.
596    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    /// Write one page and its commit-token watermark **atomically**, inside a
606    /// single multi-document transaction.
607    ///
608    /// Requires a **replica set or sharded cluster** — MongoDB transactions
609    /// are unavailable on a standalone server, and that failure is surfaced
610    /// as a self-explanatory typed error.
611    ///
612    /// Semantics on this path (vs. the at-least-once `write_batch`):
613    /// - **One page = one transaction.** Append mode inserts the whole page
614    ///   in a single `insert_many` — `batch_size` re-chunking does NOT apply
615    ///   here (chunking would break page↔watermark atomicity). Size pages via
616    ///   the source's `batch_size` instead.
617    /// - **Upsert/delete ops run sequentially** with the session (a
618    ///   `ClientSession` cannot be used concurrently), trading the
619    ///   at-least-once path's concurrent fan-out for atomicity.
620    /// - The commit is retried while the driver reports
621    ///   `UnknownTransactionCommitResult` (bounded), per the driver's
622    ///   recommended pattern; any other failure aborts the transaction
623    ///   (best-effort) and surfaces the original error.
624    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        // Prepare (plan + convert to BSON) BEFORE the transaction so a
633        // key-extraction or conversion failure aborts without ever opening a
634        // transaction — mirroring the postgres sink's fail-fast on plan errors.
635        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                // Best-effort abort so the server releases the transaction's
669                // locks promptly; the original error is what matters.
670                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        // Driver-recommended commit retry: `commit_transaction` is itself
678        // retryable while the error carries the UnknownTransactionCommitResult
679        // label (e.g. a primary failover mid-commit). Bounded so a persistent
680        // outage surfaces instead of spinning forever.
681        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    // dataset_uri test is skipped: MongoSink::new() requires a live MongoDB
724    // connection (Client::with_uri_str connects in new()), and no offline
725    // constructor exists.
726
727    #[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        // and it converts to a bson filter Document via the sink's converter:
737        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    // -- exactly-once pure helpers ------------------------------------------
800
801    #[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        // Tokens may carry '#' + JSON payload; the doc must store it verbatim.
823        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        // The canonical standalone-server message (code 20, IllegalOperation).
861        assert!(super::is_transactions_unsupported(
862            "Command failed: Transaction numbers are only allowed on a replica set member or mongos"
863        ));
864        // Case-insensitive.
865        assert!(super::is_transactions_unsupported(
866            "TRANSACTION NUMBERS ARE ONLY ALLOWED ON A REPLICA SET MEMBER OR MONGOS"
867        ));
868        // The Rust driver's rewrite of the same server error (what actually
869        // surfaces from a standalone `mongod` through mongodb v3).
870        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        // Alternate phrasing.
876        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}