1use 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
82pub const RUNTIME_FILTER_ENV: &str = "KRISHIV_CROSS_STAGE_RUNTIME_FILTER";
96
97#[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
109pub const FILTER_COLUMN: &str = "krishiv_runtime_filter";
111
112#[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#[derive(Debug)]
134pub struct RuntimeFilterBuildExec {
135 input: Arc<dyn ExecutionPlan>,
136 key_index: usize,
138 key_type: FilterKeyType,
139 filter_bytes: usize,
146 properties: Arc<PlanProperties>,
147}
148
149impl RuntimeFilterBuildExec {
150 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 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#[derive(Debug)]
296pub struct RuntimeFilterProbeExec {
297 input: Arc<dyn ExecutionPlan>,
298 filter_source: Arc<dyn ExecutionPlan>,
299 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 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
363async 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 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 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 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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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}