Skip to main content

laminar_sql/datafusion/lookup_join_exec/
partial.rs

1//! Partial lookup execution with cache hits and bounded source fallback.
2//!
3//! The operator owns miss collection, one fallback lookup per batch, and deterministic reassembly
4//! into original stream-row order.
5
6use super::{
7    fmt, take, Arc, Array, Boundedness, ColumnId, DataFusionError, Debug, DisplayAs,
8    DisplayFormatType, EmissionType, EquivalenceProperties, ExecutionPlan, Formatter, LexOrdering,
9    LookupJoinType, LookupMemoryCache, LookupSourceDyn, Partitioning, PlanProperties, RecordBatch,
10    RecordBatchStreamAdapter, Result, RowConverter, Schema, SchemaRef, Semaphore,
11    SendableRecordBatchStream, SortField, StreamExt, TaskContext, UInt32Array,
12    LOOKUP_SOURCE_TIMEOUT,
13};
14
15/// Physical plan that probes a bounded lookup cache per key for each input batch.
16///
17/// Used for on-demand tables whose full dataset does not fit in memory.
18pub struct PartialLookupJoinExec {
19    input: Arc<dyn ExecutionPlan>,
20    lookup_cache: Arc<LookupMemoryCache>,
21    stream_key_indices: Vec<usize>,
22    join_type: LookupJoinType,
23    schema: SchemaRef,
24    properties: Arc<PlanProperties>,
25    /// Prebuilt `RowConverter` — built once at planning time, reused on
26    /// every `execute()`. Previously rebuilt per-cycle.
27    converter: Arc<RowConverter>,
28    stream_field_count: usize,
29    lookup_schema: SchemaRef,
30    source: Option<Arc<dyn LookupSourceDyn>>,
31    fetch_semaphore: Arc<Semaphore>,
32    projection: Vec<ColumnId>,
33}
34
35impl PartialLookupJoinExec {
36    /// Creates a new partial lookup join executor.
37    ///
38    /// # Errors
39    ///
40    /// Returns an error if the output schema cannot be constructed.
41    pub fn try_new(
42        input: Arc<dyn ExecutionPlan>,
43        lookup_cache: Arc<LookupMemoryCache>,
44        stream_key_indices: Vec<usize>,
45        key_sort_fields: Vec<SortField>,
46        join_type: LookupJoinType,
47        lookup_schema: SchemaRef,
48        output_schema: SchemaRef,
49    ) -> Result<Self> {
50        Self::try_new_with_source(
51            input,
52            lookup_cache,
53            stream_key_indices,
54            key_sort_fields,
55            join_type,
56            lookup_schema,
57            output_schema,
58            None,
59            Arc::new(Semaphore::new(64)),
60            vec![],
61        )
62    }
63
64    /// Creates a new partial lookup join executor with optional source fallback.
65    ///
66    /// # Errors
67    ///
68    /// Returns an error if the output schema cannot be constructed.
69    #[allow(clippy::too_many_arguments)]
70    pub fn try_new_with_source(
71        input: Arc<dyn ExecutionPlan>,
72        lookup_cache: Arc<LookupMemoryCache>,
73        stream_key_indices: Vec<usize>,
74        key_sort_fields: Vec<SortField>,
75        join_type: LookupJoinType,
76        lookup_schema: SchemaRef,
77        output_schema: SchemaRef,
78        source: Option<Arc<dyn LookupSourceDyn>>,
79        fetch_semaphore: Arc<Semaphore>,
80        projection: Vec<ColumnId>,
81    ) -> Result<Self> {
82        let output_schema = if join_type == LookupJoinType::LeftOuter {
83            let stream_count = input.schema().fields().len();
84            let mut fields = output_schema.fields().to_vec();
85            for f in &mut fields[stream_count..] {
86                if !f.is_nullable() {
87                    *f = Arc::new(f.as_ref().clone().with_nullable(true));
88                }
89            }
90            Arc::new(Schema::new_with_metadata(
91                fields,
92                output_schema.metadata().clone(),
93            ))
94        } else {
95            output_schema
96        };
97
98        let properties = Arc::new(PlanProperties::new(
99            EquivalenceProperties::new(Arc::clone(&output_schema)),
100            Partitioning::UnknownPartitioning(1),
101            EmissionType::Incremental,
102            Boundedness::Unbounded {
103                requires_infinite_memory: false,
104            },
105        ));
106
107        let stream_field_count = input.schema().fields().len();
108        let converter = Arc::new(RowConverter::new(key_sort_fields)?);
109
110        Ok(Self {
111            input,
112            lookup_cache,
113            stream_key_indices,
114            join_type,
115            schema: output_schema,
116            properties,
117            converter,
118            stream_field_count,
119            lookup_schema,
120            source,
121            fetch_semaphore,
122            projection,
123        })
124    }
125}
126
127impl Debug for PartialLookupJoinExec {
128    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
129        f.debug_struct("PartialLookupJoinExec")
130            .field("join_type", &self.join_type)
131            .field("stream_keys", &self.stream_key_indices)
132            .field("cache_table_id", &self.lookup_cache.table_id())
133            .finish_non_exhaustive()
134    }
135}
136
137impl DisplayAs for PartialLookupJoinExec {
138    fn fmt_as(&self, t: DisplayFormatType, f: &mut Formatter<'_>) -> fmt::Result {
139        match t {
140            DisplayFormatType::Default | DisplayFormatType::Verbose => {
141                write!(
142                    f,
143                    "PartialLookupJoinExec: type={}, stream_keys={:?}, cache_entries={}",
144                    self.join_type,
145                    self.stream_key_indices,
146                    self.lookup_cache.len(),
147                )
148            }
149            DisplayFormatType::TreeRender => write!(f, "PartialLookupJoinExec"),
150        }
151    }
152}
153
154impl ExecutionPlan for PartialLookupJoinExec {
155    fn as_any(&self) -> &dyn std::any::Any {
156        self
157    }
158
159    fn name(&self) -> &'static str {
160        "PartialLookupJoinExec"
161    }
162
163    fn schema(&self) -> SchemaRef {
164        Arc::clone(&self.schema)
165    }
166
167    fn properties(&self) -> &Arc<PlanProperties> {
168        &self.properties
169    }
170
171    fn children(&self) -> Vec<&Arc<dyn ExecutionPlan>> {
172        vec![&self.input]
173    }
174
175    fn with_new_children(
176        self: Arc<Self>,
177        mut children: Vec<Arc<dyn ExecutionPlan>>,
178    ) -> Result<Arc<dyn ExecutionPlan>> {
179        if children.len() != 1 {
180            return Err(DataFusionError::Plan(
181                "PartialLookupJoinExec requires exactly one child".into(),
182            ));
183        }
184        Ok(Arc::new(Self {
185            input: children.swap_remove(0),
186            lookup_cache: Arc::clone(&self.lookup_cache),
187            stream_key_indices: self.stream_key_indices.clone(),
188            join_type: self.join_type,
189            schema: Arc::clone(&self.schema),
190            properties: self.properties.clone(),
191            converter: Arc::clone(&self.converter),
192            stream_field_count: self.stream_field_count,
193            lookup_schema: Arc::clone(&self.lookup_schema),
194            source: self.source.clone(),
195            fetch_semaphore: Arc::clone(&self.fetch_semaphore),
196            projection: self.projection.clone(),
197        }))
198    }
199
200    fn execute(
201        &self,
202        partition: usize,
203        context: Arc<TaskContext>,
204    ) -> Result<SendableRecordBatchStream> {
205        let input_stream = self.input.execute(partition, context)?;
206        let converter = Arc::clone(&self.converter);
207        let lookup_cache = Arc::clone(&self.lookup_cache);
208        let stream_key_indices = self.stream_key_indices.clone();
209        let join_type = self.join_type;
210        let schema = self.schema();
211        let stream_field_count = self.stream_field_count;
212        let lookup_schema = Arc::clone(&self.lookup_schema);
213        let source = self.source.clone();
214        let fetch_semaphore = Arc::clone(&self.fetch_semaphore);
215        let projection = self.projection.clone();
216
217        let output = input_stream.then(move |result| {
218            let lookup_cache = Arc::clone(&lookup_cache);
219            let converter = Arc::clone(&converter);
220            let stream_key_indices = stream_key_indices.clone();
221            let schema = Arc::clone(&schema);
222            let lookup_schema = Arc::clone(&lookup_schema);
223            let source = source.clone();
224            let fetch_semaphore = Arc::clone(&fetch_semaphore);
225            let projection = projection.clone();
226            async move {
227                let batch = result?;
228                if batch.num_rows() == 0 {
229                    return Ok(RecordBatch::new_empty(Arc::clone(&schema)));
230                }
231                probe_partial_batch_with_fallback(
232                    &batch,
233                    &converter,
234                    &lookup_cache,
235                    &stream_key_indices,
236                    join_type,
237                    &schema,
238                    stream_field_count,
239                    &lookup_schema,
240                    source.as_deref(),
241                    &fetch_semaphore,
242                    &projection,
243                )
244                .await
245            }
246        });
247
248        Ok(Box::pin(RecordBatchStreamAdapter::new(
249            self.schema(),
250            output,
251        )))
252    }
253}
254
255impl datafusion::physical_plan::ExecutionPlanProperties for PartialLookupJoinExec {
256    fn output_partitioning(&self) -> &Partitioning {
257        self.properties.output_partitioning()
258    }
259
260    fn output_ordering(&self) -> Option<&LexOrdering> {
261        self.properties.output_ordering()
262    }
263
264    fn boundedness(&self) -> Boundedness {
265        Boundedness::Unbounded {
266            requires_infinite_memory: false,
267        }
268    }
269
270    fn pipeline_behavior(&self) -> EmissionType {
271        EmissionType::Incremental
272    }
273
274    fn equivalence_properties(&self) -> &EquivalenceProperties {
275        self.properties.equivalence_properties()
276    }
277}
278
279/// Probes the lookup cache for each row in `stream_batch`, falling back
280/// to the async source for cache misses. Inserts source results into
281/// the cache before building the output.
282// PERF: This batch probe keeps cache lookup, fallback application, and output assembly together so
283// row indices and optional matches stay in one allocation domain; extracting a generic context
284// would add hot-path indirection without making the apply order easier to audit.
285#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
286async fn probe_partial_batch_with_fallback(
287    stream_batch: &RecordBatch,
288    converter: &RowConverter,
289    lookup_cache: &LookupMemoryCache,
290    stream_key_indices: &[usize],
291    join_type: LookupJoinType,
292    output_schema: &SchemaRef,
293    stream_field_count: usize,
294    lookup_schema: &SchemaRef,
295    source: Option<&dyn LookupSourceDyn>,
296    fetch_semaphore: &Semaphore,
297    projection: &[ColumnId],
298) -> Result<RecordBatch> {
299    let key_cols: Vec<_> = stream_key_indices
300        .iter()
301        .map(|&i| stream_batch.column(i).clone())
302        .collect();
303    let rows = converter.convert_columns(&key_cols)?;
304
305    let num_rows = stream_batch.num_rows();
306    let mut stream_indices: Vec<u32> = Vec::with_capacity(num_rows);
307    let mut lookup_batches: Vec<Option<RecordBatch>> = Vec::with_capacity(num_rows);
308    let mut miss_keys: Vec<(usize, Vec<u8>)> = Vec::new();
309
310    #[allow(clippy::cast_possible_truncation)]
311    for row in 0..num_rows {
312        // SQL semantics: NULL != NULL, so rows with any null key never match.
313        if key_cols.iter().any(|c| c.is_null(row)) {
314            if join_type == LookupJoinType::LeftOuter {
315                stream_indices.push(row as u32);
316                lookup_batches.push(None);
317            }
318            continue;
319        }
320
321        let key = rows.row(row);
322        let result = lookup_cache.get_cached(key.as_ref());
323        if let Some(batch) = result.into_batch() {
324            stream_indices.push(row as u32);
325            lookup_batches.push(Some(batch));
326        } else {
327            let idx = stream_indices.len();
328            stream_indices.push(row as u32);
329            lookup_batches.push(None);
330            miss_keys.push((idx, key.as_ref().to_vec()));
331        }
332    }
333
334    // Fetch missed keys from the source in a single batch query
335    if let Some(source) = source {
336        if !miss_keys.is_empty() {
337            let _permit = fetch_semaphore
338                .acquire()
339                .await
340                .map_err(|_| DataFusionError::Internal("fetch semaphore closed".into()))?;
341
342            let key_refs: Vec<&[u8]> = miss_keys.iter().map(|(_, k)| k.as_slice()).collect();
343
344            // Propagate fetch failures instead of silently serving cache-only
345            // results: returning Err replays the cycle rather than dropping
346            // inner-join matches or NULL-filling left-join rows.
347            let results = match tokio::time::timeout(
348                LOOKUP_SOURCE_TIMEOUT,
349                source.query_batch(&key_refs, &[], projection),
350            )
351            .await
352            {
353                Ok(Ok(results)) => results,
354                Ok(Err(e)) => {
355                    return Err(DataFusionError::Execution(format!(
356                        "lookup source query failed ({} keys): {e}",
357                        miss_keys.len()
358                    )));
359                }
360                Err(_elapsed) => {
361                    return Err(DataFusionError::Execution(format!(
362                        "lookup source query timed out after {LOOKUP_SOURCE_TIMEOUT:?} \
363                         ({} keys)",
364                        miss_keys.len()
365                    )));
366                }
367            };
368
369            if results.len() != miss_keys.len() {
370                return Err(DataFusionError::Execution(format!(
371                    "Lookup source returned mismatched results cardinality. Expected {} results, but got {} results. Miss keys: {:?}",
372                    miss_keys.len(),
373                    results.len(),
374                    miss_keys
375                )));
376            }
377
378            for ((idx, key_bytes), maybe_batch) in miss_keys.iter().zip(results) {
379                if let Some(batch) = maybe_batch {
380                    lookup_cache.insert(key_bytes, batch.clone());
381                    lookup_batches[*idx] = Some(batch);
382                }
383            }
384        }
385    }
386
387    // For inner joins, remove rows that still have no match
388    if join_type == LookupJoinType::Inner {
389        let mut write = 0;
390        for read in 0..stream_indices.len() {
391            if lookup_batches[read].is_some() {
392                stream_indices[write] = stream_indices[read];
393                lookup_batches.swap(write, read);
394                write += 1;
395            }
396        }
397        stream_indices.truncate(write);
398        lookup_batches.truncate(write);
399    }
400
401    if stream_indices.is_empty() {
402        return Ok(RecordBatch::new_empty(Arc::clone(output_schema)));
403    }
404
405    let take_indices = UInt32Array::from(stream_indices);
406    let mut columns = Vec::with_capacity(output_schema.fields().len());
407
408    for col in stream_batch.columns() {
409        columns.push(take(col.as_ref(), &take_indices, None)?);
410    }
411
412    let lookup_col_count = lookup_schema.fields().len();
413    for col_idx in 0..lookup_col_count {
414        let arrays: Vec<_> = lookup_batches
415            .iter()
416            .map(|opt| match opt {
417                Some(b) => b.column(col_idx).clone(),
418                None => arrow_array::new_null_array(lookup_schema.field(col_idx).data_type(), 1),
419            })
420            .collect();
421        let refs: Vec<&dyn arrow_array::Array> = arrays.iter().map(AsRef::as_ref).collect();
422        columns.push(arrow::compute::concat(&refs)?);
423    }
424
425    debug_assert_eq!(
426        columns.len(),
427        stream_field_count + lookup_col_count,
428        "output column count mismatch"
429    );
430
431    Ok(RecordBatch::try_new(Arc::clone(output_schema), columns)?)
432}