Skip to main content

krishiv_sql/
runtime_filter_exec.rs

1//! Cross-stage runtime filter: the two plan nodes that carry a bloom filter
2//! from a join's build side into the probe side's *map stage*.
3//!
4//! # Why this exists at all
5//!
6//! DataFusion builds a dynamic filter from a join's build side and pushes it
7//! into the probe side's scan. That works single-node because both live in one
8//! plan. We cut the plan at every exchange, so the probe's scan runs in its own
9//! stage *before* the join stage exists — there is no build side to learn from,
10//! and every SF100 plan dump shows the placeholder unfilled:
11//!
12//! ```text
13//! lineitem ... predicate=l_returnflag = R AND DynamicFilter [ empty ]
14//! ```
15//!
16//! The cost is not the scan, it is the shuffle. TPC-H q10 hash-partitions ALL
17//! ~150M returned lineitem rows across a pod network measured at ~11 MiB/s when
18//! the orders side selects one quarter (~3.5%) and only ~7M of those rows can
19//! possibly join. Dropping the other 95% *before* the shuffle write removes the
20//! bytes from the wire, which is the binding constraint.
21//!
22//! # The shape
23//!
24//! ```text
25//!   stage B (build side)          stage F (filter)         stage P (probe)
26//!   ────────────────────          ────────────────         ───────────────
27//!   scan orders                   scan orders              scan lineitem
28//!   filter o_orderdate            filter o_orderdate       filter l_returnflag
29//!   └─> shuffle(o_orderkey)       project o_orderkey       └─> RuntimeFilterProbeExec
30//!                                 coalesce -> 1 task            ├── data
31//!                                 RuntimeFilterBuildExec        └── ShuffleReadExec(F)
32//!                                 └─> shuffle(keyless, 1)   └─> shuffle(l_orderkey)
33//! ```
34//!
35//! Stage F is a **clone of stage B's subtree**, not a read of its output: the
36//! build side is small by construction (the planner's selectivity gate refuses
37//! otherwise) and re-scanning it from local disk at ~300 MB/s beats re-reading
38//! its shuffle output across a ~11 MiB/s pod network. It is also what Spark's
39//! `InjectRuntimeFilter` does.
40//!
41//! Coalescing F to a single task is deliberate. The filter is a broadcast: every
42//! task of stage P must fetch the whole thing. One task producing one ~6.5 MB
43//! blob costs `P × 6.5 MB`; N tasks producing N partials costs `P × N × 6.5 MB`,
44//! which at q10's shape is 2 GB of wire to save a shuffle — the fix paying for
45//! itself many times over in the wrong direction.
46//!
47//! # Why the filter travels as ordinary shuffle data
48//!
49//! It is a one-row `RecordBatch` with a single `Binary` column, written through
50//! the same keyless single-partition gather the stage cutter already emits for
51//! ungrouped aggregates, and read back through an ordinary [`ShuffleReadExec`].
52//! So there is no new RPC, no new store key, no new transport, and the stage DAG
53//! edge `P → F` is an edge the scheduler already knows how to order and
54//! cycle-check.
55//!
56//! # Correctness
57//!
58//! A bloom filter has false positives but never false negatives, so a row that
59//! *can* join is never dropped and the join's output is unchanged. Everything
60//! here is built to keep that one property true: see
61//! [`krishiv_shuffle::RuntimeFilter`] for the encoding, union and fail-open
62//! rules, and `runtime_filter_candidates` in `distributed_plan` for the join
63//! shapes this may fire on (inner only).
64
65use std::fmt;
66use std::sync::Arc;
67
68use arrow::array::{Array, BinaryArray, RecordBatch};
69use arrow::datatypes::{DataType, Field, Schema, SchemaRef};
70use datafusion::error::DataFusionError;
71use datafusion::execution::TaskContext;
72use datafusion::physical_expr::EquivalenceProperties;
73use datafusion::physical_plan::execution_plan::{Boundedness, EmissionType};
74use datafusion::physical_plan::stream::RecordBatchStreamAdapter;
75use datafusion::physical_plan::{
76    DisplayAs, DisplayFormatType, ExecutionPlan, ExecutionPlanProperties, PlanProperties,
77    SendableRecordBatchStream,
78};
79use futures::{StreamExt, TryStreamExt};
80use krishiv_shuffle::{FilterKeyType, RuntimeFilter, RuntimeFilterBuilder};
81
82/// Env flag gating the whole feature. Off by default.
83///
84/// The name is long on purpose. `KRISHIV_RUNTIME_FILTERS` already exists — it is
85/// DataFusion's *in-plan* dynamic-filter master switch, and it defaults to on.
86/// Naming this one the singular of that would put two flags one letter apart,
87/// with opposite defaults and different mechanisms, in the same namespace: a
88/// trap an operator only discovers by setting one and measuring nothing change.
89///
90/// Guard 5 of the design register: two prior plan rules that fired too widely
91/// cost more than they gained (`semi-join-rule-was-the-pessimization` took q2
92/// from 189 s to a nested-loop join, and the broadcast over-reach regressed four
93/// queries). A rule that rewrites the stage DAG ships dark until a full 22-query
94/// sweep is clean against the queries we currently *win*, not only against q10.
95pub const RUNTIME_FILTER_ENV: &str = "KRISHIV_CROSS_STAGE_RUNTIME_FILTER";
96
97/// Is cross-stage runtime filtering enabled?
98#[must_use]
99pub fn enabled() -> bool {
100    std::env::var(RUNTIME_FILTER_ENV)
101        .ok()
102        .map(|v| {
103            let v = v.trim().to_ascii_lowercase();
104            v == "1" || v == "true" || v == "on" || v == "yes"
105        })
106        .unwrap_or(false)
107}
108
109/// Column name of the single `Binary` column a filter stage emits.
110pub const FILTER_COLUMN: &str = "krishiv_runtime_filter";
111
112/// Schema of a filter stage's output: one row, one serialized bloom.
113#[must_use]
114pub fn filter_schema() -> SchemaRef {
115    Arc::new(Schema::new(vec![Field::new(
116        FILTER_COLUMN,
117        DataType::Binary,
118        false,
119    )]))
120}
121
122fn exec_err(message: impl Into<String>) -> DataFusionError {
123    DataFusionError::Execution(message.into())
124}
125
126// ── RuntimeFilterBuildExec ─────────────────────────────────────────────────
127
128/// Consumes its input and emits ONE row: the serialized bloom filter of a
129/// single key column.
130///
131/// Input rows are not forwarded — this node's output *is* the filter. It is the
132/// root of a dedicated filter stage, never spliced into a data path.
133#[derive(Debug)]
134pub struct RuntimeFilterBuildExec {
135    input: Arc<dyn ExecutionPlan>,
136    /// Index of the key column in `input`'s schema.
137    key_index: usize,
138    key_type: FilterKeyType,
139    /// Filter size in bytes, fixed by the planner.
140    ///
141    /// Fixed rather than derived per task because [`RuntimeFilter::union`]
142    /// refuses partials of differing sizes: OR-ing different-sized bitsets
143    /// misplaces blocks and *loses* set bits, which is the one failure mode that
144    /// produces false negatives — i.e. wrong answers.
145    filter_bytes: usize,
146    properties: Arc<PlanProperties>,
147}
148
149impl RuntimeFilterBuildExec {
150    /// Build a filter node over `input`'s column `key_index`.
151    ///
152    /// Errors rather than panics on an out-of-range index or an unsupported key
153    /// type: this runs inside the stage builder, where declining to inject a
154    /// filter is always an acceptable outcome and a panic never is.
155    pub fn try_new(
156        input: Arc<dyn ExecutionPlan>,
157        key_index: usize,
158        filter_bytes: usize,
159    ) -> Result<Self, DataFusionError> {
160        let schema = input.schema();
161        let field = schema.fields().get(key_index).ok_or_else(|| {
162            exec_err(format!(
163                "runtime filter key index {key_index} is out of range for a {}-column input",
164                schema.fields().len()
165            ))
166        })?;
167        let key_type = FilterKeyType::for_data_type(field.data_type()).ok_or_else(|| {
168            exec_err(format!(
169                "runtime filter cannot key on {} ({:?})",
170                field.name(),
171                field.data_type()
172            ))
173        })?;
174        let out = filter_schema();
175        let properties = Arc::new(PlanProperties::new(
176            EquivalenceProperties::new(Arc::clone(&out)),
177            datafusion::physical_plan::Partitioning::UnknownPartitioning(
178                input.output_partitioning().partition_count().max(1),
179            ),
180            // The filter exists only once the last input row has been seen.
181            EmissionType::Final,
182            Boundedness::Bounded,
183        ));
184        Ok(Self {
185            input,
186            key_index,
187            key_type,
188            filter_bytes,
189            properties,
190        })
191    }
192
193    pub fn key_index(&self) -> usize {
194        self.key_index
195    }
196
197    pub fn filter_bytes(&self) -> usize {
198        self.filter_bytes
199    }
200}
201
202impl DisplayAs for RuntimeFilterBuildExec {
203    fn fmt_as(&self, _t: DisplayFormatType, f: &mut fmt::Formatter<'_>) -> fmt::Result {
204        write!(
205            f,
206            "RuntimeFilterBuildExec: key_index={}, key_type={:?}, bytes={}",
207            self.key_index, self.key_type, self.filter_bytes
208        )
209    }
210}
211
212impl ExecutionPlan for RuntimeFilterBuildExec {
213    fn name(&self) -> &str {
214        "RuntimeFilterBuildExec"
215    }
216
217    fn properties(&self) -> &Arc<PlanProperties> {
218        &self.properties
219    }
220
221    fn children(&self) -> Vec<&Arc<dyn ExecutionPlan>> {
222        vec![&self.input]
223    }
224
225    fn with_new_children(
226        self: Arc<Self>,
227        children: Vec<Arc<dyn ExecutionPlan>>,
228    ) -> datafusion::error::Result<Arc<dyn ExecutionPlan>> {
229        let [input] = <[Arc<dyn ExecutionPlan>; 1]>::try_from(children).map_err(|c| {
230            exec_err(format!(
231                "RuntimeFilterBuildExec takes exactly one child, got {}",
232                c.len()
233            ))
234        })?;
235        Ok(Arc::new(Self::try_new(
236            input,
237            self.key_index,
238            self.filter_bytes,
239        )?))
240    }
241
242    fn execute(
243        &self,
244        partition: usize,
245        context: Arc<TaskContext>,
246    ) -> datafusion::error::Result<SendableRecordBatchStream> {
247        let mut input = self.input.execute(partition, context)?;
248        let key_index = self.key_index;
249        let key_type = self.key_type;
250        let filter_bytes = self.filter_bytes;
251        let out = filter_schema();
252        let batch_schema = Arc::clone(&out);
253        let built = async move {
254            let mut builder = RuntimeFilterBuilder::new(filter_bytes, key_type);
255            while let Some(batch) = input.next().await {
256                let batch = batch?;
257                let column = batch.column(key_index);
258                builder
259                    .insert_array(column.as_ref())
260                    .map_err(|e| exec_err(format!("runtime filter build: {e}")))?;
261            }
262            let keys = builder.keys_inserted();
263            let bytes = builder
264                .finish()
265                .to_bytes()
266                .map_err(|e| exec_err(format!("runtime filter encode: {e}")))?;
267            tracing::debug!(
268                keys,
269                bytes = bytes.len(),
270                "built a cross-stage runtime filter"
271            );
272            let column = BinaryArray::from_vec(vec![bytes.as_slice()]);
273            RecordBatch::try_new(batch_schema, vec![Arc::new(column)])
274                .map_err(|e| exec_err(format!("runtime filter batch: {e}")))
275        };
276        Ok(Box::pin(RecordBatchStreamAdapter::new(
277            out,
278            futures::stream::once(built),
279        )))
280    }
281}
282
283// ── RuntimeFilterProbeExec ─────────────────────────────────────────────────
284
285/// Drops input rows whose key is provably absent from the build side.
286///
287/// Two children: `[data, filter_source]`. The filter source is an ordinary
288/// [`ShuffleReadExec`](crate::distributed_plan::ShuffleReadExec) over the filter
289/// stage, so awaiting it is what makes this stage wait for that one — the whole
290/// inverted dependency is expressed by having the node as a child.
291///
292/// **Fails open in every degenerate case.** No filter rows, an undecodable
293/// payload, or a key type that disagrees with the build side all yield "keep
294/// every row": slower than intended, never wrong.
295#[derive(Debug)]
296pub struct RuntimeFilterProbeExec {
297    input: Arc<dyn ExecutionPlan>,
298    filter_source: Arc<dyn ExecutionPlan>,
299    /// Index of the key column in `input`'s schema.
300    key_index: usize,
301    properties: Arc<PlanProperties>,
302}
303
304impl RuntimeFilterProbeExec {
305    pub fn try_new(
306        input: Arc<dyn ExecutionPlan>,
307        filter_source: Arc<dyn ExecutionPlan>,
308        key_index: usize,
309    ) -> Result<Self, DataFusionError> {
310        let schema = input.schema();
311        if key_index >= schema.fields().len() {
312            return Err(exec_err(format!(
313                "runtime filter probe index {key_index} is out of range for a {}-column input",
314                schema.fields().len()
315            )));
316        }
317        // Row count changes, so equivalences and ordering are the input's but
318        // statistics are not: report the input's partitioning verbatim, which is
319        // what keeps the stage's task count unchanged.
320        //
321        // The equivalence properties are **cloned from the input**, not rebuilt
322        // from the schema. This node drops rows and touches nothing else — same
323        // schema, same column values, same relative order — so every ordering
324        // and equivalence class the input advertised still holds.
325        //
326        // It used to construct `EquivalenceProperties::new(schema)`, which is
327        // empty: no orderings, no equivalence classes. That contradicted the
328        // sentence above it, and it was harmless only by accident — this node is
329        // injected solely beneath a hash join, which imposes no ordering
330        // requirement on its inputs, so nothing ever read the field.
331        //
332        // It stops being harmless the moment the runtime-filter rule reaches a
333        // `SortMergeJoinExec`, which *requires* its inputs sorted on the join
334        // keys. Declaring "no ordering" there would at best provoke a redundant
335        // `SortExec` over the probe side and at worst feed unsorted input to a
336        // merge join. Propagating the truth costs nothing and removes the trap
337        // before the rule is generalised.
338        let properties = Arc::new(PlanProperties::new(
339            input.equivalence_properties().clone(),
340            input.output_partitioning().clone(),
341            EmissionType::Incremental,
342            Boundedness::Bounded,
343        ));
344        Ok(Self {
345            input,
346            filter_source,
347            key_index,
348            properties,
349        })
350    }
351
352    pub fn key_index(&self) -> usize {
353        self.key_index
354    }
355}
356
357impl DisplayAs for RuntimeFilterProbeExec {
358    fn fmt_as(&self, _t: DisplayFormatType, f: &mut fmt::Formatter<'_>) -> fmt::Result {
359        write!(f, "RuntimeFilterProbeExec: key_index={}", self.key_index)
360    }
361}
362
363/// Read every filter row the source yields and union them into one filter.
364///
365/// `None` means "no filter available" — the caller must then pass all rows
366/// through. Partials are unioned rather than assumed single so that a filter
367/// stage with more than one task stays correct: a probe that saw only some of
368/// the partials would produce false negatives, which is a wrong answer.
369async fn collect_filter(
370    mut source: SendableRecordBatchStream,
371) -> Result<Option<RuntimeFilter>, DataFusionError> {
372    let mut merged: Option<RuntimeFilter> = None;
373    while let Some(batch) = source.next().await {
374        let batch = batch?;
375        let column = batch
376            .column(0)
377            .as_any()
378            .downcast_ref::<BinaryArray>()
379            .ok_or_else(|| {
380                exec_err(format!(
381                    "runtime filter stage produced {:?}, not Binary",
382                    batch.column(0).data_type()
383                ))
384            })?;
385        for i in 0..column.len() {
386            if column.is_null(i) {
387                continue;
388            }
389            let filter = RuntimeFilter::from_bytes(column.value(i))
390                .map_err(|e| exec_err(format!("runtime filter decode: {e}")))?;
391            match &mut merged {
392                Some(acc) => acc
393                    .union(&filter)
394                    .map_err(|e| exec_err(format!("runtime filter union: {e}")))?,
395                None => merged = Some(filter),
396            }
397        }
398    }
399    Ok(merged)
400}
401
402impl ExecutionPlan for RuntimeFilterProbeExec {
403    fn name(&self) -> &str {
404        "RuntimeFilterProbeExec"
405    }
406
407    fn properties(&self) -> &Arc<PlanProperties> {
408        &self.properties
409    }
410
411    fn children(&self) -> Vec<&Arc<dyn ExecutionPlan>> {
412        vec![&self.input, &self.filter_source]
413    }
414
415    fn with_new_children(
416        self: Arc<Self>,
417        children: Vec<Arc<dyn ExecutionPlan>>,
418    ) -> datafusion::error::Result<Arc<dyn ExecutionPlan>> {
419        let [input, filter_source] =
420            <[Arc<dyn ExecutionPlan>; 2]>::try_from(children).map_err(|c| {
421                exec_err(format!(
422                    "RuntimeFilterProbeExec takes exactly two children, got {}",
423                    c.len()
424                ))
425            })?;
426        Ok(Arc::new(Self::try_new(
427            input,
428            filter_source,
429            self.key_index,
430        )?))
431    }
432
433    /// Row counts shrink by an unknown amount, so the row estimate becomes
434    /// inexact-at-best and the byte estimate with it.
435    ///
436    /// Reported as the input's numbers rather than as unknown: `Absent` is read
437    /// by `SpillableJoinSelection` as "no idea, keep hash join", and laundering a
438    /// known size into an unknown one downstream of this node is how q18 ran out
439    /// of memory. An over-estimate is the safe direction — the filter only ever
440    /// removes rows.
441    fn partition_statistics(
442        &self,
443        partition: Option<usize>,
444    ) -> datafusion::error::Result<Arc<datafusion::common::Statistics>> {
445        let stats = self.input.partition_statistics(partition)?;
446        let mut stats = stats.as_ref().clone();
447        stats.num_rows = stats.num_rows.to_inexact();
448        stats.total_byte_size = stats.total_byte_size.to_inexact();
449        Ok(Arc::new(stats))
450    }
451
452    fn execute(
453        &self,
454        partition: usize,
455        context: Arc<TaskContext>,
456    ) -> datafusion::error::Result<SendableRecordBatchStream> {
457        // Partition 0 unconditionally: a filter stage gathers to exactly one
458        // output partition, and every probe task needs the whole filter.
459        let filter_stream = self.filter_source.execute(0, Arc::clone(&context))?;
460        let data = self.input.execute(partition, context)?;
461        let key_index = self.key_index;
462        let schema = self.input.schema();
463        let out = Arc::clone(&schema);
464        let filtered = futures::stream::once(async move {
465            let filter = collect_filter(filter_stream).await?;
466            let Some(filter) = filter else {
467                // No filter was produced. Passing every row through is the
468                // correct degradation; refusing rows here would be the one
469                // outcome this design must never have.
470                tracing::warn!(
471                    "runtime filter stage produced no filter; passing all probe rows through"
472                );
473                return Ok::<_, DataFusionError>(data.boxed());
474            };
475            Ok(data
476                .map(move |batch| {
477                    let batch = batch?;
478                    let mask = filter
479                        .contains(batch.column(key_index).as_ref())
480                        .map_err(|e| exec_err(format!("runtime filter probe: {e}")))?;
481                    arrow::compute::filter_record_batch(&batch, &mask)
482                        .map_err(|e| exec_err(format!("runtime filter apply: {e}")))
483                })
484                .boxed())
485        })
486        .try_flatten();
487        Ok(Box::pin(RecordBatchStreamAdapter::new(out, filtered)))
488    }
489}
490
491#[cfg(test)]
492mod tests {
493    use super::*;
494    use arrow::array::Int64Array;
495    use datafusion::catalog::memory::MemorySourceConfig;
496    use datafusion::datasource::source::DataSourceExec;
497    use datafusion::prelude::SessionContext;
498
499    fn keys(values: &[i64]) -> RecordBatch {
500        let schema = Arc::new(Schema::new(vec![Field::new("k", DataType::Int64, false)]));
501        RecordBatch::try_new(schema, vec![Arc::new(Int64Array::from(values.to_vec()))])
502            .expect("batch")
503    }
504
505    fn source(batch: RecordBatch) -> Arc<dyn ExecutionPlan> {
506        let schema = batch.schema();
507        let config = MemorySourceConfig::try_new(&[vec![batch]], schema, None).expect("source");
508        Arc::new(DataSourceExec::new(Arc::new(config)))
509    }
510
511    /// The probe node drops rows and changes nothing else, so every ordering
512    /// its input advertised still holds — and it must say so.
513    ///
514    /// It used to build `EquivalenceProperties::new(schema)`, which advertises
515    /// no ordering at all. That was invisible while this node only ever sat
516    /// beneath a hash join (no ordering requirement on its inputs), and becomes
517    /// a correctness hazard under a `SortMergeJoinExec`, which requires its
518    /// inputs sorted on the join keys: an input claiming "unordered" either
519    /// provokes a redundant sort or feeds a merge join something it cannot
520    /// merge.
521    #[test]
522    fn probe_preserves_its_input_ordering() {
523        use datafusion::physical_expr::expressions::Column;
524        use datafusion::physical_expr::{LexOrdering, PhysicalSortExpr};
525        use datafusion::physical_plan::sorts::sort::SortExec;
526
527        let ordering = LexOrdering::new(vec![PhysicalSortExpr::new_default(Arc::new(
528            Column::new("k", 0),
529        ))])
530        .expect("a single-column ordering");
531        let sorted: Arc<dyn ExecutionPlan> =
532            Arc::new(SortExec::new(ordering, source(keys(&[3, 1, 2]))));
533        let input_ordering = sorted.output_ordering().cloned();
534        assert!(
535            input_ordering.is_some(),
536            "precondition: the input must advertise an ordering"
537        );
538
539        let probe: Arc<dyn ExecutionPlan> = Arc::new(
540            RuntimeFilterProbeExec::try_new(sorted, source(keys(&[1])), 0)
541                .expect("probe over a sorted input"),
542        );
543
544        assert_eq!(
545            probe.output_ordering().cloned(),
546            input_ordering,
547            "a row-dropping filter must carry its input's ordering through"
548        );
549    }
550
551    async fn collect(plan: Arc<dyn ExecutionPlan>) -> Vec<RecordBatch> {
552        let ctx = SessionContext::new();
553        datafusion::physical_plan::collect(plan, ctx.task_ctx())
554            .await
555            .expect("collect")
556    }
557
558    /// The end-to-end property: a probe row whose key exists on the build side
559    /// always survives, and the surviving set never grows.
560    #[tokio::test]
561    async fn probe_keeps_every_row_that_could_join_and_drops_most_that_cannot() {
562        let build = source(keys(&(0..1000).map(|i| i * 2).collect::<Vec<_>>()));
563        let filter = Arc::new(RuntimeFilterBuildExec::try_new(build, 0, 4096).expect("build node"))
564            as Arc<dyn ExecutionPlan>;
565
566        // Probe carries the 1000 matching evens and 1000 non-matching odds.
567        let probe_keys: Vec<i64> = (0..2000).collect();
568        let probe = source(keys(&probe_keys));
569        let node = Arc::new(RuntimeFilterProbeExec::try_new(probe, filter, 0).expect("probe node"))
570            as Arc<dyn ExecutionPlan>;
571
572        let out = collect(node).await;
573        let kept: Vec<i64> = out
574            .iter()
575            .flat_map(|b| {
576                b.column(0)
577                    .as_any()
578                    .downcast_ref::<Int64Array>()
579                    .expect("i64")
580                    .values()
581                    .to_vec()
582            })
583            .collect();
584
585        for even in (0..2000).step_by(2) {
586            assert!(
587                kept.contains(&even),
588                "key {even} was inserted on the build side but the probe dropped it — \
589                 a false negative is a wrong answer, not a slow one"
590            );
591        }
592        assert!(
593            kept.len() < 1500,
594            "kept {} of 2000 rows; the filter is not rejecting the 1000 absent odd keys, \
595             so it removes no shuffle bytes at all",
596            kept.len()
597        );
598    }
599
600    /// A filter source that yields nothing must keep every row.
601    #[tokio::test]
602    async fn an_absent_filter_keeps_every_row_rather_than_dropping_them() {
603        let empty = MemorySourceConfig::try_new(&[vec![]], filter_schema(), None).expect("source");
604        let empty = Arc::new(DataSourceExec::new(Arc::new(empty))) as Arc<dyn ExecutionPlan>;
605        let probe = source(keys(&[1, 2, 3, 4, 5]));
606        let node = Arc::new(RuntimeFilterProbeExec::try_new(probe, empty, 0).expect("probe node"))
607            as Arc<dyn ExecutionPlan>;
608
609        let rows: usize = collect(node).await.iter().map(RecordBatch::num_rows).sum();
610        assert_eq!(
611            rows, 5,
612            "a missing filter must fail OPEN; dropping rows because the filter stage \
613             produced nothing would turn a transport problem into a wrong answer"
614        );
615    }
616
617    /// An empty build side rejects everything — the filter is real, not a
618    /// pass-through that happens to look right in the test above.
619    #[tokio::test]
620    async fn an_empty_build_side_rejects_every_probe_row() {
621        let build = MemorySourceConfig::try_new(
622            &[vec![]],
623            Arc::new(Schema::new(vec![Field::new("k", DataType::Int64, false)])),
624            None,
625        )
626        .expect("source");
627        let build = Arc::new(DataSourceExec::new(Arc::new(build))) as Arc<dyn ExecutionPlan>;
628        let filter = Arc::new(RuntimeFilterBuildExec::try_new(build, 0, 4096).expect("build"))
629            as Arc<dyn ExecutionPlan>;
630        let probe = source(keys(&[1, 2, 3, 4, 5]));
631        let node = Arc::new(RuntimeFilterProbeExec::try_new(probe, filter, 0).expect("probe node"))
632            as Arc<dyn ExecutionPlan>;
633
634        let rows: usize = collect(node).await.iter().map(RecordBatch::num_rows).sum();
635        assert_eq!(rows, 0, "nothing on the build side can join anything");
636    }
637
638    #[test]
639    fn an_unsupported_key_type_is_refused_at_construction() {
640        let schema = Arc::new(Schema::new(vec![Field::new("f", DataType::Float64, false)]));
641        let config = MemorySourceConfig::try_new(&[vec![]], schema, None).expect("source");
642        let input = Arc::new(DataSourceExec::new(Arc::new(config))) as Arc<dyn ExecutionPlan>;
643        assert!(
644            RuntimeFilterBuildExec::try_new(input, 0, 4096).is_err(),
645            "float keys must be refused, not guessed: -0.0 == 0.0 compares equal but \
646             hashes differently, so the filter would produce false negatives"
647        );
648    }
649
650    #[test]
651    fn an_out_of_range_key_index_is_an_error_not_a_panic() {
652        let input = source(keys(&[1]));
653        assert!(RuntimeFilterBuildExec::try_new(Arc::clone(&input), 7, 4096).is_err());
654        let filter = source(keys(&[1]));
655        assert!(RuntimeFilterProbeExec::try_new(input, filter, 7).is_err());
656    }
657
658    /// String keys are the other half of the corpus (q17/q20 filter `part` by
659    /// brand and container), and they take a different encoding path from
660    /// integers — raw bytes rather than a widened little-endian word.
661    #[tokio::test]
662    async fn string_keys_survive_the_round_trip_through_both_nodes() {
663        use arrow::array::StringArray;
664        fn names(values: &[&str]) -> RecordBatch {
665            let schema = Arc::new(Schema::new(vec![Field::new("k", DataType::Utf8, false)]));
666            RecordBatch::try_new(schema, vec![Arc::new(StringArray::from(values.to_vec()))])
667                .expect("batch")
668        }
669        let build = source(names(&["BRAND#11", "BRAND#23", "BRAND#42"]));
670        let filter = Arc::new(RuntimeFilterBuildExec::try_new(build, 0, 4096).expect("build"))
671            as Arc<dyn ExecutionPlan>;
672        let probe = source(names(&[
673            "BRAND#11", "BRAND#99", "BRAND#23", "BRAND#77", "BRAND#42",
674        ]));
675        let node = Arc::new(RuntimeFilterProbeExec::try_new(probe, filter, 0).expect("probe node"))
676            as Arc<dyn ExecutionPlan>;
677
678        let kept: Vec<String> = collect(node)
679            .await
680            .iter()
681            .flat_map(|b| {
682                let column = b
683                    .column(0)
684                    .as_any()
685                    .downcast_ref::<StringArray>()
686                    .expect("utf8");
687                (0..column.len())
688                    .map(|i| column.value(i).to_owned())
689                    .collect::<Vec<_>>()
690            })
691            .collect();
692        for present in ["BRAND#11", "BRAND#23", "BRAND#42"] {
693            assert!(
694                kept.iter().any(|k| k == present),
695                "{present} was on the build side and must survive"
696            );
697        }
698    }
699
700    /// Every probe partition must see the WHOLE filter. The filter stage gathers
701    /// to one output partition, so a node that passed its own partition index
702    /// through to the filter child would read an out-of-range partition on
703    /// partition 1 — or, worse, a partial filter.
704    #[tokio::test]
705    async fn every_probe_partition_reads_the_whole_filter() {
706        let build = source(keys(&[10, 20, 30]));
707        let filter = Arc::new(RuntimeFilterBuildExec::try_new(build, 0, 4096).expect("build"))
708            as Arc<dyn ExecutionPlan>;
709
710        let schema = Arc::new(Schema::new(vec![Field::new("k", DataType::Int64, false)]));
711        let part = |values: Vec<i64>| {
712            RecordBatch::try_new(
713                Arc::clone(&schema),
714                vec![Arc::new(Int64Array::from(values))],
715            )
716            .expect("batch")
717        };
718        let probe = MemorySourceConfig::try_new(
719            &[
720                vec![part(vec![10, 11])],
721                vec![part(vec![20, 21])],
722                vec![part(vec![30, 31])],
723            ],
724            Arc::clone(&schema),
725            None,
726        )
727        .expect("source");
728        let probe = Arc::new(DataSourceExec::new(Arc::new(probe))) as Arc<dyn ExecutionPlan>;
729        assert_eq!(
730            probe.output_partitioning().partition_count(),
731            3,
732            "precondition: the probe must actually be multi-partition"
733        );
734
735        let node = Arc::new(RuntimeFilterProbeExec::try_new(probe, filter, 0).expect("probe node"))
736            as Arc<dyn ExecutionPlan>;
737        let kept: Vec<i64> = collect(node)
738            .await
739            .iter()
740            .flat_map(|b| {
741                b.column(0)
742                    .as_any()
743                    .downcast_ref::<Int64Array>()
744                    .expect("i64")
745                    .values()
746                    .to_vec()
747            })
748            .collect();
749        for present in [10, 20, 30] {
750            assert!(
751                kept.contains(&present),
752                "key {present} lives in a different probe partition from the others; \
753                 missing it means that partition did not get the full filter"
754            );
755        }
756    }
757
758    /// `with_new_children` is what `datafusion-proto` and every optimizer rule
759    /// use to rebuild a node. A version that dropped the key index or swapped
760    /// the two children would filter on the wrong column — silently, and only
761    /// after a round trip.
762    #[test]
763    fn rebuilding_with_new_children_preserves_the_key_and_child_order() {
764        let data = source(keys(&[1, 2, 3]));
765        let filter = source(keys(&[1]));
766        let node = Arc::new(
767            RuntimeFilterProbeExec::try_new(Arc::clone(&data), Arc::clone(&filter), 0)
768                .expect("probe"),
769        );
770        let rebuilt = ExecutionPlan::with_new_children(node, vec![data, filter]).expect("rebuild");
771        let rebuilt = rebuilt
772            .downcast_ref::<RuntimeFilterProbeExec>()
773            .expect("still a probe node");
774        assert_eq!(rebuilt.key_index(), 0);
775        assert_eq!(rebuilt.children().len(), 2);
776
777        let build =
778            Arc::new(RuntimeFilterBuildExec::try_new(source(keys(&[1])), 0, 8192).expect("build"));
779        let rebuilt =
780            ExecutionPlan::with_new_children(build, vec![source(keys(&[1]))]).expect("rebuild");
781        let rebuilt = rebuilt
782            .downcast_ref::<RuntimeFilterBuildExec>()
783            .expect("still a build node");
784        assert_eq!(
785            rebuilt.filter_bytes(),
786            8192,
787            "the planner-fixed size must survive a rebuild: partials of differing \
788             sizes cannot be unioned without losing set bits"
789        );
790    }
791
792    #[test]
793    fn rebuilding_with_the_wrong_number_of_children_is_an_error() {
794        let node = Arc::new(
795            RuntimeFilterProbeExec::try_new(source(keys(&[1])), source(keys(&[1])), 0)
796                .expect("probe"),
797        );
798        assert!(ExecutionPlan::with_new_children(node, vec![source(keys(&[1]))]).is_err());
799    }
800}