1use std::collections::HashMap;
17use std::fmt::{self, Debug, Formatter};
18use std::sync::Arc;
19
20use parking_lot::RwLock;
21
22use arrow::compute::take;
23use arrow::row::{RowConverter, SortField};
24use arrow_array::{Array, RecordBatch, UInt32Array};
25use arrow_schema::{Schema, SchemaRef};
26use async_trait::async_trait;
27use datafusion::execution::{SendableRecordBatchStream, SessionState, TaskContext};
28use datafusion::logical_expr::{LogicalPlan, UserDefinedLogicalNode};
29use datafusion::physical_expr::{EquivalenceProperties, LexOrdering, Partitioning};
30use datafusion::physical_plan::execution_plan::{Boundedness, EmissionType};
31use datafusion::physical_plan::stream::RecordBatchStreamAdapter;
32use datafusion::physical_plan::{DisplayAs, DisplayFormatType, ExecutionPlan, PlanProperties};
33use datafusion::physical_planner::{ExtensionPlanner, PhysicalPlanner};
34use datafusion_common::{DataFusionError, Result};
35use datafusion_expr::Expr;
36use futures::StreamExt;
37use laminar_core::lookup::lookup_cache::LookupMemoryCache;
38use laminar_core::lookup::source::{ColumnId, LookupSourceDyn};
39use tokio::sync::Semaphore;
40
41use super::lookup_join::{LookupJoinNode, LookupJoinType};
42
43mod partial;
44
45pub use partial::PartialLookupJoinExec;
46
47const LOOKUP_SOURCE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
50
51#[derive(Default)]
58pub struct LookupTableRegistry {
59 tables: RwLock<HashMap<String, RegisteredLookup>>,
60}
61
62pub enum RegisteredLookup {
64 Snapshot(Arc<LookupSnapshot>),
66 Partial(Arc<PartialLookupState>),
68}
69
70pub struct LookupSnapshot {
72 pub batch: RecordBatch,
74}
75
76pub struct PartialLookupState {
78 pub lookup_cache: Arc<LookupMemoryCache>,
80 pub schema: SchemaRef,
82 pub key_columns: Vec<String>,
84 pub key_sort_fields: Vec<SortField>,
86 pub source: Option<Arc<dyn LookupSourceDyn>>,
88 pub fetch_semaphore: Arc<Semaphore>,
90 pub projection: Vec<ColumnId>,
94}
95
96impl LookupTableRegistry {
97 #[must_use]
99 pub fn new() -> Self {
100 Self::default()
101 }
102
103 pub fn register(&self, name: &str, snapshot: LookupSnapshot) {
109 self.tables.write().insert(
110 name.to_lowercase(),
111 RegisteredLookup::Snapshot(Arc::new(snapshot)),
112 );
113 }
114
115 pub fn register_partial(&self, name: &str, state: PartialLookupState) {
121 self.tables.write().insert(
122 name.to_lowercase(),
123 RegisteredLookup::Partial(Arc::new(state)),
124 );
125 }
126
127 pub fn unregister(&self, name: &str) {
133 self.tables.write().remove(&name.to_lowercase());
134 }
135
136 #[must_use]
142 pub fn get(&self, name: &str) -> Option<Arc<LookupSnapshot>> {
143 let tables = self.tables.read();
144 match tables.get(&name.to_lowercase())? {
145 RegisteredLookup::Snapshot(s) => Some(Arc::clone(s)),
146 RegisteredLookup::Partial(_) => None,
147 }
148 }
149
150 pub fn get_entry(&self, name: &str) -> Option<RegisteredLookup> {
156 let tables = self.tables.read();
157 tables.get(&name.to_lowercase()).map(|e| match e {
158 RegisteredLookup::Snapshot(s) => RegisteredLookup::Snapshot(Arc::clone(s)),
159 RegisteredLookup::Partial(p) => RegisteredLookup::Partial(Arc::clone(p)),
160 })
161 }
162}
163
164struct HashIndex {
168 map: HashMap<Box<[u8]>, Vec<u32>>,
169}
170
171impl HashIndex {
172 fn build(batch: &RecordBatch, key_indices: &[usize]) -> Result<Self> {
177 if batch.num_rows() == 0 {
178 return Ok(Self {
179 map: HashMap::new(),
180 });
181 }
182
183 let sort_fields: Vec<SortField> = key_indices
184 .iter()
185 .map(|&i| SortField::new(batch.schema().field(i).data_type().clone()))
186 .collect();
187 let converter = RowConverter::new(sort_fields)?;
188
189 let key_cols: Vec<_> = key_indices
190 .iter()
191 .map(|&i| batch.column(i).clone())
192 .collect();
193 let rows = converter.convert_columns(&key_cols)?;
194
195 let num_rows = batch.num_rows();
196 let mut map: HashMap<Box<[u8]>, Vec<u32>> = HashMap::with_capacity(num_rows);
197 #[allow(clippy::cast_possible_truncation)] for i in 0..num_rows {
199 map.entry(Box::from(rows.row(i).as_ref()))
200 .or_default()
201 .push(i as u32);
202 }
203
204 Ok(Self { map })
205 }
206
207 fn probe(&self, key: &[u8]) -> Option<&[u32]> {
208 self.map.get(key).map(Vec::as_slice)
209 }
210}
211
212pub struct LookupJoinExec {
217 input: Arc<dyn ExecutionPlan>,
218 index: Arc<HashIndex>,
219 lookup_batch: Arc<RecordBatch>,
220 stream_key_indices: Vec<usize>,
221 join_type: LookupJoinType,
222 schema: SchemaRef,
223 properties: Arc<PlanProperties>,
224 converter: Arc<RowConverter>,
228 stream_field_count: usize,
229 projection: Vec<usize>,
230}
231
232impl LookupJoinExec {
233 #[allow(clippy::needless_pass_by_value)] pub fn try_new(
243 input: Arc<dyn ExecutionPlan>,
244 lookup_batch: RecordBatch,
245 stream_key_indices: Vec<usize>,
246 lookup_key_indices: Vec<usize>,
247 join_type: LookupJoinType,
248 output_schema: SchemaRef,
249 ) -> Result<Self> {
250 let index = HashIndex::build(&lookup_batch, &lookup_key_indices)?;
251
252 let key_sort_fields: Vec<SortField> = lookup_key_indices
253 .iter()
254 .map(|&i| SortField::new(lookup_batch.schema().field(i).data_type().clone()))
255 .collect();
256 let converter = Arc::new(RowConverter::new(key_sort_fields)?);
257
258 let output_schema = if join_type == LookupJoinType::LeftOuter {
261 let stream_count = input.schema().fields().len();
262 let mut fields = output_schema.fields().to_vec();
263 for f in &mut fields[stream_count..] {
264 if !f.is_nullable() {
265 *f = Arc::new(f.as_ref().clone().with_nullable(true));
266 }
267 }
268 Arc::new(Schema::new_with_metadata(
269 fields,
270 output_schema.metadata().clone(),
271 ))
272 } else {
273 output_schema
274 };
275
276 let properties = Arc::new(PlanProperties::new(
277 EquivalenceProperties::new(Arc::clone(&output_schema)),
278 Partitioning::UnknownPartitioning(1),
279 EmissionType::Incremental,
280 Boundedness::Unbounded {
281 requires_infinite_memory: false,
282 },
283 ));
284
285 let stream_field_count = input.schema().fields().len();
286 let projection = (0..(stream_field_count + lookup_batch.num_columns())).collect();
287
288 Ok(Self {
289 input,
290 index: Arc::new(index),
291 lookup_batch: Arc::new(lookup_batch),
292 stream_key_indices,
293 join_type,
294 schema: output_schema,
295 properties,
296 converter,
297 stream_field_count,
298 projection,
299 })
300 }
301
302 #[must_use]
304 pub fn with_projection(mut self, projection: Vec<usize>) -> Self {
305 self.projection = projection;
306 self
307 }
308}
309
310impl Debug for LookupJoinExec {
311 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
312 f.debug_struct("LookupJoinExec")
313 .field("join_type", &self.join_type)
314 .field("stream_keys", &self.stream_key_indices)
315 .field("lookup_rows", &self.lookup_batch.num_rows())
316 .finish_non_exhaustive()
317 }
318}
319
320impl DisplayAs for LookupJoinExec {
321 fn fmt_as(&self, t: DisplayFormatType, f: &mut Formatter<'_>) -> fmt::Result {
322 match t {
323 DisplayFormatType::Default | DisplayFormatType::Verbose => {
324 write!(
325 f,
326 "LookupJoinExec: type={}, stream_keys={:?}, lookup_rows={}",
327 self.join_type,
328 self.stream_key_indices,
329 self.lookup_batch.num_rows(),
330 )
331 }
332 DisplayFormatType::TreeRender => write!(f, "LookupJoinExec"),
333 }
334 }
335}
336
337impl ExecutionPlan for LookupJoinExec {
338 fn as_any(&self) -> &dyn std::any::Any {
339 self
340 }
341
342 fn name(&self) -> &'static str {
343 "LookupJoinExec"
344 }
345
346 fn schema(&self) -> SchemaRef {
347 Arc::clone(&self.schema)
348 }
349
350 fn properties(&self) -> &Arc<PlanProperties> {
351 &self.properties
352 }
353
354 fn children(&self) -> Vec<&Arc<dyn ExecutionPlan>> {
355 vec![&self.input]
356 }
357
358 fn with_new_children(
359 self: Arc<Self>,
360 mut children: Vec<Arc<dyn ExecutionPlan>>,
361 ) -> Result<Arc<dyn ExecutionPlan>> {
362 if children.len() != 1 {
363 return Err(DataFusionError::Plan(
364 "LookupJoinExec requires exactly one child".into(),
365 ));
366 }
367 Ok(Arc::new(Self {
368 input: children.swap_remove(0),
369 index: Arc::clone(&self.index),
370 lookup_batch: Arc::clone(&self.lookup_batch),
371 stream_key_indices: self.stream_key_indices.clone(),
372 join_type: self.join_type,
373 schema: Arc::clone(&self.schema),
374 properties: self.properties.clone(),
375 converter: Arc::clone(&self.converter),
376 stream_field_count: self.stream_field_count,
377 projection: self.projection.clone(),
378 }))
379 }
380
381 fn execute(
382 &self,
383 partition: usize,
384 context: Arc<TaskContext>,
385 ) -> Result<SendableRecordBatchStream> {
386 let input_stream = self.input.execute(partition, context)?;
387 let converter = Arc::clone(&self.converter);
388 let index = Arc::clone(&self.index);
389 let lookup_batch = Arc::clone(&self.lookup_batch);
390 let stream_key_indices = self.stream_key_indices.clone();
391 let join_type = self.join_type;
392 let schema = self.schema();
393 let stream_field_count = self.stream_field_count;
394 let projection = self.projection.clone();
395
396 let output = input_stream.map(move |result| {
397 let batch = result?;
398 if batch.num_rows() == 0 {
399 return Ok(RecordBatch::new_empty(Arc::clone(&schema)));
400 }
401 probe_batch(
402 &batch,
403 &converter,
404 &index,
405 &lookup_batch,
406 &stream_key_indices,
407 join_type,
408 &schema,
409 stream_field_count,
410 &projection,
411 )
412 });
413
414 Ok(Box::pin(RecordBatchStreamAdapter::new(
415 self.schema(),
416 output,
417 )))
418 }
419}
420
421impl datafusion::physical_plan::ExecutionPlanProperties for LookupJoinExec {
422 fn output_partitioning(&self) -> &Partitioning {
423 self.properties.output_partitioning()
424 }
425
426 fn output_ordering(&self) -> Option<&LexOrdering> {
427 self.properties.output_ordering()
428 }
429
430 fn boundedness(&self) -> Boundedness {
431 Boundedness::Unbounded {
432 requires_infinite_memory: false,
433 }
434 }
435
436 fn pipeline_behavior(&self) -> EmissionType {
437 EmissionType::Incremental
438 }
439
440 fn equivalence_properties(&self) -> &EquivalenceProperties {
441 self.properties.equivalence_properties()
442 }
443}
444
445#[allow(clippy::too_many_arguments)]
450fn probe_batch(
451 stream_batch: &RecordBatch,
452 converter: &RowConverter,
453 index: &HashIndex,
454 lookup_batch: &RecordBatch,
455 stream_key_indices: &[usize],
456 join_type: LookupJoinType,
457 output_schema: &SchemaRef,
458 stream_field_count: usize,
459 projection: &[usize],
460) -> Result<RecordBatch> {
461 let key_cols: Vec<_> = stream_key_indices
462 .iter()
463 .map(|&i| stream_batch.column(i).clone())
464 .collect();
465 let rows = converter.convert_columns(&key_cols)?;
466
467 let num_rows = stream_batch.num_rows();
468 let mut stream_indices: Vec<u32> = Vec::with_capacity(num_rows);
469 let mut lookup_indices: Vec<Option<u32>> = Vec::with_capacity(num_rows);
470
471 #[allow(clippy::cast_possible_truncation)] for row in 0..num_rows {
473 if key_cols.iter().any(|c| c.is_null(row)) {
475 if join_type == LookupJoinType::LeftOuter {
476 stream_indices.push(row as u32);
477 lookup_indices.push(None);
478 }
479 continue;
480 }
481
482 let key = rows.row(row);
483 match index.probe(key.as_ref()) {
484 Some(matches) => {
485 for &lookup_row in matches {
486 stream_indices.push(row as u32);
487 lookup_indices.push(Some(lookup_row));
488 }
489 }
490 None if join_type == LookupJoinType::LeftOuter => {
491 stream_indices.push(row as u32);
492 lookup_indices.push(None);
493 }
494 None => {}
495 }
496 }
497
498 if stream_indices.is_empty() {
499 return Ok(RecordBatch::new_empty(Arc::clone(output_schema)));
500 }
501
502 let take_stream = UInt32Array::from(stream_indices);
504 let mut columns = Vec::with_capacity(stream_field_count + lookup_batch.num_columns());
505
506 for col in stream_batch.columns() {
507 columns.push(take(col.as_ref(), &take_stream, None)?);
508 }
509
510 let take_lookup: UInt32Array = lookup_indices.into_iter().collect();
512 for col in lookup_batch.columns() {
513 columns.push(take(col.as_ref(), &take_lookup, None)?);
514 }
515
516 debug_assert_eq!(
517 columns.len(),
518 stream_field_count + lookup_batch.num_columns(),
519 "output column count mismatch"
520 );
521
522 let projected_columns: Vec<_> = projection.iter().map(|&idx| columns[idx].clone()).collect();
523
524 debug_assert_eq!(
525 projected_columns.len(),
526 output_schema.fields().len(),
527 "projected column count mismatch"
528 );
529
530 Ok(RecordBatch::try_new(
531 Arc::clone(output_schema),
532 projected_columns,
533 )?)
534}
535
536fn resolve_physical_projection(
537 logical_schema: &datafusion::common::DFSchema,
538 stream_schema: &arrow::datatypes::Schema,
539 lookup_schema: &arrow::datatypes::Schema,
540 lookup_table: &str,
541 lookup_alias: Option<&str>,
542) -> Result<Vec<usize>> {
543 let mut projection = Vec::with_capacity(logical_schema.fields().len());
544 for idx in 0..logical_schema.fields().len() {
545 let (qualifier, field) = logical_schema.qualified_field(idx);
546 let name = field.name();
547 let is_lookup = if let Some(relation) = qualifier {
548 let table = relation.table();
549 table == lookup_table || Some(table) == lookup_alias
550 } else {
551 if stream_schema.index_of(name).is_ok() && lookup_schema.index_of(name).is_err() {
553 false
554 } else if lookup_schema.index_of(name).is_ok() && stream_schema.index_of(name).is_err()
555 {
556 true
557 } else {
558 stream_schema.index_of(name).is_ok()
559 }
560 };
561
562 if is_lookup {
563 let lookup_idx = lookup_schema.index_of(name).map_err(|_| {
564 DataFusionError::Plan(format!(
565 "lookup join projection: output field '{name}' not found in lookup \
566 schema {lookup_schema:?}"
567 ))
568 })?;
569 projection.push(stream_schema.fields().len() + lookup_idx);
570 } else {
571 let stream_idx = stream_schema.index_of(name).map_err(|_| {
572 DataFusionError::Plan(format!(
573 "lookup join projection: output field '{name}' not found in stream \
574 schema {stream_schema:?}"
575 ))
576 })?;
577 projection.push(stream_idx);
578 }
579 }
580 Ok(projection)
581}
582
583pub struct LookupJoinExtensionPlanner {
589 registry: Arc<LookupTableRegistry>,
590}
591
592impl LookupJoinExtensionPlanner {
593 pub fn new(registry: Arc<LookupTableRegistry>) -> Self {
595 Self { registry }
596 }
597}
598
599#[async_trait]
600impl ExtensionPlanner for LookupJoinExtensionPlanner {
601 #[allow(clippy::too_many_lines)]
602 async fn plan_extension(
603 &self,
604 _planner: &dyn PhysicalPlanner,
605 node: &dyn UserDefinedLogicalNode,
606 _logical_inputs: &[&LogicalPlan],
607 physical_inputs: &[Arc<dyn ExecutionPlan>],
608 session_state: &SessionState,
609 ) -> Result<Option<Arc<dyn ExecutionPlan>>> {
610 let Some(lookup_node) = node.as_any().downcast_ref::<LookupJoinNode>() else {
611 return Ok(None);
612 };
613
614 let entry = self
615 .registry
616 .get_entry(lookup_node.lookup_table_name())
617 .ok_or_else(|| {
618 DataFusionError::Plan(format!(
619 "lookup table '{}' not registered",
620 lookup_node.lookup_table_name()
621 ))
622 })?;
623
624 let input = Arc::clone(&physical_inputs[0]);
625 let stream_schema = input.schema();
626
627 match entry {
628 RegisteredLookup::Partial(partial_state) => {
629 let stream_key_indices = resolve_stream_keys(lookup_node, &stream_schema)?;
630
631 let mut output_fields = stream_schema.fields().to_vec();
632 output_fields.extend(partial_state.schema.fields().iter().cloned());
633 let output_schema = Arc::new(Schema::new(output_fields));
634
635 let exec = PartialLookupJoinExec::try_new_with_source(
636 input,
637 Arc::clone(&partial_state.lookup_cache),
638 stream_key_indices,
639 partial_state.key_sort_fields.clone(),
640 lookup_node.join_type(),
641 Arc::clone(&partial_state.schema),
642 output_schema,
643 partial_state.source.clone(),
644 Arc::clone(&partial_state.fetch_semaphore),
645 partial_state.projection.clone(),
646 )?;
647 Ok(Some(Arc::new(exec)))
648 }
649 RegisteredLookup::Snapshot(snapshot) => {
650 let lookup_schema = snapshot.batch.schema();
651 let lookup_key_indices = resolve_lookup_keys(lookup_node, &lookup_schema)?;
652
653 let lookup_batch = if lookup_node.pushdown_predicates().is_empty()
654 || snapshot.batch.num_rows() == 0
655 {
656 snapshot.batch.clone()
657 } else {
658 apply_pushdown_predicates(
659 &snapshot.batch,
660 lookup_node.pushdown_predicates(),
661 session_state,
662 )?
663 };
664
665 let stream_key_indices = resolve_stream_keys(lookup_node, &stream_schema)?;
666
667 for (si, li) in stream_key_indices.iter().zip(&lookup_key_indices) {
669 let st = stream_schema.field(*si).data_type();
670 let lt = lookup_schema.field(*li).data_type();
671 if st != lt {
672 return Err(DataFusionError::Plan(format!(
673 "Lookup join key type mismatch: stream '{}' is {st:?} \
674 but lookup '{}' is {lt:?}",
675 stream_schema.field(*si).name(),
676 lookup_schema.field(*li).name(),
677 )));
678 }
679 }
680
681 let logical_schema = lookup_node.schema();
682 let physical_projection = resolve_physical_projection(
683 logical_schema,
684 &stream_schema,
685 &lookup_schema,
686 lookup_node.lookup_table_name(),
687 lookup_node.lookup_alias(),
688 )?;
689
690 let output_schema = Arc::new(Schema::new(logical_schema.fields().to_vec()));
691
692 let exec = LookupJoinExec::try_new(
693 input,
694 lookup_batch,
695 stream_key_indices,
696 lookup_key_indices,
697 lookup_node.join_type(),
698 output_schema,
699 )?
700 .with_projection(physical_projection);
701
702 Ok(Some(Arc::new(exec)))
703 }
704 }
705 }
706}
707
708fn apply_pushdown_predicates(
711 batch: &RecordBatch,
712 predicates: &[Expr],
713 session_state: &SessionState,
714) -> Result<RecordBatch> {
715 use arrow::compute::filter_record_batch;
716 use datafusion::physical_expr::create_physical_expr;
717
718 let schema = batch.schema();
719 let df_schema = datafusion::common::DFSchema::try_from(schema.as_ref().clone())?;
720
721 let mut mask = None::<arrow_array::BooleanArray>;
722 for pred in predicates {
723 let phys_expr = create_physical_expr(pred, &df_schema, session_state.execution_props())?;
724 let result = phys_expr.evaluate(batch)?;
725 let bool_arr = result
726 .into_array(batch.num_rows())?
727 .as_any()
728 .downcast_ref::<arrow_array::BooleanArray>()
729 .ok_or_else(|| {
730 DataFusionError::Internal("pushdown predicate did not evaluate to boolean".into())
731 })?
732 .clone();
733 mask = Some(match mask {
734 Some(existing) => arrow::compute::and(&existing, &bool_arr)?,
735 None => bool_arr,
736 });
737 }
738
739 match mask {
740 Some(m) => Ok(filter_record_batch(batch, &m)?),
741 None => Ok(batch.clone()),
742 }
743}
744
745fn resolve_stream_keys(node: &LookupJoinNode, schema: &SchemaRef) -> Result<Vec<usize>> {
746 node.join_keys()
747 .iter()
748 .map(|pair| match &pair.stream_expr {
749 Expr::Column(col) => schema.index_of(&col.name).map_err(|_| {
750 DataFusionError::Plan(format!(
751 "stream key column '{}' not found in physical schema",
752 col.name
753 ))
754 }),
755 other => Err(DataFusionError::NotImplemented(format!(
756 "lookup join requires column references as stream keys, got: {other}"
757 ))),
758 })
759 .collect()
760}
761
762fn resolve_lookup_keys(node: &LookupJoinNode, schema: &SchemaRef) -> Result<Vec<usize>> {
763 node.join_keys()
764 .iter()
765 .map(|pair| {
766 schema.index_of(&pair.lookup_column).map_err(|_| {
767 DataFusionError::Plan(format!(
768 "lookup key column '{}' not found in lookup table schema",
769 pair.lookup_column
770 ))
771 })
772 })
773 .collect()
774}
775
776#[cfg(test)]
779mod tests;