rivet/source/mongo/mod.rs
1//! **Layer: Execution** — MongoDB source engine (document store).
2//!
3//! Unlike the three SQL engines (PostgreSQL / MySQL / SQL Server), MongoDB has
4//! no SQL, no fixed per-collection schema, and no `information_schema`. So the
5//! SQL-shaped read seam — chunked/keyset planning, incremental predicate
6//! building, catalog introspection — does **not** apply. This adapter is the
7//! **OSS JSON-blob model**: every document exports as exactly two columns —
8//! `_id` (`Utf8`, the document key stringified: ObjectId → hex, etc.) and
9//! `document` (`Utf8` + the `arrow.json` extension type, the whole BSON
10//! document rendered as relaxed extended JSON).
11//!
12//! Per-field typing is punted downstream (`PARSE_JSON` in the warehouse). Schema
13//! inference / auto-discovery is deliberately **out of scope for OSS** (it is the
14//! paid-tier `discover` mechanism); the user gets a lossless blob, not a guessed
15//! columnar schema. A future `columns:` projection can carve typed columns out of
16//! the blob without changing this default.
17//!
18//! ## Sync bridge (ADR-0011)
19//!
20//! The `mongodb` driver is async (tokio); the [`Source`] trait is sync
21//! `&mut self`. Like the MSSQL/tiberius engine, each [`MongoSource`] owns a tokio
22//! runtime and `block_on`s every driver call — no async leaks into the runner.
23//!
24//! ## Scope
25//!
26//! `mode: full` only (incremental / chunked / time-window are SQL-runner concepts
27//! and are refused with an actionable error). Within full mode, `source.mongo.
28//! page_size` opts into **keyset (seek) paging** on `_id` — bounded query time,
29//! per-page parts, optional cross-run **resume** (`resume: true`, typed BSON
30//! checkpoint), and `parallel: N` **`_id`-range fan-out** (any BSON `_id`). CDC
31//! (change streams) rides the canonical `ChangeStream` seam and is added
32//! separately.
33
34use std::sync::Arc;
35
36use arrow::array::{ArrayRef, StringBuilder};
37use arrow::datatypes::{Schema, SchemaRef};
38use arrow::record_batch::RecordBatch;
39use futures_util::TryStreamExt;
40use mongodb::Client;
41use mongodb::bson::oid::ObjectId;
42use mongodb::bson::{Bson, Document, doc};
43use mongodb::options::{ClientOptions, CollectionOptions, ReadConcern};
44use tokio::runtime::Runtime;
45
46use crate::config::{MongoConfig, MongoJsonMode, MongoReadConcern, TlsConfig};
47use crate::error::Result;
48use crate::source::{BatchSink, ExportRequest, Source};
49use crate::types::{ColumnOverrides, RivetType, SourceColumn, TypeMapping};
50
51pub(crate) mod cdc;
52
53/// The byte budget at which a document batch flushes. The `document` column is an
54/// Arrow `Utf8` (i32 offsets): a batch accumulating > 2 GiB panics ("byte array
55/// offset overflow"). Large documents (up to 16 MB each) hit that far before the
56/// row cap (~65 K rows), so a row-only limit is unsafe for a document store —
57/// `None` (the default in every tuning profile) MUST NOT mean "unbounded".
58/// Default 256 MiB: bounds RSS (the correct blob knob) and keeps every batch well
59/// under the i32 ceiling (bug-hunt: 2300×1 MB docs panicked on the default).
60fn mongo_batch_byte_cap(max_batch_memory_mb: Option<usize>) -> usize {
61 const DEFAULT: usize = 256 * 1024 * 1024;
62 // Keep the cap STRICTLY below the i32 offset ceiling. `document` is an Arrow
63 // Utf8 (i32 offsets), so a flush threshold >= 2 GiB lets a batch's cumulative
64 // value buffer overflow i32::MAX on `append_value` and panic ("byte array
65 // offset overflow") BEFORE the byte-cap flush check fires — the exact crash
66 // the cap exists to prevent. The default is already sub-2 GiB, but a user
67 // override (`max_batch_memory_mb: 2048`, a plausible "give it 2 GB") had no
68 // upper clamp. Clamp to 1 GiB so a full batch + one more 16 MB doc still fits.
69 const MAX: usize = 1024 * 1024 * 1024;
70 max_batch_memory_mb
71 .map(|mb| mb.saturating_mul(1024 * 1024).min(MAX))
72 .filter(|&b| b > 0)
73 .unwrap_or(DEFAULT)
74}
75
76/// Map rivet's [`TlsConfig`] onto the mongodb driver's `Tls`, mirroring the SQL
77/// engines' posture: `Disable` = plaintext; `Require` = TLS but accept any cert
78/// (sniff-proof, not MITM-proof); `VerifyCa` = verify chain, skip hostname;
79/// `VerifyFull` = verify both. The per-field `accept_invalid_*` flags widen (but
80/// never narrow) the mode. Without this the driver ignored the block entirely.
81fn tls_to_mongo(cfg: &TlsConfig) -> mongodb::options::Tls {
82 use crate::config::TlsMode;
83 use mongodb::options::{Tls, TlsOptions};
84 if matches!(cfg.mode, TlsMode::Disable) {
85 return Tls::Disabled;
86 }
87 // Only `Require` waives certificate verification; `VerifyCa`/`VerifyFull`
88 // both verify the chain. (This driver exposes no hostname-only toggle, so
89 // `VerifyCa` verifies the hostname too — stricter than the SQL engines, never
90 // weaker.) The per-field `accept_invalid_certs` flag widens, never narrows.
91 let mode_invalid_cert = matches!(cfg.mode, TlsMode::Require);
92 let mut o = TlsOptions::default();
93 o.ca_file_path = cfg.ca_file.as_ref().map(std::path::PathBuf::from);
94 o.allow_invalid_certificates = Some(mode_invalid_cert || cfg.accept_invalid_certs);
95 Tls::Enabled(o)
96}
97
98/// A connected MongoDB session: the async client plus the tokio runtime that
99/// drives it, so the sync `Source` trait can `block_on` every driver call
100/// (ADR-0011, mirrors MSSQL/tiberius). This is the **one** place that owns the
101/// async→sync bridge — the SDAM-needs-a-worker-thread runtime, the connect +
102/// ping handshake, the borrow dance — reused by the source, the harm / count
103/// probes, and `rivet init`.
104pub struct MongoSession {
105 rt: Runtime,
106 client: Client,
107 /// Database resolved from the connection URL path (`mongodb://…/<db>`).
108 db: String,
109}
110
111impl MongoSession {
112 /// Connect + `ping`. `gate` applies the shared remote-plaintext TLS refusal
113 /// (`require_tls_or_loopback`, CWE-319); `rivet init` passes `false` (dev
114 /// convenience, like the SQL init helpers), every other caller `true`.
115 pub fn connect(url: &str, tls: Option<&TlsConfig>, gate: bool) -> Result<Self> {
116 if gate {
117 crate::source::require_tls_or_loopback(url, tls)?;
118 }
119 // Small multi-thread runtime (not current-thread): the driver spawns
120 // background SDAM/heartbeat tasks that must make progress independently
121 // of our `block_on` calls, or connection monitoring starves.
122 let rt = tokio::runtime::Builder::new_multi_thread()
123 .worker_threads(2)
124 .enable_all()
125 .build()?;
126 let (client, db) = rt.block_on(async {
127 let mut opts = ClientOptions::parse(url).await?;
128 // Honor the `tls:` block. Without this the driver used only the URL's
129 // `?tls=` — so `tls: { mode: verify-full }` on a URL that didn't opt
130 // in connected in PLAINTEXT, the exact posture the operator asked to
131 // forbid (bug-hunt find). `None` ⇒ leave the URL's own tls setting.
132 if let Some(cfg) = tls {
133 opts.tls = Some(tls_to_mongo(cfg));
134 }
135 let db = opts.default_database.clone().ok_or_else(|| {
136 anyhow::anyhow!(
137 "mongodb url must include a database: mongodb://user:pass@host:port/<db>"
138 )
139 })?;
140 let client = Client::with_options(opts)?;
141 // Round-trip so a bad host/auth fails at connect, not first read.
142 client.database(&db).run_command(doc! { "ping": 1 }).await?;
143 Ok::<_, anyhow::Error>((client, db))
144 })?;
145 Ok(Self { rt, client, db })
146 }
147
148 pub fn client(&self) -> &Client {
149 &self.client
150 }
151 pub fn db(&self) -> &str {
152 &self.db
153 }
154 /// Drive a future to completion on the owned runtime.
155 pub fn block_on<T>(&self, fut: impl std::future::Future<Output = T>) -> T {
156 self.rt.block_on(fut)
157 }
158}
159
160/// MongoDB source over a [`MongoSession`], carrying the resolved `source.mongo:`
161/// read options `export` applies.
162pub struct MongoSource {
163 session: MongoSession,
164 /// Render the `document` column as canonical (type-tagged) extended JSON.
165 canonical_json: bool,
166 /// Scan under `readConcern: snapshot` (point-in-time; 5.0+ replica set).
167 snapshot: bool,
168 /// Set `noCursorTimeout` on the scan cursor.
169 no_cursor_timeout: bool,
170 /// A disjoint `_id` slice `[lo, hi)` for ONE parallel worker. When `Some`,
171 /// every scan (keyset page or full) is bounded to `{_id: {$gte: lo, $lt:
172 /// hi}}` (composed with the keyset `$gt` cursor). Bounds are `Bson` — a slice
173 /// works for ANY `_id` type (ObjectId, integer, string), with `MinKey`/
174 /// `MaxKey` as the outer sentinels. Set per-worker by the parallel runner;
175 /// ranges tile the whole BSON key space so their union is the full collection
176 /// with no overlap. `_id` is immutable ⇒ no doc migrates between ranges.
177 /// `None` ⇒ the whole collection (the normal single reader).
178 id_range: Option<(Bson, Bson)>,
179 /// Collections already vetted by [`Self::ensure_uniform_id_type`] this run —
180 /// the guard runs on the FIRST keyset page of every run, including a
181 /// checkpointed resume (`after_id` present). Gating it on `after_id.is_none()`
182 /// let `resume: true` bypass it forever: an `_id` of a new BSON band inserted
183 /// after run 1 was silently unreachable on every later run (bug-hunt find).
184 id_guard_checked: std::collections::HashSet<String>,
185}
186
187impl MongoSource {
188 /// Connect, resolving the optional `source.mongo:` read options (absent ⇒
189 /// relaxed JSON, server read concern, cursor kept alive). Used by
190 /// `create_source` (with the config block) and the `doctor` / type-report
191 /// probes (with `None`).
192 pub fn connect(url: &str, tls: Option<&TlsConfig>, cfg: Option<&MongoConfig>) -> Result<Self> {
193 let session = MongoSession::connect(url, tls, true)?;
194 let (canonical_json, snapshot, no_cursor_timeout) = match cfg {
195 None => (false, false, true),
196 Some(c) => (
197 matches!(c.json, MongoJsonMode::Canonical),
198 matches!(c.read_concern, MongoReadConcern::Snapshot),
199 c.no_cursor_timeout,
200 ),
201 };
202 Ok(Self {
203 session,
204 canonical_json,
205 snapshot,
206 no_cursor_timeout,
207 id_range: None,
208 id_guard_checked: std::collections::HashSet::new(),
209 })
210 }
211
212 /// Bind this reader to one disjoint `_id` slice `[lo, hi)` (a parallel
213 /// worker). Consuming builder — each worker owns its own `MongoSource`.
214 pub fn with_id_range(mut self, lo: Bson, hi: Bson) -> Self {
215 self.id_range = Some((lo, hi));
216 self
217 }
218
219 /// The min and max `_id` of `collection` by BSON sort order (one index-bound
220 /// `find_one` each), or `None` if the collection is empty. Brackets are
221 /// *contiguous* in BSON sort order, so the endpoints span every bracket
222 /// present — comparing them detects any heterogeneous-`_id` mix. Shared by the
223 /// keyset *refusal* ([`Self::ensure_uniform_id_type`]) and the full-scan
224 /// *warning* ([`Self::warn_if_heterogeneous_id`]).
225 fn id_span(&self, collection: &str) -> Result<Option<(Document, Document)>> {
226 let coll = self
227 .session
228 .client()
229 .database(self.session.db())
230 .collection::<Document>(collection);
231 self.session.block_on(async {
232 let lo = coll.find_one(doc! {}).sort(doc! { "_id": 1 }).await?;
233 let hi = coll.find_one(doc! {}).sort(doc! { "_id": -1 }).await?;
234 Ok::<_, anyhow::Error>(lo.zip(hi))
235 })
236 }
237
238 /// Guard against silent loss on a heterogeneous-`_id` collection. Keyset
239 /// paging seeks with `$gt`/`$lt`, which MongoDB **type-brackets**: a numeric
240 /// cursor never matches a string `_id` even though strings sort after numbers,
241 /// so a collection mixing `_id` types would page only the first bracket and
242 /// silently drop the rest (verified: an int+string collection reads 50%). We
243 /// compare the BSON bracket of the min and max `_id` (a single bracket is a
244 /// contiguous band, so differing endpoints ⟺ a bracket jump exists) and refuse
245 /// keyset/parallel, pointing at the full scan — a single ordered cursor, which
246 /// DOES cross brackets. Numeric types share bracket 0, so a mixed
247 /// Int32/Int64/Double `_id` still keysets fine.
248 pub(crate) fn ensure_uniform_id_type(&self, collection: &str) -> Result<()> {
249 if let Some((lo, hi)) = self.id_span(collection)?
250 && let (Some(lo_id), Some(hi_id)) = (lo.get("_id"), hi.get("_id"))
251 {
252 // NaN never matches $gt/$gte/$lt against a non-NaN operand: a keyset
253 // page boundary landing ON the NaN row drops the whole remaining
254 // collection, and every parallel range excludes it. NaN sorts FIRST
255 // in the numeric band, so the min probe always sees it (the max
256 // probe covers the all-NaN corner).
257 for id in [lo_id, hi_id] {
258 if is_id_nan(id) {
259 anyhow::bail!(
260 "collection '{collection}' has a NaN `_id`: MongoDB range \
261 operators never match NaN, so keyset paging / parallel \
262 ranges would silently skip documents. Remove `page_size` \
263 and `parallel` to use a full ordered scan (a single \
264 cursor reads every document), or fix the `_id`."
265 );
266 }
267 }
268 // Unknown band is refused too (see `id_types_collide`): if we cannot
269 // place the type we cannot promise the seek crosses it — loud beats a
270 // silent gap.
271 if id_types_collide(lo_id, hi_id) {
272 anyhow::bail!(
273 "collection '{collection}' has heterogeneous `_id` types \
274 ({:?} … {:?}): MongoDB keyset paging seeks with `$gt`, which is \
275 BSON-type-bracketed and would silently skip every `_id` type but \
276 one. Remove `page_size` and `parallel` to use a full ordered scan \
277 (a single cursor crosses BSON types), or normalise `_id` to one type.",
278 lo_id.element_type(),
279 hi_id.element_type(),
280 );
281 }
282 }
283 Ok(())
284 }
285
286 /// Advisory sibling of [`Self::ensure_uniform_id_type`] for the **full-scan**
287 /// path (no `page_size`, no `parallel`): a full ordered cursor DOES cross BSON
288 /// brackets, so a heterogeneous-`_id` collection is read completely — no loss.
289 /// But the flat `_id` **display column** stringifies distinct BSON types to
290 /// possibly-colliding text (int `1001` and string `"1001"` both → `"1001"`), so
291 /// a warehouse merge keyed on `_id` would conflate them. Warn once, up front,
292 /// and point at the typed `document._id` (see docs). Best-effort: a probe
293 /// failure is silent (advisory, never a gate).
294 pub(crate) fn warn_if_heterogeneous_id(&self, collection: &str) {
295 let Ok(Some((lo, hi))) = self.id_span(collection) else {
296 return;
297 };
298 if let (Some(lo_id), Some(hi_id)) = (lo.get("_id"), hi.get("_id"))
299 && id_types_collide(lo_id, hi_id)
300 {
301 log::warn!(
302 "collection '{collection}' has heterogeneous `_id` types ({:?} … {:?}): {}",
303 lo_id.element_type(),
304 hi_id.element_type(),
305 hetero_id_guidance()
306 );
307 }
308 }
309
310 /// Split the collection's `_id` space into `n` disjoint, size-balanced
311 /// ranges `[lo, hi)` whose union tiles the whole BSON key space (so the union
312 /// read is the complete collection, no overlap) — for ANY `_id` type, not
313 /// just ObjectId. Boundaries come from a `$sample` of the `_id`s (a
314 /// WiredTiger random cursor — O(sample), NOT a collection scan, unlike
315 /// `$bucketAuto` which examines every doc), then the N−1 quantiles of the
316 /// sorted sample. Outer bounds are `MinKey`/`MaxKey`, which sort before/after
317 /// every value, so the first/last range cannot miss the true extremes.
318 pub fn sample_id_ranges(&self, collection: &str, n: usize) -> Result<Vec<(Bson, Bson)>> {
319 self.ensure_uniform_id_type(collection)?;
320 let n = n.max(1);
321 let full_range = || vec![(Bson::MinKey, Bson::MaxKey)];
322 if n == 1 {
323 return Ok(full_range());
324 }
325 // ~250 samples per target range (min 2000), capped so a huge N stays well
326 // under the 5%-of-collection threshold that flips $sample to a full sort.
327 let sample_size = (n * 250).clamp(2000, 50_000);
328 let coll = self
329 .session
330 .client()
331 .database(self.session.db())
332 .collection::<Document>(collection);
333 let ids: Vec<Bson> = self.session.block_on(async move {
334 let mut cursor = coll
335 .aggregate(vec![
336 doc! { "$sample": { "size": sample_size as i64 } },
337 doc! { "$sort": { "_id": 1 } },
338 doc! { "$project": { "_id": 1 } },
339 ])
340 .await?;
341 let mut ids = Vec::new();
342 while let Some(d) = cursor.try_next().await? {
343 // `$sort` yields the sampled `_id`s in BSON order; any type is fine
344 // (the range filter compares by the same order the server sorts by).
345 if let Some(id) = d.get("_id") {
346 ids.push(id.clone());
347 }
348 }
349 Ok::<_, anyhow::Error>(ids)
350 })?;
351 if ids.len() < 2 {
352 // Too few docs to split meaningfully — one range over everything.
353 return Ok(full_range());
354 }
355 // N−1 interior quantile boundaries → N ranges; MinKey/MaxKey at the ends
356 // sort before/after every value, so the outer slices cover the extremes
357 // for ANY `_id` type.
358 let mut bounds = vec![Bson::MinKey];
359 for i in 1..n {
360 bounds.push(ids[i * ids.len() / n].clone());
361 }
362 bounds.push(Bson::MaxKey);
363 // Adjacent quantiles can land on the same value in a skewed sample; drop
364 // the resulting empty `[x, x)` so a worker never gets a no-op range (the
365 // dropped value is still covered by the next range's `$gte`).
366 Ok(bounds
367 .windows(2)
368 .filter(|w| w[0] != w[1])
369 .map(|w| (w[0].clone(), w[1].clone()))
370 .collect())
371 }
372}
373
374/// The two fixed columns of the JSON-blob model. Used by both the export schema
375/// and `type_mappings`, so `check --type-report` and the export agree.
376fn blob_mappings() -> Vec<TypeMapping> {
377 vec![
378 TypeMapping::from_source(
379 &SourceColumn::simple("_id", "objectid", false),
380 RivetType::String,
381 ),
382 TypeMapping::from_source(
383 // Nullable: a CDC DELETE with no pre-image (pre/post-images not
384 // enabled, or < 6.0) carries only `_id`, so `document` is NULL. A
385 // batch export never writes a null here — nullable is a safe superset.
386 &SourceColumn::simple("document", "document", true),
387 RivetType::Json,
388 ),
389 ]
390}
391
392/// Arrow schema for the JSON-blob model (`_id: Utf8`, `document: Utf8 + json`).
393fn blob_schema() -> SchemaRef {
394 let fields = blob_mappings()
395 .iter()
396 .map(|m| {
397 crate::types::build_arrow_field(m)
398 .expect("blob columns (String/Json) always have an Arrow mapping")
399 })
400 .collect::<Vec<_>>();
401 Arc::new(Schema::new(fields))
402}
403
404/// Stringify a document `_id` for the `_id` column. ObjectId → 24-char hex;
405/// string/number keys → their natural text; anything exotic (binary, compound)
406/// → its relaxed extended JSON, so the column is always populated and never
407/// silently null.
408fn id_to_string(id: Option<&Bson>) -> String {
409 match id {
410 Some(Bson::ObjectId(oid)) => oid.to_hex(),
411 Some(Bson::String(s)) => s.clone(),
412 // Ints, bools, etc. render as their bare relaxed-extjson value (`42`,
413 // `true`); ObjectId/String are special-cased above only to drop the
414 // `{"$oid":…}` wrapper / surrounding quotes.
415 Some(other) => other.clone().into_relaxed_extjson().to_string(),
416 // A document with no `_id` is not possible in MongoDB (the server
417 // always assigns one), but never panic on malformed input.
418 None => String::new(),
419 }
420}
421
422/// Encode a BSON `_id` as a lossless, engine-decodable keyset token: the raw
423/// BSON bytes of `{_id: <value>}`, hex-encoded. Unlike the display `_id` column
424/// (hex/text — type-ambiguous, e.g. integer `1001` vs string `"1001"`), this
425/// preserves the exact BSON type across the string-typed cursor seam, so keyset
426/// paging + resume work for ANY `_id`, not just ObjectId.
427/// The BSON comparison *band* an `_id` falls in for a `$gt`/`$lt` keyset seek.
428/// MongoDB only compares within a band; the server's sort order is
429/// `MinKey < Null < Numbers < String/Symbol < Object < Array < BinData <
430/// ObjectId < Boolean < Date < Timestamp < Regex < MaxKey`. The four numeric
431/// types share one band (a mixed Int32/Int64/Double `_id` keysets fine); every
432/// other band is its own bracket — a former catch-all arm lumped Null, Object,
433/// Array and Regex together, so a `null` + `{…}` collection slipped past the
434/// guard and silently lost a band (bug-hunt find). `None` = a type we cannot
435/// place (MinKey/MaxKey/JS/DbPointer/…) — the caller refuses keyset rather than
436/// guessing. Two `_id`s in different brackets ⟹ un-keyset-able.
437/// A numeric `_id` that is NaN. MongoDB range operators (`$gt`/`$gte`/`$lt`)
438/// never match NaN against any operand, so a keyset page boundary landing on it
439/// drops the rest of the collection and every parallel range excludes it. BOTH
440/// forms must be caught: a 64-bit-float NaN (`Bson::Double`) AND a Decimal128 NaN
441/// (`NumberDecimal("NaN")`) — the latter lands in the numeric band via
442/// [`id_bracket`] but slipped past a Double-only check and was silently dropped.
443/// A finite decimal renders as digits/sign/`.`/`E`, so a lowercased `"nan"`
444/// substring is an unambiguous NaN test (covers `NaN`, `-NaN`, signaling `sNaN`).
445fn is_id_nan(id: &Bson) -> bool {
446 match id {
447 Bson::Double(f) => f.is_nan(),
448 Bson::Decimal128(d) => d.to_string().to_ascii_lowercase().contains("nan"),
449 _ => false,
450 }
451}
452
453fn id_bracket(v: &Bson) -> Option<u8> {
454 Some(match v {
455 Bson::Double(_) | Bson::Int32(_) | Bson::Int64(_) | Bson::Decimal128(_) => 0,
456 Bson::Null => 1,
457 Bson::String(_) | Bson::Symbol(_) => 2,
458 Bson::Document(_) => 3,
459 Bson::Array(_) => 4,
460 Bson::Binary(_) => 5,
461 Bson::ObjectId(_) => 6,
462 Bson::Boolean(_) => 7,
463 Bson::DateTime(_) => 8,
464 Bson::Timestamp(_) => 9,
465 Bson::RegularExpression(_) => 10,
466 _ => return None,
467 })
468}
469
470/// Whether two `_id` values fall in DIFFERENT keyset brackets — i.e. the
471/// collection mixes BSON `_id` types. An unplaceable type (`id_bracket` = `None`)
472/// counts as colliding: we can neither seek across it (keyset) nor vouch its
473/// display text won't collide (full scan). Pure, so the keyset refusal and the
474/// full-scan warning share one notion of "heterogeneous `_id`".
475fn id_types_collide(lo: &Bson, hi: &Bson) -> bool {
476 match (id_bracket(lo), id_bracket(hi)) {
477 (Some(a), Some(b)) => a != b,
478 _ => true,
479 }
480}
481
482/// The shared downstream-guidance tail of the heterogeneous-`_id` warning (batch
483/// full scan and CDC both emit it). The flat `_id` column renders different BSON
484/// types to possibly-colliding text, so a warehouse merge keyed on `_id` conflates
485/// distinct documents — the typed value is always in `document._id`.
486pub(super) fn hetero_id_guidance() -> &'static str {
487 "the flat `_id` column renders different BSON types to possibly-colliding text \
488 (int 1001 and string \"1001\" both become \"1001\"), so a downstream merge keyed \
489 on `_id` conflates distinct documents — key on the typed `document._id` instead \
490 (see docs/reference/mongodb.md#consuming-in-the-warehouse)"
491}
492
493fn encode_id_cursor(id: &Bson) -> String {
494 let mut buf = Vec::new();
495 doc! { "_id": id.clone() }
496 .to_writer(&mut buf)
497 .expect("a one-field BSON document always serializes");
498 bytes_to_hex(&buf)
499}
500
501/// Lower-case hex of raw bytes — the string half of the BSON-token round-trip
502/// (paired with [`hex_to_bytes`]), used to carry a lossless BSON value across a
503/// string-typed seam (the keyset cursor and the CDC resume token).
504pub(super) fn bytes_to_hex(bytes: &[u8]) -> String {
505 bytes.iter().map(|b| format!("{b:02x}")).collect()
506}
507
508/// Inverse of [`encode_id_cursor`]. Falls back to a bare 24-char ObjectId hex so
509/// a cursor written before the typed token (or a value read from the display
510/// column) still resolves.
511fn decode_id_cursor(token: &str) -> Result<Bson> {
512 if let Ok(bytes) = hex_to_bytes(token)
513 && let Ok(doc) = Document::from_reader(&bytes[..])
514 && let Some(id) = doc.get("_id")
515 {
516 return Ok(id.clone());
517 }
518 ObjectId::parse_str(token).map(Bson::ObjectId).map_err(|_| {
519 anyhow::anyhow!("MongoDB keyset cursor '{token}' is neither a typed token nor ObjectId hex")
520 })
521}
522
523fn hex_to_bytes(s: &str) -> Result<Vec<u8>> {
524 // The 2-byte slices below index by BYTE; a multi-byte char boundary would
525 // panic. A corrupted / foreign token is an error, never a panic.
526 if !s.is_ascii() {
527 anyhow::bail!("invalid hex cursor token (non-ASCII)");
528 }
529 if !s.len().is_multiple_of(2) {
530 anyhow::bail!("odd-length hex cursor token");
531 }
532 (0..s.len())
533 .step_by(2)
534 .map(|i| u8::from_str_radix(&s[i..i + 2], 16).map_err(|e| anyhow::anyhow!(e)))
535 .collect()
536}
537
538/// Render a whole BSON document as an extended-JSON string. `canonical=false`
539/// (relaxed) keeps common scalars native (`42`, `"x"`, `true`, nested
540/// objects/arrays) while still wrapping exotic BSON types losslessly
541/// (`{"$oid":…}`, `{"$date":…}`, `{"$numberDecimal":…}`) — friendliest for a
542/// downstream `PARSE_JSON`. `canonical=true` type-tags every value
543/// (`{"$numberLong":…}`) so Int64/Double survive a double-based JSON-number
544/// parser that would otherwise clamp values beyond 2^53.
545fn document_to_json(doc: &Document, canonical: bool) -> Result<String> {
546 let bson = Bson::Document(doc.clone());
547 let value = if canonical {
548 bson.into_canonical_extjson()
549 } else {
550 bson.into_relaxed_extjson()
551 };
552 Ok(serde_json::to_string(&value)?)
553}
554
555/// Finish the accumulated builders into a `RecordBatch` and hand it to the sink.
556fn flush(
557 schema: &SchemaRef,
558 ids: &mut StringBuilder,
559 docs: &mut StringBuilder,
560 sink: &mut dyn BatchSink,
561) -> Result<()> {
562 let columns: Vec<ArrayRef> = vec![Arc::new(ids.finish()), Arc::new(docs.finish())];
563 let batch = RecordBatch::try_new(schema.clone(), columns)?;
564 sink.on_batch(&batch)?;
565 Ok(())
566}
567
568impl Source for MongoSource {
569 fn export(&mut self, request: &ExportRequest<'_>, sink: &mut dyn BatchSink) -> Result<()> {
570 // The structured read-intent: the bare collection behind the `table:`
571 // shortcut (ADR-0027). `None` ⇒ a hand-written `query:` or a
572 // filtered/wrapped form, which has no MongoDB equivalent.
573 let coll_name = request.base_relation.ok_or_else(|| {
574 anyhow::anyhow!(
575 "MongoDB source supports only `table: <collection>` with `mode: full` — a \
576 hand-written `query:` or a filtered/wrapped form has no MongoDB equivalent. \
577 Got query: {}",
578 request.query
579 )
580 })?;
581 let schema = blob_schema();
582 sink.on_schema(schema.clone())?;
583
584 // `readConcern: snapshot` must ride the collection handle; a plain scan
585 // uses the default handle. The scalar opts are copied into locals so the
586 // async scan closure doesn't borrow `self`.
587 let db = self.session.client().database(self.session.db());
588 let coll = if self.snapshot {
589 db.collection_with_options::<Document>(
590 coll_name,
591 CollectionOptions::builder()
592 .read_concern(ReadConcern::snapshot())
593 .build(),
594 )
595 } else {
596 db.collection::<Document>(coll_name)
597 };
598 let canonical = self.canonical_json;
599 let no_cursor_timeout = self.no_cursor_timeout;
600 let id_range = self.id_range.clone();
601 // Source-side batch = the RSS lever. Two caps, whichever trips first:
602 // • `tuning.batch_size` — a row count (schema-based memory budget for a
603 // document store is unreliable: the `document` column is variable
604 // length, so a row estimate off the Arrow schema misfires);
605 // • `tuning.max_batch_memory_mb` — a hard byte budget on the batch,
606 // the *correct* RSS knob for a JSON blob (flush by accumulated bytes,
607 // independent of how big each document is).
608 let batch_rows = request.tuning.effective_batch_size(Some(&schema)).max(1);
609 let batch_byte_cap = mongo_batch_byte_cap(request.tuning.max_batch_memory_mb);
610
611 // Keyset (seek) pagination: the keyset runner drives the outer loop and
612 // hands us one page's `page_limit` plus the previous page's max `_id`
613 // as a lossless typed token (`set_source_cursor` → `decode_id_cursor`).
614 // We page `find({_id:{$gt:after}}).sort({_id:1}).limit(n)` — a bounded,
615 // index-driven range scan. `page_limit` unset ⇒ one full scan. Because
616 // the token carries the exact BSON type (not a type-ambiguous hex/text
617 // rendering), keyset works for ANY `_id` — ObjectId, integer, string —
618 // and MongoDB's own `$gt`/`sort` provide the ordering.
619 let page_limit = request.page_limit;
620 let after_id: Option<Bson> =
621 match request.cursor.and_then(|c| c.last_cursor_value.as_deref()) {
622 Some(token) => Some(decode_id_cursor(token)?),
623 None => None,
624 };
625
626 // Single-worker keyset, first page of THIS run — including a checkpointed
627 // resume: refuse a heterogeneous-`_id` collection up front. `$gt` is
628 // type-bracketed and would silently drop every `_id` band but the
629 // cursor's; a resume whose cursor predates newly-inserted documents of a
630 // different band would otherwise skip them forever while reporting
631 // success (the guard must NOT be gated on `after_id.is_none()`).
632 // (Parallel checks this in `sample_id_ranges`; a full scan — `page_limit`
633 // unset — is exempt because its single cursor crosses brackets.)
634 if page_limit.is_some() && id_range.is_none() && !self.id_guard_checked.contains(coll_name)
635 {
636 self.ensure_uniform_id_type(coll_name)?;
637 self.id_guard_checked.insert(coll_name.to_string());
638 }
639
640 // Full scan (no page_size, not a parallel worker) is the ONE path that
641 // permits a heterogeneous-`_id` collection — its single cursor crosses BSON
642 // brackets, so the read is complete (keyset/parallel refuse it above). But
643 // the flat `_id` display column can collide across types downstream, so warn
644 // once, up front. Advisory only — never gates the run.
645 if page_limit.is_none() && id_range.is_none() {
646 self.warn_if_heterogeneous_id(coll_name);
647 }
648
649 let total = self.session.block_on(async {
650 // Lower bound: the keyset cursor (`$gt: after`) when resuming a page,
651 // else this worker's range floor (`$gte: lo`). Upper bound: the range
652 // ceiling (`$lt: hi`). A worker with no range and no cursor scans all.
653 let filter = {
654 let mut cond = Document::new();
655 match &after_id {
656 Some(id) => {
657 cond.insert("$gt", id.clone());
658 }
659 None => {
660 if let Some((lo, _)) = &id_range {
661 cond.insert("$gte", lo.clone());
662 }
663 }
664 }
665 if let Some((_, hi)) = &id_range {
666 cond.insert("$lt", hi.clone());
667 }
668 if cond.is_empty() {
669 doc! {}
670 } else {
671 doc! { "_id": cond }
672 }
673 };
674 let mut find = coll.find(filter);
675 if let Some(page) = page_limit {
676 find = find.sort(doc! { "_id": 1 }).limit(page as i64);
677 }
678 if no_cursor_timeout {
679 find = find.no_cursor_timeout(true);
680 }
681 let mut cursor = find.await?;
682 let mut ids = StringBuilder::new();
683 let mut docs = StringBuilder::new();
684 let mut in_batch = 0usize;
685 let mut batch_bytes = 0usize;
686 let mut total = 0usize;
687 // The page's max `_id` (the last row, since keyset sorts `_id` asc).
688 // Reported to the sink as a typed token so the runner advances by the
689 // exact BSON value, not the type-ambiguous display string.
690 let mut max_id: Option<Bson> = None;
691 while let Some(d) = cursor.try_next().await? {
692 let id = id_to_string(d.get("_id"));
693 let json = document_to_json(&d, canonical)?;
694 batch_bytes += id.len() + json.len();
695 ids.append_value(&id);
696 docs.append_value(&json);
697 if page_limit.is_some() {
698 max_id = d.get("_id").cloned();
699 }
700 in_batch += 1;
701 total += 1;
702 if in_batch >= batch_rows || batch_bytes >= batch_byte_cap {
703 flush(&schema, &mut ids, &mut docs, sink)?;
704 in_batch = 0;
705 batch_bytes = 0;
706 }
707 }
708 if in_batch > 0 {
709 flush(&schema, &mut ids, &mut docs, sink)?;
710 }
711 // Keyset only: hand the runner the exact BSON high-water mark.
712 if let Some(id) = &max_id {
713 sink.set_source_cursor(encode_id_cursor(id));
714 }
715 Ok::<usize, anyhow::Error>(total)
716 })?;
717
718 log::info!("total: {total} documents (collection '{coll_name}')");
719 Ok(())
720 }
721
722 /// The JSON-blob model has a fixed two-column schema regardless of the
723 /// query, so `check --type-report` shows it without touching the server.
724 fn type_mappings(
725 &mut self,
726 _query: &str,
727 _column_overrides: &ColumnOverrides,
728 ) -> Result<Vec<TypeMapping>> {
729 Ok(blob_mappings())
730 }
731
732 /// The only scalar rivet asks a `mode: full` source is the reconcile row
733 /// count — `SELECT COUNT(*) FROM (SELECT * FROM <coll>) AS _rivet_reconcile`.
734 /// Recognize that shape and answer it with `countDocuments` so
735 /// `rivet run --reconcile` verifies Mongo exports like the SQL engines. Any
736 /// other SQL scalar (cursor MIN/MAX, etc.) has no Mongo meaning → `None`,
737 /// which callers treat as "unavailable" and skip.
738 fn query_scalar(&mut self, sql: &str) -> Result<Option<String>> {
739 if !sql.to_ascii_lowercase().contains("count(") {
740 return Ok(None);
741 }
742 let Some(coll_name) = last_from_identifier(sql) else {
743 return Ok(None);
744 };
745 let coll = self
746 .session
747 .client()
748 .database(self.session.db())
749 .collection::<Document>(coll_name);
750 let n = self
751 .session
752 .block_on(async move { coll.count_documents(doc! {}).await })?;
753 Ok(Some(n.to_string()))
754 }
755
756 fn server_context(&mut self) -> Option<String> {
757 // buildInfo (version) + serverStatus (connections/uptime), block_on'd on the
758 // session runtime like every other Mongo driver call. serverStatus needs the
759 // clusterMonitor role on an authed deployment — best-effort, so a missing
760 // grant just drops those fields (version from buildInfo needs none).
761 let session = &self.session;
762 let (version, current, available, uptime) = session.block_on(async {
763 let db = session.client().database(session.db());
764 let build = db.run_command(doc! { "buildInfo": 1 }).await.ok();
765 let status = db.run_command(doc! { "serverStatus": 1 }).await.ok();
766 let version = build
767 .as_ref()
768 .and_then(|d| d.get_str("version").ok().map(str::to_string));
769 let current = status
770 .as_ref()
771 .and_then(|d| nested_i64(d, &["connections", "current"]));
772 let available = status
773 .as_ref()
774 .and_then(|d| nested_i64(d, &["connections", "available"]));
775 let uptime = status.as_ref().and_then(|d| nested_i64(d, &["uptime"]));
776 (version, current, available, uptime)
777 });
778 Some(
779 serde_json::json!({
780 "engine": "mongodb",
781 "version": version,
782 "connections_current": current,
783 "connections_available": available,
784 "uptime_s": uptime,
785 })
786 .to_string(),
787 )
788 }
789}
790
791/// Pull the collection name out of the innermost `… FROM <ident>` of a SQL
792/// string — the reconcile count wraps the base as
793/// `SELECT COUNT(*) FROM (SELECT * FROM <coll>) AS _rivet_reconcile`, so the
794/// *last* `FROM` names the collection. `None` if no bare identifier follows.
795///
796/// The batch export path no longer un-parses SQL — it reads the structured
797/// `ExportRequest::base_relation` (ADR-0027). This un-parser survives only
798/// because `query_scalar` receives a bare SQL string with no structured
799/// counterpart yet; giving the reconcile count a typed request would delete it
800/// too (the remaining tail of ADR-0027).
801fn last_from_identifier(sql: &str) -> Option<&str> {
802 const MARKER: &str = "from ";
803 let start = sql.to_ascii_lowercase().rfind(MARKER)? + MARKER.len();
804 let ident = sql[start..]
805 .trim_start()
806 .split(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '.'))
807 .next()?;
808 (!ident.is_empty()).then_some(ident)
809}
810
811/// Read a possibly-nested numeric field out of a `serverStatus` document,
812/// coercing Int32 / Int64 / Double to `i64`. `None` if any path segment is
813/// missing or the leaf is not numeric — so a storage engine that omits a
814/// section (e.g. in-memory has no `wiredTiger`) just drops that one metric.
815fn nested_i64(doc: &Document, path: &[&str]) -> Option<i64> {
816 let (leaf, parents) = path.split_last()?;
817 let mut cur = doc;
818 for key in parents {
819 cur = cur.get_document(key).ok()?;
820 }
821 match cur.get(leaf) {
822 Some(Bson::Int64(v)) => Some(*v),
823 Some(Bson::Int32(v)) => Some(*v as i64),
824 Some(Bson::Double(v)) => Some(*v as i64),
825 _ => None,
826 }
827}
828
829/// Pull the source-harm counters out of a `serverStatus` response. Only
830/// **cumulative monotonic** counters are emitted (never gauges), because the
831/// pipeline stores the before→after *delta* — the same contract as the SQL
832/// engines' `sample_harm_counters`.
833fn harm_from_server_status(status: &Document) -> Vec<(String, i64)> {
834 // (metric label, serverStatus path). Chosen as the Mongo analogues of the
835 // PG harm set: docs/keys *scanned* are the read-amplification signal
836 // (≈ `pg_tup_returned`), docs *returned* ≈ `pg_tup_fetched`, `getmore` is
837 // rivet's own streaming footprint (every cursor batch), and WiredTiger
838 // cache bytes-read is the I/O-vs-cache split (≈ `pg_blks_read`).
839 let probes: [(&str, &[&str]); 6] = [
840 (
841 "mongo_docs_scanned",
842 &["metrics", "queryExecutor", "scannedObjects"],
843 ),
844 (
845 "mongo_keys_scanned",
846 &["metrics", "queryExecutor", "scanned"],
847 ),
848 ("mongo_docs_returned", &["metrics", "document", "returned"]),
849 ("mongo_op_query", &["opcounters", "query"]),
850 ("mongo_op_getmore", &["opcounters", "getmore"]),
851 (
852 "mongo_wt_cache_bytes_read",
853 &["wiredTiger", "cache", "bytes read into cache"],
854 ),
855 ];
856 probes
857 .iter()
858 .filter_map(|(name, path)| nested_i64(status, path).map(|v| ((*name).to_string(), v)))
859 .collect()
860}
861
862/// Snapshot MongoDB's source-harm counters via `serverStatus` — the document-
863/// store analogue of the SQL engines' `sample_harm_counters`. Returns
864/// `(metric, cumulative_value)` pairs; the pipeline captures these before and
865/// after the export and stores the per-metric delta in `export_harm`.
866///
867/// These are **server-wide** cumulative counters, so concurrent activity
868/// inflates the delta; on a single-tenant pilot box it is the run's own read
869/// footprint. `serverStatus` needs the `clusterMonitor` role (or the
870/// `serverStatus` privilege) on authenticated deployments — `None` on any
871/// connect / permission / query failure, exactly like the SQL engines:
872/// harm metrics are observability, never a gate.
873pub(crate) fn sample_harm_counters(
874 url: &str,
875 tls: Option<&TlsConfig>,
876) -> Option<Vec<(String, i64)>> {
877 let session = MongoSession::connect(url, tls, true).ok()?;
878 session.block_on(async {
879 let status = session
880 .client()
881 .database(session.db())
882 .run_command(doc! { "serverStatus": 1 })
883 .await
884 .ok()?;
885 Some(harm_from_server_status(&status))
886 })
887}
888
889/// Scan-free document-count estimate for one collection — the preflight
890/// `row_estimate` (the Mongo analogue of PG `reltuples` / MySQL `TABLE_ROWS`).
891/// `estimatedDocumentCount` reads collection metadata, never scans the
892/// collection. `None` on any failure — the diagnostic then shows an unknown
893/// row count, exactly as MySQL does when its estimate is untrustworthy.
894pub(crate) fn estimated_count(url: &str, tls: Option<&TlsConfig>, collection: &str) -> Option<i64> {
895 let session = MongoSession::connect(url, tls, true).ok()?;
896 session.block_on(async {
897 let n = session
898 .client()
899 .database(session.db())
900 .collection::<Document>(collection)
901 .estimated_document_count()
902 .await
903 .ok()?;
904 i64::try_from(n).ok()
905 })
906}
907
908#[cfg(test)]
909mod tests {
910 use super::*;
911 use mongodb::bson::oid::ObjectId;
912
913 // W4: the byte-cap multiplications had no exact-value test — `mb * 1024 *
914 // 1024` mutating to `+` or `/` silently shrinks (or explodes) the flush
915 // budget that keeps document batches under the i32 offset ceiling.
916 #[test]
917 fn batch_byte_cap_is_exact_mib() {
918 assert_eq!(super::mongo_batch_byte_cap(Some(10)), 10 * 1024 * 1024);
919 assert_eq!(super::mongo_batch_byte_cap(None), 256 * 1024 * 1024);
920 // Zero is not a valid budget — falls back to the default.
921 assert_eq!(super::mongo_batch_byte_cap(Some(0)), 256 * 1024 * 1024);
922 }
923
924 #[test]
925 fn batch_byte_cap_is_clamped_below_the_i32_offset_ceiling() {
926 // RED before the clamp: `document` is an Arrow Utf8 (i32 offsets), so a cap
927 // >= 2 GiB let a batch overflow i32::MAX on append and PANIC before the
928 // flush check — the exact crash the cap prevents. A user "give it 2 GB"
929 // (max_batch_memory_mb: 2048) must be clamped, not passed through.
930 let ceiling = i32::MAX as usize;
931 for mb in [2048usize, 4096, 65_536, usize::MAX / (1024 * 1024)] {
932 let cap = super::mongo_batch_byte_cap(Some(mb));
933 assert!(
934 cap < ceiling,
935 "max_batch_memory_mb={mb} yields cap {cap} >= i32::MAX ({ceiling}) — would overflow the Utf8 offset builder"
936 );
937 }
938 // The clamp doesn't shrink a safe sub-ceiling value.
939 assert_eq!(super::mongo_batch_byte_cap(Some(256)), 256 * 1024 * 1024);
940 }
941
942 // Finding #1: the NaN `_id` guard caught only a Bson::Double NaN, so a
943 // Decimal128 NaN (`NumberDecimal("NaN")`) — which id_bracket places in the
944 // numeric band — slipped past and was silently dropped from every keyset /
945 // parallel range (its $gt/$lt never matches). is_id_nan must catch BOTH.
946 #[test]
947 fn nan_id_detected_for_double_and_decimal128() {
948 use mongodb::bson::Decimal128;
949 // 64-bit-float NaN.
950 assert!(is_id_nan(&Bson::Double(f64::NAN)));
951 // Decimal128 NaN (the form that used to slip past).
952 let dec_nan: Decimal128 = "NaN".parse().expect("bson parses NumberDecimal NaN");
953 assert!(
954 is_id_nan(&Bson::Decimal128(dec_nan)),
955 "a Decimal128 NaN _id must be caught, not silently keyset-dropped"
956 );
957 // Finite numeric _ids are NOT NaN (no false positive).
958 assert!(!is_id_nan(&Bson::Double(1.5)));
959 assert!(!is_id_nan(&Bson::Int64(1001)));
960 let dec_finite: Decimal128 = "1234.5".parse().unwrap();
961 assert!(!is_id_nan(&Bson::Decimal128(dec_finite)));
962 }
963
964 // ── mutation-W4 gap closure ──────────────────────────────────────────────
965 // Deleting any `id_bracket` arm survived the suite: the type falls to the
966 // `None` catch-all and keyset is (conservatively) refused — a false refusal
967 // no test noticed. The same blindness would pass the DANGEROUS neighbour: a
968 // MERGE regression (two types sharing a band number) makes collide=false
969 // and keysets across a real server band — the silent bracket-loss class the
970 // heterogeneous-`_id` guard exists to prevent. Pin the WHOLE map.
971 #[test]
972 fn id_bracket_map_matches_the_server_sort_bands() {
973 use mongodb::bson::spec::BinarySubtype;
974 use mongodb::bson::{Binary, DateTime, Decimal128, Regex, Timestamp, doc};
975 let b = |v: &Bson| id_bracket(v);
976
977 // The four numeric types share band 0 — a mixed Int32/Int64/Double
978 // `_id` keysets fine by design.
979 assert_eq!(b(&Bson::Int32(1)), Some(0));
980 assert_eq!(b(&Bson::Int64(1)), Some(0));
981 assert_eq!(b(&Bson::Double(1.0)), Some(0));
982 assert_eq!(
983 b(&Bson::Decimal128("1".parse::<Decimal128>().unwrap())),
984 Some(0)
985 );
986 // Every other placeable type is its OWN band, in server sort order.
987 assert_eq!(b(&Bson::Null), Some(1));
988 assert_eq!(b(&Bson::String("s".into())), Some(2));
989 assert_eq!(b(&Bson::Document(doc! {"k": 1})), Some(3));
990 assert_eq!(b(&Bson::Array(vec![Bson::Int32(1)])), Some(4));
991 assert_eq!(
992 b(&Bson::Binary(Binary {
993 subtype: BinarySubtype::Generic,
994 bytes: vec![1],
995 })),
996 Some(5)
997 );
998 assert_eq!(b(&Bson::ObjectId(ObjectId::new())), Some(6));
999 assert_eq!(b(&Bson::Boolean(true)), Some(7));
1000 assert_eq!(b(&Bson::DateTime(DateTime::from_millis(0))), Some(8));
1001 assert_eq!(
1002 b(&Bson::Timestamp(Timestamp {
1003 time: 0,
1004 increment: 0
1005 })),
1006 Some(9)
1007 );
1008 assert_eq!(
1009 b(&Bson::RegularExpression(Regex {
1010 pattern: "a".into(),
1011 options: String::new(),
1012 })),
1013 Some(10)
1014 );
1015 // Unplaceable types refuse keyset rather than guess.
1016 assert_eq!(b(&Bson::MaxKey), None);
1017
1018 // The collide relation derives from the map: same band never collides,
1019 // different bands always do, unplaceable collides with everything.
1020 assert!(!id_types_collide(&Bson::Int32(1), &Bson::Double(2.0)));
1021 assert!(id_types_collide(
1022 &Bson::Int64(2000),
1023 &Bson::String("id-1".into())
1024 ));
1025 assert!(id_types_collide(&Bson::Null, &Bson::Document(doc! {})));
1026 assert!(id_types_collide(&Bson::MaxKey, &Bson::MaxKey));
1027 }
1028
1029 #[test]
1030 fn id_string_covers_common_key_types() {
1031 let oid = ObjectId::parse_str("64b8f0000000000000000000").unwrap();
1032 assert_eq!(
1033 id_to_string(Some(&Bson::ObjectId(oid))),
1034 "64b8f0000000000000000000"
1035 );
1036 assert_eq!(id_to_string(Some(&Bson::String("abc".to_string()))), "abc");
1037 assert_eq!(id_to_string(Some(&Bson::Int64(42))), "42");
1038 assert_eq!(id_to_string(Some(&Bson::Int32(7))), "7");
1039 // Never panic / never silently vanish on a missing id.
1040 assert_eq!(id_to_string(None), "");
1041 }
1042
1043 #[test]
1044 fn heterogeneous_id_detection_is_bracket_based() {
1045 let int1001 = Bson::Int32(1001);
1046 let str1001 = Bson::String("1001".to_string());
1047 let oid = Bson::ObjectId(ObjectId::parse_str("64b8f0000000000000000000").unwrap());
1048 // The exact collision the warehouse MERGE guards against: int and string
1049 // that stringify to the SAME `_id` text but are distinct documents.
1050 assert!(id_types_collide(&int1001, &str1001));
1051 // ObjectId vs a string (e.g. its own hex stored as a string _id): cross-bracket.
1052 assert!(id_types_collide(&oid, &str1001));
1053 // Uniform is NOT flagged — same type, and the four numeric types share
1054 // bracket 0 (a mixed Int32/Int64 `_id` is not a display collision).
1055 assert!(!id_types_collide(&int1001, &Bson::Int32(2002)));
1056 assert!(!id_types_collide(&int1001, &Bson::Int64(9_000_000_000)));
1057 assert!(!id_types_collide(&oid, &oid));
1058 assert!(!id_types_collide(
1059 &str1001,
1060 &Bson::String("abc".to_string())
1061 ));
1062 }
1063
1064 #[test]
1065 fn roast_tls_config_is_honored_not_ignored() {
1066 use crate::config::{TlsConfig, TlsMode};
1067 use mongodb::options::Tls;
1068 let cfg = |mode, ca: Option<&str>| TlsConfig {
1069 mode,
1070 ca_file: ca.map(String::from),
1071 accept_invalid_certs: false,
1072 accept_invalid_hostnames: false,
1073 };
1074 // verify-full: TLS on, verify chain AND hostname, ca wired.
1075 match tls_to_mongo(&cfg(TlsMode::VerifyFull, Some("/ca.pem"))) {
1076 Tls::Enabled(o) => {
1077 assert_eq!(
1078 o.ca_file_path.as_deref(),
1079 Some(std::path::Path::new("/ca.pem"))
1080 );
1081 assert_eq!(o.allow_invalid_certificates, Some(false));
1082 }
1083 Tls::Disabled => panic!("verify-full must ENABLE tls, not ignore it"),
1084 }
1085 // require: TLS on but cert unverified.
1086 match tls_to_mongo(&cfg(TlsMode::Require, None)) {
1087 Tls::Enabled(o) => assert_eq!(o.allow_invalid_certificates, Some(true)),
1088 Tls::Disabled => panic!("require must enable tls"),
1089 }
1090 // disable: explicit plaintext opt-in.
1091 assert!(matches!(
1092 tls_to_mongo(&cfg(TlsMode::Disable, None)),
1093 Tls::Disabled
1094 ));
1095 }
1096
1097 #[test]
1098 fn roast_default_batch_byte_cap_is_bounded_not_unlimited() {
1099 // The whole point of the fix: with no user budget the cap must be a
1100 // finite byte value (≤ the Arrow i32 ceiling), never "unbounded" — an
1101 // unbounded batch of large documents panics ("byte array offset
1102 // overflow"). Verified live at 2300×1 MB before this guard.
1103 let cap = mongo_batch_byte_cap(None);
1104 assert!(
1105 cap > 0 && cap < (i32::MAX as usize),
1106 "cap must be finite + sub-2GiB"
1107 );
1108 // A user budget is honored (MiB → bytes); 0 falls back to the default.
1109 assert_eq!(mongo_batch_byte_cap(Some(64)), 64 * 1024 * 1024);
1110 assert_eq!(mongo_batch_byte_cap(Some(0)), mongo_batch_byte_cap(None));
1111 }
1112
1113 #[test]
1114 fn roast_non_ascii_cursor_token_is_a_clean_error_not_a_panic() {
1115 // A corrupted / foreign checkpoint token must surface as Err — never a
1116 // byte-boundary panic. '€' is 3 bytes: "€€" is 6 bytes (even, so it
1117 // passes the odd-length check) and the 2-byte hex slice at [0..2] cuts
1118 // the char in half — which panicked before hex_to_bytes rejected
1119 // non-ASCII input up front.
1120 assert!(decode_id_cursor("€€").is_err());
1121 }
1122
1123 #[test]
1124 fn id_cursor_token_roundtrips_every_bson_type_losslessly() {
1125 // The whole point of the typed token (A′): the display column can't tell
1126 // integer `1001` from string `"1001"`, but the cursor MUST — otherwise a
1127 // keyset `$gt` on the wrong BSON type mis-pages. Prove the round-trip
1128 // preserves the exact type for each `_id` shape keyset supports.
1129 let oid = Bson::ObjectId(ObjectId::parse_str("64b8f0000000000000000000").unwrap());
1130 for id in [
1131 oid.clone(),
1132 Bson::Int32(1001),
1133 Bson::Int64(9_000_000_000),
1134 Bson::String("1001".to_string()), // same text as Int32(1001) — must NOT collide
1135 Bson::String("sku-00042".to_string()),
1136 ] {
1137 let token = encode_id_cursor(&id);
1138 assert_eq!(
1139 decode_id_cursor(&token).unwrap(),
1140 id,
1141 "typed cursor round-trip lost the type for {id:?}"
1142 );
1143 }
1144 // Int32(1001) and String("1001") encode to DIFFERENT tokens (no ambiguity).
1145 assert_ne!(
1146 encode_id_cursor(&Bson::Int32(1001)),
1147 encode_id_cursor(&Bson::String("1001".to_string()))
1148 );
1149 // Legacy / display fallback: a bare 24-char ObjectId hex still decodes.
1150 assert_eq!(decode_id_cursor("64b8f0000000000000000000").unwrap(), oid);
1151 }
1152
1153 #[test]
1154 fn document_renders_relaxed_and_canonical() {
1155 let d = doc! {
1156 "a": 1i32,
1157 "b": "x",
1158 "big": 9_223_372_036_854_775_807i64,
1159 "nested": doc! { "d": true },
1160 };
1161 // Relaxed: common scalars native, Int64 as a bare JSON number.
1162 let relaxed = document_to_json(&d, false).unwrap();
1163 assert!(relaxed.contains("\"a\":1"), "got: {relaxed}");
1164 assert!(relaxed.contains("\"b\":\"x\""), "got: {relaxed}");
1165 assert!(
1166 relaxed.contains("\"nested\":{\"d\":true}"),
1167 "got: {relaxed}"
1168 );
1169 assert!(
1170 relaxed.contains("\"big\":9223372036854775807"),
1171 "int64 is a bare number in relaxed: {relaxed}"
1172 );
1173 // Canonical: every number type-tagged so a double-based JSON parser can't
1174 // clamp an Int64 beyond 2^53.
1175 let canonical = document_to_json(&d, true).unwrap();
1176 assert!(
1177 canonical.contains("\"$numberLong\":\"9223372036854775807\""),
1178 "int64 is type-tagged in canonical: {canonical}"
1179 );
1180 }
1181
1182 #[test]
1183 fn blob_schema_is_two_columns_id_and_document_json() {
1184 let schema = blob_schema();
1185 let names: Vec<&str> = schema.fields().iter().map(|f| f.name().as_str()).collect();
1186 assert_eq!(names, vec!["_id", "document"]);
1187 // The document column carries the Arrow JSON canonical extension type so
1188 // parquet emits a native JSON column downstream, not a bare string.
1189 let doc_field = schema.field_with_name("document").unwrap();
1190 assert_eq!(
1191 doc_field.metadata().get("ARROW:extension:name"),
1192 Some(&"arrow.json".to_string()),
1193 "document column must carry the arrow.json extension type"
1194 );
1195 }
1196
1197 #[test]
1198 fn harm_counters_extracted_from_server_status() {
1199 let status = doc! {
1200 "metrics": {
1201 "queryExecutor": { "scanned": 10i64, "scannedObjects": 500i64 },
1202 "document": { "returned": 480i64 },
1203 },
1204 "opcounters": { "query": 7i32, "getmore": 12i32 },
1205 "wiredTiger": { "cache": { "bytes read into cache": 1_048_576i64 } },
1206 };
1207 let harm: std::collections::HashMap<String, i64> =
1208 harm_from_server_status(&status).into_iter().collect();
1209 assert_eq!(harm.get("mongo_docs_scanned"), Some(&500));
1210 assert_eq!(harm.get("mongo_keys_scanned"), Some(&10));
1211 assert_eq!(harm.get("mongo_docs_returned"), Some(&480));
1212 assert_eq!(harm.get("mongo_op_query"), Some(&7)); // Int32 coerced to i64
1213 assert_eq!(harm.get("mongo_op_getmore"), Some(&12));
1214 assert_eq!(harm.get("mongo_wt_cache_bytes_read"), Some(&1_048_576));
1215 }
1216
1217 #[test]
1218 fn reconcile_count_query_names_the_collection() {
1219 // The reconcile count wraps the base query; the LAST `FROM` names the
1220 // collection countDocuments runs against.
1221 assert_eq!(
1222 last_from_identifier("SELECT COUNT(*) FROM (SELECT * FROM orders) AS _rivet_reconcile"),
1223 Some("orders")
1224 );
1225 assert_eq!(
1226 last_from_identifier("select count(*) from (select * from shop_events) as _r"),
1227 Some("shop_events")
1228 );
1229 // No bare identifier after a FROM → no count.
1230 assert_eq!(last_from_identifier("SELECT 1"), None);
1231 }
1232
1233 #[test]
1234 fn harm_skips_missing_sections_gracefully() {
1235 // e.g. the in-memory storage engine has no `wiredTiger` section — a
1236 // missing section drops only its own metric, never errors.
1237 let status = doc! { "opcounters": { "query": 3i64 } };
1238 let harm: std::collections::HashMap<String, i64> =
1239 harm_from_server_status(&status).into_iter().collect();
1240 assert_eq!(harm.get("mongo_op_query"), Some(&3));
1241 assert!(!harm.contains_key("mongo_wt_cache_bytes_read"));
1242 assert!(!harm.contains_key("mongo_docs_scanned"));
1243 }
1244}