laminar_connectors/mongodb/lookup/
mod.rs1pub(super) mod schema_resolution;
13
14#[cfg(feature = "mongodb-cdc")]
15use std::sync::Arc;
16#[cfg(feature = "mongodb-cdc")]
17use std::time::Duration;
18
19#[cfg(feature = "mongodb-cdc")]
20use arrow_array::{Array, RecordBatch};
21#[cfg(feature = "mongodb-cdc")]
22use arrow_row::SortField;
23#[cfg(feature = "mongodb-cdc")]
24use arrow_schema::{DataType, SchemaRef};
25#[cfg(feature = "mongodb-cdc")]
26use mongodb::bson::{doc, Bson, Document, RawDocumentBuf};
27#[cfg(feature = "mongodb-cdc")]
28use mongodb::{Client, IndexModel};
29
30#[cfg(feature = "mongodb-cdc")]
31use laminar_core::lookup::predicate::Predicate;
32#[cfg(feature = "mongodb-cdc")]
33use laminar_core::lookup::source::{
34 projection_names, ColumnId, LookupError, LookupSource, LookupSourceCapabilities,
35};
36#[cfg(feature = "mongodb-cdc")]
37use laminar_core::lookup::KeyAligner;
38
39#[cfg(feature = "mongodb-cdc")]
40const LOOKUP_SERVER_TIMEOUT: Duration = Duration::from_secs(25);
41#[cfg(feature = "mongodb-cdc")]
42const MAX_LOOKUP_KEYS: usize = 4_096;
43#[cfg(feature = "mongodb-cdc")]
44const MAX_LOOKUP_KEY_BYTES: usize = 4 * 1024 * 1024;
45#[cfg(feature = "mongodb-cdc")]
46const MAX_LOOKUP_COMMAND_BYTES: usize = 8 * 1024 * 1024;
47#[cfg(feature = "mongodb-cdc")]
48const MAX_LOOKUP_RESULT_BYTES: usize = 64 * 1024 * 1024;
49
50#[cfg(feature = "mongodb-cdc")]
51fn sharded_lookup_not_admitted() -> LookupError {
52 LookupError::Internal(
53 "mongodb sharded lookup through mongos/load-balanced topology is not admitted until \
54 shard-key routing and cluster-global uniqueness certification are implemented"
55 .into(),
56 )
57}
58
59#[cfg(feature = "mongodb-cdc")]
60fn validate_lookup_server_topology(hello: &Document) -> Result<(), LookupError> {
61 if matches!(hello.get_str("msg"), Ok("isdbgrid")) {
62 return Err(sharded_lookup_not_admitted());
63 }
64 Ok(())
65}
66
67#[cfg(feature = "mongodb-cdc")]
69#[derive(Debug, Clone)]
70pub struct MongoLookupSourceConfig {
71 pub connection_uri: String,
73 pub database: String,
75 pub collection: String,
77 pub primary_key_columns: Vec<String>,
79 pub schema: SchemaRef,
81}
82
83#[cfg(feature = "mongodb-cdc")]
85pub struct MongoLookupSource {
86 client: Client,
87 database: String,
88 collection: String,
89 pk_field: String,
90 schema: SchemaRef,
91 aligner: KeyAligner,
92}
93
94#[cfg(feature = "mongodb-cdc")]
95fn validate_lookup_keys(keys: &[&[u8]]) -> Result<(), LookupError> {
96 if keys.len() > MAX_LOOKUP_KEYS {
97 return Err(LookupError::Query(format!(
98 "mongodb lookup received {} keys, exceeding the fixed {MAX_LOOKUP_KEYS}-key batch limit",
99 keys.len()
100 )));
101 }
102 let bytes = keys.iter().try_fold(0_usize, |total, key| {
103 total
104 .checked_add(key.len())
105 .ok_or_else(|| LookupError::Query("mongodb lookup key byte count overflow".into()))
106 })?;
107 if bytes > MAX_LOOKUP_KEY_BYTES {
108 return Err(LookupError::Query(format!(
109 "mongodb lookup received {bytes} key bytes, exceeding the fixed {MAX_LOOKUP_KEY_BYTES}-byte batch limit"
110 )));
111 }
112 Ok(())
113}
114
115#[cfg(feature = "mongodb-cdc")]
116fn validate_lookup_command(
117 database: &str,
118 collection: &str,
119 filter: &Document,
120 projection: Option<&Document>,
121 limit: i64,
122) -> Result<(), LookupError> {
123 #[derive(serde::Serialize)]
124 #[serde(rename_all = "camelCase")]
125 struct FindCommand<'a> {
126 find: &'a str,
127 #[serde(rename = "$db")]
128 database: &'a str,
129 filter: &'a Document,
130 #[serde(skip_serializing_if = "Option::is_none")]
131 projection: Option<&'a Document>,
132 limit: i64,
133 #[serde(rename = "maxTimeMS")]
134 max_time_ms: u64,
135 }
136
137 let max_time_ms = u64::try_from(LOOKUP_SERVER_TIMEOUT.as_millis()).map_err(|_| {
138 LookupError::Internal("mongodb lookup server timeout exceeds u64 milliseconds".into())
139 })?;
140 let command = FindCommand {
141 find: collection,
142 database,
143 filter,
144 projection,
145 limit,
146 max_time_ms,
147 };
148 let bytes = mongodb::bson::to_vec(&command)
149 .map_err(|error| LookupError::Query(format!("serialize mongodb find command: {error}")))?;
150 if bytes.len() > MAX_LOOKUP_COMMAND_BYTES {
151 return Err(LookupError::Query(format!(
152 "mongodb lookup command is {} bytes, exceeding the fixed {MAX_LOOKUP_COMMAND_BYTES}-byte limit",
153 bytes.len()
154 )));
155 }
156 Ok(())
157}
158
159#[cfg(feature = "mongodb-cdc")]
160fn has_usable_unique_lookup_index(index: &IndexModel, pk_field: &str) -> bool {
161 if index.keys.len() != 1 {
162 return false;
163 }
164 let Some((field, direction)) = index.keys.iter().next() else {
165 return false;
166 };
167 let is_ascending_or_descending = match direction {
168 Bson::Int32(value) => *value == 1 || *value == -1,
169 Bson::Int64(value) => *value == 1 || *value == -1,
170 Bson::Double(value) => value.abs().total_cmp(&1.0).is_eq(),
171 _ => false,
172 };
173 if field != pk_field || !is_ascending_or_descending {
174 return false;
175 }
176
177 let Some(options) = index.options.as_ref() else {
178 return false;
179 };
180 if options.hidden == Some(true)
181 || options.partial_filter_expression.is_some()
182 || options.collation.is_some()
183 {
184 return false;
185 }
186
187 options.unique == Some(true) || (pk_field == "_id" && options.name.as_deref() == Some("_id_"))
188}
189
190#[cfg(feature = "mongodb-cdc")]
191async fn verify_unique_lookup_index(
192 client: Client,
193 database: String,
194 collection: String,
195 pk_field: String,
196) -> Result<(), LookupError> {
197 use tokio_stream::StreamExt;
198
199 let hello = client
200 .database("admin")
201 .run_command(doc! { "hello": 1 })
202 .await
203 .map_err(|error| {
204 LookupError::Connection(format!("inspect mongodb lookup topology: {error}"))
205 })?;
206 validate_lookup_server_topology(&hello)?;
207
208 let collection_handle = client
209 .database(&database)
210 .collection::<Document>(&collection);
211 let mut indexes = collection_handle
212 .list_indexes()
213 .max_time(LOOKUP_SERVER_TIMEOUT)
214 .await
215 .map_err(|error| {
216 LookupError::Connection(format!(
217 "inspect mongodb lookup indexes for {database}.{collection}: {error}"
218 ))
219 })?;
220 while let Some(next) = indexes.next().await {
221 let index = next.map_err(|error| {
222 LookupError::Connection(format!(
223 "read mongodb lookup indexes for {database}.{collection}: {error}"
224 ))
225 })?;
226 if has_usable_unique_lookup_index(&index, &pk_field) {
227 return Ok(());
228 }
229 }
230
231 Err(LookupError::Internal(format!(
232 "mongodb lookup requires a visible, non-partial, simple-collation unique single-field ascending/descending index on '{pk_field}' in {database}.{collection}"
233 )))
234}
235
236#[cfg(feature = "mongodb-cdc")]
237impl MongoLookupSource {
238 pub async fn open(config: MongoLookupSourceConfig) -> Result<Self, LookupError> {
245 if config.connection_uri.trim().is_empty()
246 || config.database.trim().is_empty()
247 || config.collection.trim().is_empty()
248 {
249 return Err(LookupError::Internal(
250 "mongodb lookup requires non-empty connection_uri, database, and collection".into(),
251 ));
252 }
253 if config.collection == "*" {
254 return Err(LookupError::Internal(
255 "mongodb lookup does not support collection='*'".into(),
256 ));
257 }
258 if config.primary_key_columns.len() != 1 {
259 return Err(LookupError::Internal(format!(
260 "mongodb lookup requires exactly one primary key column, got {}",
261 config.primary_key_columns.len()
262 )));
263 }
264 let pk_field = config.primary_key_columns[0].clone();
265
266 let pk_idx = config.schema.index_of(&pk_field).map_err(|_| {
267 LookupError::Internal(format!("pk column not in declared schema: {pk_field}"))
268 })?;
269
270 for field in config.schema.fields() {
271 match field.data_type() {
272 DataType::Int32
273 | DataType::Int64
274 | DataType::Float64
275 | DataType::Boolean
276 | DataType::Utf8
277 | DataType::LargeUtf8 => {}
278 dt => {
279 return Err(LookupError::Internal(format!(
280 "unsupported field data type in schema for mongodb lookup: {dt}"
281 )));
282 }
283 }
284 }
285
286 let pk_sort_fields = vec![SortField::new(
287 config.schema.field(pk_idx).data_type().clone(),
288 )];
289 let aligner = KeyAligner::new(pk_sort_fields, config.primary_key_columns)?;
290
291 let connection_uri = config.connection_uri.clone();
292 let mut client_options =
293 tokio::spawn(
294 async move { mongodb::options::ClientOptions::parse(&connection_uri).await },
295 )
296 .await
297 .map_err(|error| {
298 LookupError::Internal(format!("mongodb client-options task failed: {error}"))
299 })?
300 .map_err(|e| LookupError::Connection(format!("mongodb client options: {e}")))?;
301 if client_options.load_balanced == Some(true) {
302 return Err(sharded_lookup_not_admitted());
303 }
304 super::sink::harden_mongodb_tls(&mut client_options)
305 .map_err(|e| LookupError::Connection(e.to_string()))?;
306 client_options.connect_timeout = Some(
307 client_options
308 .connect_timeout
309 .unwrap_or(LOOKUP_SERVER_TIMEOUT)
310 .min(LOOKUP_SERVER_TIMEOUT),
311 );
312 client_options.server_selection_timeout = Some(
313 client_options
314 .server_selection_timeout
315 .unwrap_or(LOOKUP_SERVER_TIMEOUT)
316 .min(LOOKUP_SERVER_TIMEOUT),
317 );
318 let client = Client::with_options(client_options)
319 .map_err(|e| LookupError::Connection(format!("mongodb client: {e}")))?;
320
321 let index_client = client.clone();
322 let index_database = config.database.clone();
323 let index_collection = config.collection.clone();
324 let index_pk_field = pk_field.clone();
325 tokio::spawn(async move {
326 verify_unique_lookup_index(
327 index_client,
328 index_database,
329 index_collection,
330 index_pk_field,
331 )
332 .await
333 })
334 .await
335 .map_err(|error| {
336 LookupError::Internal(format!("mongodb index-check task failed: {error}"))
337 })??;
338
339 Ok(Self {
340 client,
341 database: config.database,
342 collection: config.collection,
343 pk_field,
344 schema: config.schema,
345 aligner,
346 })
347 }
348
349 fn cell_to_bson(array: &dyn Array, row: usize) -> Result<Option<Bson>, LookupError> {
352 use arrow_array::{
353 BooleanArray, Float64Array, Int32Array, Int64Array, LargeStringArray, StringArray,
354 };
355
356 fn downcast<T: 'static>(array: &dyn Array) -> Result<&T, LookupError> {
357 array
358 .as_any()
359 .downcast_ref::<T>()
360 .ok_or_else(|| LookupError::Internal("pk column downcast failed".into()))
361 }
362
363 if array.is_null(row) {
364 return Ok(None);
365 }
366
367 let bson = match array.data_type() {
368 DataType::Int32 => Bson::Int32(downcast::<Int32Array>(array)?.value(row)),
369 DataType::Int64 => Bson::Int64(downcast::<Int64Array>(array)?.value(row)),
370 DataType::Float64 => Bson::Double(downcast::<Float64Array>(array)?.value(row)),
371 DataType::Boolean => Bson::Boolean(downcast::<BooleanArray>(array)?.value(row)),
372 DataType::Utf8 => Bson::String(downcast::<StringArray>(array)?.value(row).to_string()),
373 DataType::LargeUtf8 => {
374 Bson::String(downcast::<LargeStringArray>(array)?.value(row).to_string())
375 }
376 dt => {
377 return Err(LookupError::Internal(format!(
378 "unsupported PK data type for mongodb lookup: {dt}"
379 )));
380 }
381 };
382 Ok(Some(bson))
383 }
384
385 fn docs_to_batch(schema: &SchemaRef, docs: &[Document]) -> Result<RecordBatch, LookupError> {
389 use arrow_array::builder::{
390 BooleanBuilder, Float64Builder, Int32Builder, Int64Builder, LargeStringBuilder,
391 StringBuilder,
392 };
393
394 let mut columns: Vec<Arc<dyn Array>> = Vec::with_capacity(schema.fields().len());
395 for field in schema.fields() {
396 let name = field.name().as_str();
397 let array: Arc<dyn Array> = match field.data_type() {
398 DataType::Int32 => {
399 let mut b = Int32Builder::with_capacity(docs.len());
400 for d in docs {
401 b.append_option(
402 bson_as_i64(d.get(name)).and_then(|v| i32::try_from(v).ok()),
403 );
404 }
405 Arc::new(b.finish())
406 }
407 DataType::Int64 => {
408 let mut b = Int64Builder::with_capacity(docs.len());
409 for d in docs {
410 b.append_option(bson_as_i64(d.get(name)));
411 }
412 Arc::new(b.finish())
413 }
414 DataType::Float64 => {
415 let mut b = Float64Builder::with_capacity(docs.len());
416 for d in docs {
417 b.append_option(bson_as_f64(d.get(name)));
418 }
419 Arc::new(b.finish())
420 }
421 DataType::Boolean => {
422 let mut b = BooleanBuilder::with_capacity(docs.len());
423 for d in docs {
424 b.append_option(d.get(name).and_then(Bson::as_bool));
425 }
426 Arc::new(b.finish())
427 }
428 DataType::LargeUtf8 => {
429 let mut b = LargeStringBuilder::with_capacity(docs.len(), docs.len() * 16);
430 for d in docs {
431 match d.get(name) {
432 None | Some(Bson::Null) => b.append_null(),
433 Some(v) => b.append_value(bson_to_string(v)),
434 }
435 }
436 Arc::new(b.finish())
437 }
438 DataType::Utf8 => {
439 let mut b = StringBuilder::with_capacity(docs.len(), docs.len() * 16);
440 for d in docs {
441 match d.get(name) {
442 None | Some(Bson::Null) => b.append_null(),
443 Some(v) => b.append_value(bson_to_string(v)),
444 }
445 }
446 Arc::new(b.finish())
447 }
448 _ => {
449 return Err(LookupError::Internal(format!(
450 "unsupported field data type: {:?}",
451 field.data_type()
452 )));
453 }
454 };
455 columns.push(array);
456 }
457 RecordBatch::try_new(Arc::clone(schema), columns)
458 .map_err(|e| LookupError::Internal(format!("arrow batch construction: {e}")))
459 }
460}
461
462#[cfg(feature = "mongodb-cdc")]
463impl LookupSource for MongoLookupSource {
464 async fn query(
465 &self,
466 keys: &[&[u8]],
467 _predicates: &[Predicate],
468 projection: &[ColumnId],
469 ) -> Result<Vec<Option<RecordBatch>>, LookupError> {
470 use tokio_stream::StreamExt;
471
472 if keys.is_empty() {
473 return Ok(Vec::new());
474 }
475 validate_lookup_keys(keys)?;
476
477 let pk_arrays = self.aligner.decode_keys(keys)?;
478 let pk_array = pk_arrays[0].as_ref();
479 let mut in_values: Vec<Bson> = Vec::with_capacity(keys.len());
480 for row in 0..pk_array.len() {
481 if let Some(b) = Self::cell_to_bson(pk_array, row)? {
482 in_values.push(b);
483 }
484 }
485 if in_values.is_empty() {
486 return Ok(vec![None; keys.len()]);
487 }
488 let result_limit = i64::try_from(in_values.len()).map_err(|_| {
489 LookupError::Internal("mongodb lookup key count exceeds i64::MAX".into())
490 })?;
491
492 let filter = doc! { &self.pk_field: doc! { "$in": in_values } };
493
494 let mut project_needed = false;
497 let (out_schema, projection_document) =
498 if projection.is_empty() {
499 (Arc::clone(&self.schema), None)
500 } else {
501 let mut names = projection_names(&self.schema, projection)?;
502 let mut idx: Vec<usize> = projection.iter().map(|&c| c as usize).collect();
503 if !names.contains(&self.pk_field) {
504 names.push(self.pk_field.clone());
505 let pk_idx = self
506 .schema
507 .index_of(&self.pk_field)
508 .map_err(|e| LookupError::Internal(format!("pk column index: {e}")))?;
509 idx.push(pk_idx);
510 project_needed = true;
511 }
512
513 let mut proj_doc = Document::new();
514 for name in &names {
515 proj_doc.insert(name.clone(), 1);
516 }
517 (
518 Arc::new(self.schema.project(&idx).map_err(|e| {
519 LookupError::Internal(format!("project mongodb schema: {e}"))
520 })?),
521 Some(proj_doc),
522 )
523 };
524
525 validate_lookup_command(
526 &self.database,
527 &self.collection,
528 &filter,
529 projection_document.as_ref(),
530 result_limit,
531 )?;
532
533 let client = self.client.clone();
536 let database = self.database.clone();
537 let collection = self.collection.clone();
538 let task_schema = Arc::clone(&out_schema);
539 let batches = tokio::spawn(async move {
540 let collection = client
541 .database(&database)
542 .collection::<RawDocumentBuf>(&collection);
543 let mut find = collection
544 .find(filter)
545 .limit(result_limit)
546 .max_time(LOOKUP_SERVER_TIMEOUT);
547 if let Some(projection) = projection_document {
548 find = find.projection(projection);
549 }
550 let mut cursor = find
551 .await
552 .map_err(|e| LookupError::Query(format!("mongodb find: {e}")))?;
553 let mut docs: Vec<Document> = Vec::new();
554 let mut result_bytes = 0_usize;
555 while let Some(next) = cursor.next().await {
556 let raw = next.map_err(|e| LookupError::Query(format!("mongodb cursor: {e}")))?;
557 result_bytes = result_bytes
558 .checked_add(raw.as_bytes().len())
559 .ok_or_else(|| {
560 LookupError::Query("mongodb lookup result byte count overflow".into())
561 })?;
562 if result_bytes > MAX_LOOKUP_RESULT_BYTES {
563 return Err(LookupError::Query(format!(
564 "mongodb lookup result is at least {result_bytes} bytes, exceeding the fixed {MAX_LOOKUP_RESULT_BYTES}-byte limit"
565 )));
566 }
567 docs.push(raw.to_document().map_err(|error| {
568 LookupError::Query(format!("decode mongodb lookup result: {error}"))
569 })?);
570 }
571 if docs.is_empty() {
572 Ok(Vec::new())
573 } else {
574 Ok(vec![Self::docs_to_batch(&task_schema, &docs)?])
575 }
576 })
577 .await
578 .map_err(|error| LookupError::Internal(format!("mongodb lookup task failed: {error}")))??;
579 let aligned = self.aligner.align(keys, &batches)?;
580
581 if project_needed {
582 let orig_names = projection_names(&self.schema, projection)?;
583 let mut projected_aligned = Vec::with_capacity(aligned.len());
584 for maybe_batch in aligned {
585 if let Some(batch) = maybe_batch {
586 let indices: Vec<usize> = orig_names
587 .iter()
588 .map(|name| {
589 batch.schema().index_of(name).map_err(|e| {
590 LookupError::Internal(format!(
591 "column not found in aligned schema: {e}"
592 ))
593 })
594 })
595 .collect::<Result<Vec<usize>, LookupError>>()?;
596 let projected = batch.project(&indices).map_err(|e| {
597 LookupError::Internal(format!("project aligned batch: {e}"))
598 })?;
599 projected_aligned.push(Some(projected));
600 } else {
601 projected_aligned.push(None);
602 }
603 }
604 Ok(projected_aligned)
605 } else {
606 Ok(aligned)
607 }
608 }
609
610 fn capabilities(&self) -> LookupSourceCapabilities {
611 LookupSourceCapabilities {
612 supports_batch_lookup: true,
613 supports_projection_pushdown: true,
614 max_batch_size: MAX_LOOKUP_KEYS,
615 ..LookupSourceCapabilities::none()
616 }
617 }
618
619 fn source_name(&self) -> &'static str {
620 "mongodb"
621 }
622
623 fn schema(&self) -> SchemaRef {
624 Arc::clone(&self.schema)
625 }
626
627 async fn health_check(&self) -> Result<(), LookupError> {
628 let client = self.client.clone();
629 let database = self.database.clone();
630 tokio::spawn(async move {
631 client
632 .database(&database)
633 .run_command(doc! { "ping": 1 })
634 .await
635 .map(|_| ())
636 .map_err(|e| LookupError::Connection(format!("health check: {e}")))
637 })
638 .await
639 .map_err(|error| LookupError::Internal(format!("mongodb health task failed: {error}")))?
640 }
641}
642
643#[cfg(feature = "mongodb-cdc")]
645fn bson_as_i64(b: Option<&Bson>) -> Option<i64> {
646 match b? {
647 Bson::Int32(v) => Some(i64::from(*v)),
648 Bson::Int64(v) => Some(*v),
649 #[allow(clippy::cast_possible_truncation)]
650 Bson::Double(v) => Some(*v as i64),
651 _ => None,
652 }
653}
654
655#[cfg(feature = "mongodb-cdc")]
657fn bson_as_f64(b: Option<&Bson>) -> Option<f64> {
658 match b? {
659 Bson::Double(v) => Some(*v),
660 Bson::Int32(v) => Some(f64::from(*v)),
661 #[allow(clippy::cast_precision_loss)]
662 Bson::Int64(v) => Some(*v as f64),
663 _ => None,
664 }
665}
666
667#[cfg(feature = "mongodb-cdc")]
669fn bson_to_string(b: &Bson) -> String {
670 match b {
671 Bson::String(s) => s.clone(),
672 Bson::ObjectId(oid) => oid.to_hex(),
673 Bson::Int32(v) => v.to_string(),
674 Bson::Int64(v) => v.to_string(),
675 Bson::Double(v) => v.to_string(),
676 Bson::Boolean(v) => v.to_string(),
677 other => other.to_string(),
678 }
679}
680
681#[cfg(all(test, feature = "mongodb-cdc"))]
682mod tests;