krishiv_sql/distributed_plan.rs
1//! Distributed physical-plan fragments and the stage builder (ADR-0003).
2//!
3//! Phase 52 replaces stringly `sql: <query>` task bodies with
4//! protobuf-encoded DataFusion physical-plan subtrees. The
5//! [`krishiv_plan::TypedTaskFragment`] envelope stays as the carrier; this
6//! module owns the `dfplan:` body kind — encoding on the coordinator (stage
7//! builder) and decoding on the executor.
8//!
9//! Body format: `dfplan:v1:<partspec>:<base64(plan proto bytes)>` where
10//! `<partspec>` names the output partition(s) of the decoded plan this task
11//! executes. The stage builder emits one partition per task
12//! (`dfplan:v1:3:<b64>`); Phase 54 AQE rewrites extend the grammar:
13//!
14//! - **Coalescing**: `dfplan:v1:1,4,7:<b64>` — the task executes each listed
15//! root partition and concatenates the streams. Correct for any plan
16//! shape: root partitions are independent (each is a complete hash
17//! group), so the union of a task group's outputs equals the union the
18//! original one-task-per-partition layout would produce.
19//! - **Skew split**: `dfplan:v1:5/s0m2-4:<b64>` — the task executes root
20//! partition 5 but, for upstream stage 0, reads only map tasks `[2, 4)`.
21//! Splitting is only correct when nothing above the shuffle read blocks
22//! on seeing the whole partition (see [`dfplan_body_is_split_safe`]).
23//!
24//! The `v1` segment is independent of the envelope version so plan-proto
25//! evolution (e.g. a DataFusion upgrade that changes the proto) is detected
26//! explicitly instead of failing deep inside prost decoding.
27//!
28//! # Stage building
29//!
30//! [`build_distributed_stages`] cuts an optimized physical plan at hash
31//! `RepartitionExec` boundaries (Ballista-style): the subtree below each cut
32//! becomes a ShuffleMap stage whose tasks hash-partition their output into
33//! the shuffle store; the cut point is replaced by a [`ShuffleReadExec`]
34//! leaf that streams those partitions back on the reduce side. Any shape
35//! the builder cannot prove correct returns `None` — the caller falls back
36//! to today's single-task `sql:` path (capability honesty).
37
38use std::fmt;
39use std::sync::Arc;
40
41use arrow::datatypes::SchemaRef;
42use base64::Engine as _;
43use datafusion::error::DataFusionError;
44use datafusion::execution::TaskContext;
45use datafusion::logical_expr::execution_props::ScalarSubqueryResults;
46use datafusion::physical_expr::EquivalenceProperties;
47use datafusion::physical_plan::coalesce_partitions::CoalescePartitionsExec;
48use datafusion::physical_plan::execution_plan::{Boundedness, EmissionType};
49use datafusion::physical_plan::repartition::RepartitionExec;
50use datafusion::physical_plan::scalar_subquery::{ScalarSubqueryExec, ScalarSubqueryLink};
51use datafusion::physical_plan::sorts::sort::SortExec;
52use datafusion::physical_plan::sorts::sort_preserving_merge::SortPreservingMergeExec;
53use datafusion::physical_plan::stream::RecordBatchStreamAdapter;
54use datafusion::physical_plan::{
55 DisplayAs, DisplayFormatType, ExecutionPlan, ExecutionPlanProperties as _, Partitioning,
56 PlanProperties, SendableRecordBatchStream,
57};
58use datafusion::prelude::SessionContext;
59use datafusion_proto::physical_plan::PhysicalExtensionCodec;
60use futures::{StreamExt as _, TryStreamExt as _};
61
62use crate::{SqlError, SqlResult};
63
64/// Task-fragment body prefix for proto-encoded physical-plan subtrees.
65pub const DFPLAN_BODY_PREFIX: &str = "dfplan:v1:";
66
67/// Env var overriding the target partition count used when planning a
68/// distributed batch query (bounds both scan parallelism and shuffle
69/// partition count). Unset, the count is derived from the cluster — see
70/// [`resolve_stage_target_partitions`].
71pub const STAGE_TARGET_PARTITIONS_ENV: &str = "KRISHIV_STAGE_TARGET_PARTITIONS";
72
73/// Env var that disables stage splitting entirely (`off`/`0`/`false`).
74pub const STAGE_SPLIT_ENV: &str = "KRISHIV_STAGE_SPLIT";
75
76/// Build-side byte ceiling under which a join is broadcast rather than
77/// hash-shuffled, on the **staged** path only. See `planning_session_context`
78/// for why the distributed default differs from DataFusion's.
79pub const BROADCAST_JOIN_BYTES_ENV: &str = "KRISHIV_BROADCAST_JOIN_BYTES";
80
81/// Default build-side byte ceiling for broadcasting on the staged path.
82///
83/// DataFusion's own default is 1 MiB, tuned for a single process where a
84/// shuffle is a memcpy; here a shuffle is the pod network. See
85/// [`planning_session_context_with_options`] for the measurement behind 32 MiB.
86const DEFAULT_BROADCAST_JOIN_BYTES: usize = 32 * 1024 * 1024;
87
88/// Default build-side row ceiling, used only when a byte estimate is absent.
89const DEFAULT_BROADCAST_JOIN_ROWS: usize = 1_000_000;
90
91/// How many tasks to create per available slot.
92///
93/// One task per slot fills the cluster in a single wave, but a single wave is
94/// as slow as its slowest task: any skew, any straggler, any cold cache is
95/// paid in full with no other work to overlap it. Splitting the same work into
96/// two waves lets fast slots pick up a second task while a slow one is still
97/// on its first, and halves the bytes each task holds at once. Spark's
98/// long-standing guidance is 2–3× the core count for the same reasons; 2 is
99/// the conservative end, since each extra wave also multiplies shuffle
100/// fragments by the partition count.
101const TASKS_PER_SLOT: usize = 2;
102
103/// Never plan fewer than this many partitions: below 2 there is no exchange to
104/// cut and the query degrades to a single task.
105const MIN_STAGE_PARTITIONS: usize = 2;
106
107/// Upper bound on planned partitions. Past this the shuffle fragment count
108/// (partitions², written and then fetched individually) costs more than the
109/// added parallelism returns.
110const MAX_STAGE_PARTITIONS: usize = 512;
111
112/// The compute capacity a query is being planned against.
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114pub struct ClusterCapacity {
115 /// Task slots currently schedulable across every live executor.
116 pub total_slots: usize,
117}
118
119/// Resolve the planning-time target partition count for distributed stages.
120///
121/// `cluster` is the live capacity the coordinator sees; `None` means the
122/// caller has no cluster view (the embedded in-process runtime), in which case
123/// the local machine's parallelism stands in for it.
124///
125/// This used to be the constant 4 regardless of anything. A 4-partition plan
126/// leaves a 32-slot cluster 87% idle, and on a 2-slot cluster it queues work
127/// two deep — the number was never related to the hardware it ran on. Deriving
128/// it from live slots is what makes a query fill the cluster it was actually
129/// submitted to.
130///
131/// The result is an upper bound, not a promise: DataFusion groups scan files
132/// into at most one partition per file group, so a query over three files
133/// plans three scan partitions however high this is set. Small inputs
134/// therefore stay cheap without needing a size term here.
135#[must_use]
136pub fn resolve_stage_target_partitions(cluster: Option<ClusterCapacity>) -> usize {
137 derive_stage_target_partitions(
138 std::env::var(STAGE_TARGET_PARTITIONS_ENV)
139 .ok()
140 .and_then(|v| v.trim().parse::<usize>().ok()),
141 cluster,
142 std::thread::available_parallelism()
143 .map(std::num::NonZeroUsize::get)
144 .unwrap_or(1),
145 )
146}
147
148/// The pure derivation behind [`resolve_stage_target_partitions`], with the
149/// environment and the machine passed in so it is testable (the workspace
150/// forbids `unsafe`, so tests cannot set environment variables).
151#[must_use]
152pub fn derive_stage_target_partitions(
153 explicit: Option<usize>,
154 cluster: Option<ClusterCapacity>,
155 local_cores: usize,
156) -> usize {
157 if let Some(explicit) = explicit.filter(|&n| n >= MIN_STAGE_PARTITIONS) {
158 return explicit;
159 }
160 cluster
161 .map_or(local_cores, |c| c.total_slots)
162 .saturating_mul(TASKS_PER_SLOT)
163 .clamp(MIN_STAGE_PARTITIONS, MAX_STAGE_PARTITIONS)
164}
165
166/// True unless stage splitting is disabled via [`STAGE_SPLIT_ENV`].
167pub fn stage_split_enabled() -> bool {
168 !matches!(
169 std::env::var(STAGE_SPLIT_ENV)
170 .unwrap_or_default()
171 .trim()
172 .to_ascii_lowercase()
173 .as_str(),
174 "off" | "0" | "false" | "disabled"
175 )
176}
177
178/// Session context used to plan a query for distributed stage execution.
179///
180/// Round-robin repartitioning is disabled: a `RoundRobinBatch` exchange left
181/// inside a stage subtree would make every task of that stage re-execute all
182/// input partitions (RepartitionExec drives all inputs per process), so only
183/// hash exchanges — which the builder cuts into shuffle boundaries — are
184/// allowed into the plan.
185pub fn planning_session_context(target_partitions: usize) -> SessionContext {
186 planning_session_context_with_join_threshold(target_partitions, None)
187}
188
189/// As [`planning_session_context`], with the spillable-join build-side
190/// threshold supplied instead of derived from this process's cgroup.
191///
192/// `None` keeps the derived threshold, which is what production uses. Tests
193/// pin it because the rule's behaviour is the whole difference between a
194/// 3-core executor with a ~700 MB per-task share and a build box with tens of
195/// gigabytes: a defect that only appears once joins actually convert is
196/// invisible on the machine the tests run on.
197pub fn planning_session_context_with_join_threshold(
198 target_partitions: usize,
199 spill_join_build_bytes: Option<u64>,
200) -> SessionContext {
201 planning_session_context_with_options(target_partitions, spill_join_build_bytes, None)
202}
203
204/// As [`planning_session_context_with_join_threshold`], with the broadcast
205/// (`CollectLeft`) build-side ceiling supplied instead of read from
206/// [`BROADCAST_JOIN_BYTES_ENV`].
207///
208/// `None` keeps the env-or-default value, which is what production uses.
209///
210/// Pinning it is the only way to reach the *cluster's* join shape from a test.
211/// The staged path broadcasts any build side under 32 MiB, and every fixture
212/// small enough to run in-process is far under that — so a two-table join that
213/// hash-shuffles **both** sides at SF100, and therefore plans a reduce stage
214/// with two `ShuffleReadExec` leaves reading two different upstream stages,
215/// collapses in tests to one broadcast join with a single shuffle input. The
216/// two shapes exercise different code, and only the small one was ever tested.
217pub fn planning_session_context_with_options(
218 target_partitions: usize,
219 spill_join_build_bytes: Option<u64>,
220 broadcast_join_bytes: Option<usize>,
221) -> SessionContext {
222 // A6: this was `SessionConfig::new()` — a bare DataFusion config carrying
223 // none of the engine's settings, so `KRISHIV_RUNTIME_FILTERS` was a no-op
224 // distributed, the SQL dialect differed from the one the query was written
225 // against, and the batch size was DataFusion's rather than the engine's.
226 // Sharing `build_single_node_session_config` is what makes the staged plan
227 // the same plan the engine would have produced.
228 let tp = std::num::NonZeroUsize::new(target_partitions.max(1))
229 .unwrap_or(std::num::NonZeroUsize::MIN);
230 let mut config = crate::build_single_node_session_config(tp, None);
231 // The one deliberate divergence, and the reason this cannot simply call a
232 // SqlEngine constructor: a RoundRobinBatch exchange left inside a stage
233 // subtree would make every task of that stage re-execute all input
234 // partitions. Only hash exchanges — which the builder cuts into shuffle
235 // boundaries — may enter a staged plan.
236 config
237 .options_mut()
238 .optimizer
239 .enable_round_robin_repartition = false;
240
241 // D1 interim (review 2026-07-27): broadcast a small build side instead of
242 // hash-shuffling both sides of the join.
243 //
244 // DataFusion's defaults are 1 MiB / 128k rows, tuned for a single process
245 // where a shuffle is a memcpy. Here a shuffle is the pod network, measured
246 // at ~11 MiB/s across three separate VPS hosts. q8/q9 hash-partition the
247 // raw 600 M-row `lineitem` scan — ~36 GiB on the wire, a ~55-minute floor —
248 // because the filtered dimension side lands just over 1 MiB and so is not
249 // eligible to broadcast. Collecting a few tens of MiB once per task is
250 // enormously cheaper than moving lineitem, and it is bounded: the build
251 // side is collected into the task's memory pool, whose per-task share on
252 // this cluster is ~732 MB, so the ceiling below is ~4% of it.
253 //
254 // Deliberately set only on the STAGED path — the embedded engine keeps
255 // DataFusion's defaults, where they are correct.
256 //
257 // A build side that only *looks* small is caught afterwards by
258 // [`is_degenerate_broadcast_join`], but only for the one case DataFusion
259 // gets wrong: it happily broadcasts on an estimate of ZERO rows, which is
260 // how q21 serialised. The ceiling itself is left entirely to DataFusion —
261 // overriding it cost q8 4x and q9 2.5x on the cluster.
262 let broadcast_bytes = broadcast_join_bytes.unwrap_or_else(|| {
263 std::env::var(BROADCAST_JOIN_BYTES_ENV)
264 .ok()
265 .and_then(|v| v.trim().parse::<usize>().ok())
266 .filter(|n| *n > 0)
267 .unwrap_or(DEFAULT_BROADCAST_JOIN_BYTES)
268 });
269 let opts = config.options_mut();
270 opts.optimizer.hash_join_single_partition_threshold = broadcast_bytes;
271 // Both ceilings gate the same decision, so a caller asking for "never
272 // broadcast" (0 bytes) must get the row ceiling zeroed too — otherwise a
273 // small build side still collects and the request is silently ignored.
274 opts.optimizer.hash_join_single_partition_threshold_rows = if broadcast_bytes == 0 {
275 0
276 } else {
277 DEFAULT_BROADCAST_JOIN_ROWS
278 };
279
280 // A6: the rules. `planning_session_context` is where every distributed
281 // query is planned, and it carried no engine rules at all — so
282 // `SpillableJoinSelection` (q18), the semi-join reductions (q17) and
283 // `CooperativeAmplifiers` (distributed cancel) were dead on exactly the
284 // path being benchmarked. See `crate::with_krishiv_optimizer_rules`.
285 let state_builder = crate::with_krishiv_optimizer_rules_with_join_threshold(
286 datafusion::execution::session_state::SessionStateBuilder::new().with_default_features(),
287 spill_join_build_bytes,
288 )
289 .with_config(config);
290
291 // Object-store tables must be plannable here: if schema inference fails,
292 // the caller reads that as "decline to stage" and the query silently runs
293 // as a single task on one executor.
294 let state_builder = match datafusion::execution::runtime_env::RuntimeEnvBuilder::new()
295 .with_object_store_registry(Arc::new(
296 crate::object_store_registry::LazyCloudObjectStoreRegistry::new(),
297 ))
298 .build_arc()
299 {
300 Ok(runtime) => state_builder.with_runtime_env(runtime),
301 // A runtime that will not build is not worth failing planning over —
302 // the default one still plans local paths, and object-store tables
303 // fall back to the single-task path as they did before.
304 Err(error) => {
305 tracing::warn!(%error, "cloud object-store registry unavailable for staged planning");
306 state_builder
307 }
308 };
309 SessionContext::new_with_state(state_builder.build())
310}
311
312/// Shuffle-store sub-stage key for one map task's output.
313///
314/// Multiple map tasks of the same stage write the same reduce-partition
315/// space; the shuffle store replaces on duplicate `(job, stage, partition)`
316/// keys, so each map task writes under its own sub-stage key and the reduce
317/// side merges across `0..num_map_tasks`. Both sides derive the key from
318/// this function — it is a wire contract between coordinator and executor.
319pub fn shuffle_stage_key(stage_index: usize, map_task_index: usize) -> String {
320 format!("s{stage_index}.m{map_task_index}")
321}
322
323// ── Fragment body encode/decode ────────────────────────────────────────────
324
325/// Encode a physical plan (sub)tree to raw proto bytes.
326pub fn encode_dfplan_bytes(
327 plan: Arc<dyn ExecutionPlan>,
328 codec: &dyn PhysicalExtensionCodec,
329) -> SqlResult<Vec<u8>> {
330 datafusion_proto::bytes::physical_plan_to_bytes_with_extension_codec(plan, codec)
331 .map(|b| b.to_vec())
332 .map_err(|e| SqlError::DataFusion {
333 message: format!("physical plan proto encode: {e}"),
334 })
335}
336
337/// Restriction of a task's shuffle reads to a subrange of one upstream
338/// stage's map tasks (Phase 54 skew split).
339#[derive(Debug, Clone, PartialEq, Eq)]
340pub struct DfplanMapRange {
341 /// Builder index of the upstream stage whose reads are restricted.
342 pub upstream_stage_index: usize,
343 /// First map-task index read (inclusive).
344 pub start: usize,
345 /// One past the last map-task index read (exclusive).
346 pub end: usize,
347}
348
349/// Parsed partition assignment of a `dfplan:v1:` task body.
350#[derive(Debug, Clone, PartialEq, Eq)]
351pub struct DfplanTaskSpec {
352 /// Root output partitions this task executes (non-empty, in order).
353 pub partitions: Vec<usize>,
354 /// Optional skew-split map-task restriction.
355 pub map_range: Option<DfplanMapRange>,
356}
357
358impl DfplanTaskSpec {
359 /// Single-partition spec (the stage builder's default shape).
360 pub fn single(partition: usize) -> Self {
361 Self {
362 partitions: vec![partition],
363 map_range: None,
364 }
365 }
366
367 /// Render the partition segment of the body grammar.
368 fn render(&self) -> String {
369 let mut out = self
370 .partitions
371 .iter()
372 .map(usize::to_string)
373 .collect::<Vec<_>>()
374 .join(",");
375 if let Some(range) = &self.map_range {
376 out.push_str(&format!(
377 "/s{}m{}-{}",
378 range.upstream_stage_index, range.start, range.end
379 ));
380 }
381 out
382 }
383}
384
385/// Assemble the per-task fragment body: `dfplan:v1:<partition>:<b64>`.
386pub fn dfplan_task_body(plan_bytes_b64: &str, partition: usize) -> String {
387 format!("{DFPLAN_BODY_PREFIX}{partition}:{plan_bytes_b64}")
388}
389
390/// Assemble a fragment body executing several root partitions (coalescing).
391pub fn dfplan_task_body_for_spec(plan_bytes_b64: &str, spec: &DfplanTaskSpec) -> String {
392 format!("{DFPLAN_BODY_PREFIX}{}:{plan_bytes_b64}", spec.render())
393}
394
395/// Rewrite an existing dfplan body to a new partition spec, preserving the
396/// encoded plan bytes verbatim (no proto decode — coordinator-side AQE
397/// rewrites reuse the b64 payload untouched).
398pub fn dfplan_body_with_spec(body: &str, spec: &DfplanTaskSpec) -> SqlResult<String> {
399 let (_, b64) = split_dfplan_body(body)?;
400 // Carry any leading Python-UDF directive(s) through to the rebuilt body.
401 //
402 // The parsers *skip* those directives; this function *re-emits* the body,
403 // and re-emitting from `DFPLAN_BODY_PREFIX` onward silently dropped them.
404 // AQE rewrites a reduce stage by rebuilding every task body through here, so
405 // an AQE-rewritten task shipped a plan that references the Python UDF with
406 // no directive telling the executor to reconstruct it — and the task died
407 // with "PhysicalExtensionCodec is not provided for scalar function <name>"
408 // while its sibling map tasks, whose bodies were never rebuilt, ran fine.
409 let trimmed = body.trim_start();
410 let rest = strip_leading_python_udf_directives(trimmed);
411 let directives = trimmed.get(..trimmed.len() - rest.len()).unwrap_or("");
412 Ok(format!(
413 "{directives}{DFPLAN_BODY_PREFIX}{}:{b64}",
414 spec.render()
415 ))
416}
417
418/// Strip the leading `/* krishiv-register-python-udf(a)f:… */` directive
419/// comment(s) a staged Python-UDF fragment carries ahead of its `dfplan:` body,
420/// returning the remaining body. A cheap no-op for any body without a leading
421/// directive. Keeps every dfplan-body parser — and the coordinator's
422/// `is_dfplan_body` shuffle-input wiring / AQE split analysis — working on a
423/// fragment that still carries its executor-side UDF registration directive.
424pub(crate) fn strip_leading_python_udf_directives(body: &str) -> &str {
425 const CLOSE: &str = " */";
426 let mut rest = body.trim_start();
427 while rest.starts_with("/* krishiv-register-python-udf:")
428 || rest.starts_with("/* krishiv-register-python-udaf:")
429 {
430 let Some(end) = rest.find(CLOSE) else { break };
431 rest = rest[end + CLOSE.len()..].trim_start();
432 }
433 rest
434}
435
436/// Split a body into its raw (partition segment, b64 payload) halves.
437fn split_dfplan_body(body: &str) -> SqlResult<(&str, &str)> {
438 let rest = strip_leading_python_udf_directives(body)
439 .strip_prefix(DFPLAN_BODY_PREFIX)
440 .ok_or_else(|| SqlError::DataFusion {
441 message: format!(
442 "task body is not a {DFPLAN_BODY_PREFIX} fragment: {}",
443 body.chars().take(48).collect::<String>()
444 ),
445 })?;
446 rest.split_once(':').ok_or_else(|| SqlError::DataFusion {
447 message: String::from("dfplan body missing partition segment"),
448 })
449}
450
451fn parse_partition_segment(segment: &str) -> SqlResult<DfplanTaskSpec> {
452 let (list, range) = match segment.split_once('/') {
453 Some((list, range_str)) => {
454 // `/s<stage>m<start>-<end>`
455 let rest = range_str
456 .strip_prefix('s')
457 .ok_or_else(|| SqlError::DataFusion {
458 message: format!("dfplan map range missing 's' prefix: {range_str}"),
459 })?;
460 let (stage, span) = rest.split_once('m').ok_or_else(|| SqlError::DataFusion {
461 message: format!("dfplan map range missing 'm' separator: {range_str}"),
462 })?;
463 let (start, end) = span.split_once('-').ok_or_else(|| SqlError::DataFusion {
464 message: format!("dfplan map range missing '-' separator: {range_str}"),
465 })?;
466 let parse = |s: &str, what: &str| {
467 s.trim().parse::<usize>().map_err(|e| SqlError::DataFusion {
468 message: format!("dfplan map range {what}: {e}"),
469 })
470 };
471 let range = DfplanMapRange {
472 upstream_stage_index: parse(stage, "stage")?,
473 start: parse(start, "start")?,
474 end: parse(end, "end")?,
475 };
476 if range.start >= range.end {
477 return Err(SqlError::DataFusion {
478 message: format!("dfplan map range is empty: m{}-{}", range.start, range.end),
479 });
480 }
481 (list, Some(range))
482 }
483 None => (segment, None),
484 };
485 let partitions = list
486 .split(',')
487 .map(|p| {
488 p.trim().parse::<usize>().map_err(|e| SqlError::DataFusion {
489 message: format!("dfplan partition index: {e}"),
490 })
491 })
492 .collect::<SqlResult<Vec<_>>>()?;
493 if partitions.is_empty() {
494 return Err(SqlError::DataFusion {
495 message: String::from("dfplan body has no partitions"),
496 });
497 }
498 Ok(DfplanTaskSpec {
499 partitions,
500 map_range: range,
501 })
502}
503
504/// Parse the partition spec of a body without decoding the plan payload
505/// (cheap coordinator-side inspection).
506pub fn dfplan_body_partition_spec(body: &str) -> SqlResult<DfplanTaskSpec> {
507 let (segment, _) = split_dfplan_body(body)?;
508 parse_partition_segment(segment)
509}
510
511/// Split a `dfplan:v1:` body into (partition spec, plan proto bytes).
512pub fn parse_dfplan_body(body: &str) -> SqlResult<(DfplanTaskSpec, Vec<u8>)> {
513 let (segment, b64) = split_dfplan_body(body)?;
514 let spec = parse_partition_segment(segment)?;
515 let bytes = base64::engine::general_purpose::STANDARD
516 .decode(b64.as_bytes())
517 .map_err(|e| SqlError::DataFusion {
518 message: format!("dfplan base64 decode: {e}"),
519 })?;
520 Ok((spec, bytes))
521}
522
523/// Prove that encoded fragment bytes can be decoded again.
524///
525/// This existed once before (b278d67b) and was reverted: the first version
526/// verified against a bare `SessionContext::new()`, whose runtime has no
527/// object-store registry, so every fragment scanning `s3://` failed the check
528/// and *silently fell back to single-task* — q1 ran as 1 task instead of 13
529/// and took 595 s instead of 156 s. The commit message claimed "anything
530/// needing session state to decode would fail on the executor too", which was
531/// exactly backwards: the executor's runtime has `LazyCloudObjectStoreRegistry`
532/// installed, and the throwaway context did not.
533///
534/// So the verification context must mirror the executor's decode environment.
535///
536/// A5 (review 2026-07-27): mirroring the executor's *object-store registry* was
537/// only half of that. The executor decodes on `krishiv-executor`'s
538/// `task_sql_engine`, a real [`crate::SqlEngine`] carrying Krishiv's whole
539/// function registry, dialect and config; this rehearsed against
540/// `planning_session_context`, a bare `SessionContext` carrying none of it. A
541/// fragment referencing any engine-registered UDF therefore decoded fine on the
542/// executor and failed here — and a failed verify means "decline to stage", so
543/// the query silently ran as a single task. `ctx` must come from
544/// [`fragment_decode_session_context`]; it is passed in so the stage builder
545/// pays for building it once per query rather than once per stage.
546///
547/// What it is for: TPC-H q22 encodes cleanly and dies on the executor with
548/// "ScalarSubqueryExpr can only be deserialized as part of a surrounding
549/// ScalarSubqueryExec" — a real encode/decode asymmetry in datafusion-proto.
550/// Catching it here converts a remote failure minutes in to an instant local
551/// fallback.
552fn verify_dfplan_roundtrip(
553 bytes: &[u8],
554 codec: &dyn PhysicalExtensionCodec,
555 ctx: &Arc<TaskContext>,
556 expected_plan: Option<&Arc<dyn ExecutionPlan>>,
557) -> SqlResult<()> {
558 let decoded =
559 datafusion_proto::bytes::physical_plan_from_bytes_with_extension_codec(bytes, ctx, codec)
560 .map_err(|e| SqlError::DataFusion {
561 message: format!("physical plan proto decode: {e}"),
562 })?;
563 // Decoding is not the same as reconstructing. A fragment can decode into a
564 // plan whose *output type* differs from the one the coordinator encoded —
565 // `datafusion-proto` re-resolves aggregate UDFs by name, and a decimal
566 // `avg` re-resolved on the executor can coerce to a different return type.
567 // Nothing downstream notices: `ShuffleReadExec` labels its stream with the
568 // schema the coordinator baked in, `RecordBatchStreamAdapter` does not
569 // validate, and the disagreement only surfaces much later, deep in an
570 // executor, as a bare Arrow error. TPC-H q17:
571 //
572 // column types must match schema types, expected Decimal128(15, 2)
573 // but found Decimal128(30, 15) at column index 0
574 //
575 // The guard already exists to answer "can the executor rebuild this?", and
576 // producing the same columns is the minimum meaning of that. Checking it
577 // here turns a remote runtime failure into a local, named refusal that
578 // degrades to correct-but-serial execution.
579 if let Some(expected) = expected_plan
580 && let Some(difference) = first_schema_difference(expected, &decoded, "root")
581 {
582 return Err(SqlError::DataFusion {
583 message: format!(
584 "decoded plan differs from the encoded plan; the fragment would produce \
585 columns the reader does not expect. {difference}"
586 ),
587 });
588 }
589 Ok(())
590}
591
592/// The first node where a decoded plan stops matching the plan it came from.
593///
594/// Comparing only the **root** schema is not enough, and q17 is why. Its
595/// divergence is an `avg` over a decimal re-resolved by name during decode:
596/// the aggregate node's schema changes from `Decimal128(15, 2)` to
597/// `Decimal128(30, 15)`, but a projection above it casts back, so the root
598/// schemas agree and the root-only check passed the fragment as sound. The
599/// executor then ran a plan whose *interior* produced different types and died
600/// with a bare Arrow error, and the guard written to prevent exactly that
601/// reported nothing.
602///
603/// A mismatch in child count is a difference too: a decode that restructures
604/// the tree has not reproduced the plan, whatever the schemas say.
605fn first_schema_difference(
606 original: &Arc<dyn ExecutionPlan>,
607 decoded: &Arc<dyn ExecutionPlan>,
608 path: &str,
609) -> Option<String> {
610 let original_children = original.children();
611 let decoded_children = decoded.children();
612 if original_children.len() != decoded_children.len() {
613 return Some(format!(
614 "at {path}: {} has {} children, decoded {} has {}",
615 original.name(),
616 original_children.len(),
617 decoded.name(),
618 decoded_children.len()
619 ));
620 }
621 for (index, (a, b)) in original_children
622 .iter()
623 .zip(decoded_children.iter())
624 .enumerate()
625 {
626 let child_path = format!("{path}/{}[{index}]", a.name());
627 if let Some(difference) = first_schema_difference(a, b, &child_path) {
628 return Some(difference);
629 }
630 }
631 // Children first, deliberately. `datafusion-proto` carries no output type
632 // for an aggregate: `AggregateExprBuilder::build()` re-derives it from the
633 // resolved UDAF and the *input* types (see `physical_plan/mod.rs`, the
634 // `UserDefinedAggrFunction` arm). Types therefore propagate upward, so the
635 // deepest disagreeing node is the cause and every node above it is that
636 // cause's shadow. Reporting the root first named the symptom.
637 if original.schema() != decoded.schema() {
638 return Some(format!(
639 "at {path} ({} vs {}):\n encoded: {:?}\n decoded: {:?}",
640 original.name(),
641 decoded.name(),
642 original.schema(),
643 decoded.schema()
644 ));
645 }
646 None
647}
648
649/// The session context a `dfplan:v1:` fragment is **decoded** on.
650///
651/// A5: the round-trip guard is the one contract on this path exercised from
652/// both sides, and it was rehearsing the decode against the wrong session. The
653/// executor decodes on `krishiv-executor`'s `task_sql_engine`, i.e. a real
654/// [`crate::SqlEngine`] — its function registry, its SQL dialect, its config,
655/// its object-store registry. `planning_session_context` is a bare
656/// `SessionContext` that shares none of that, so a fragment referencing any
657/// Krishiv-registered UDF (`get_json_object`, `tumble_start`, …) decoded fine
658/// on the executor and failed here — and the caller reads a failed verify as
659/// "decline to stage", which silently runs the whole query as a single task.
660///
661/// Built from the same constructor the executor uses, differing only in the
662/// memory source: nothing executes on this context, so the rehearsal takes an
663/// unbounded pool instead of the task's per-slot share. `target_partitions`
664/// is likewise irrelevant — a fragment is decoded, never re-planned.
665#[must_use]
666pub fn fragment_decode_session_context() -> SessionContext {
667 crate::SqlEngine::new_with_engine_memory(crate::EngineMemory::Unbounded)
668 .session_context()
669 .clone()
670}
671
672/// Decode a `dfplan:v1:` fragment body into (partition spec, plan).
673///
674/// `ctx` supplies the runtime environment (object stores, UDFs) the decoded
675/// plan executes under; it does not need the original tables registered —
676/// scan nodes carry their own file/split descriptions in the proto.
677pub fn decode_dfplan_task(
678 body: &str,
679 ctx: &TaskContext,
680 codec: &dyn PhysicalExtensionCodec,
681) -> SqlResult<(DfplanTaskSpec, Arc<dyn ExecutionPlan>)> {
682 let (spec, bytes) = parse_dfplan_body(body)?;
683 let plan =
684 datafusion_proto::bytes::physical_plan_from_bytes_with_extension_codec(&bytes, ctx, codec)
685 .map_err(|e| SqlError::DataFusion {
686 message: format!("physical plan proto decode: {e}"),
687 })?;
688 let plan = pin_file_scans_to_partitions(plan)?;
689 Ok((spec, plan))
690}
691
692/// Force strict file-group↔partition binding on every file scan.
693///
694/// Distributed tasks execute exactly one root partition of a fresh plan
695/// instance. This DataFusion's file scans default to a work-stealing queue
696/// shared across sibling partitions (`SharedWorkSource`), so the single
697/// partition a task drives would drain *all* files — every task would read
698/// the whole table. Setting `preserve_order` disables the shared queue
699/// (`create_sibling_state` returns None) and each partition reads exactly
700/// its own file group. Applied at decode time because the plan proto does
701/// not carry the flag.
702fn pin_file_scans_to_partitions(plan: Arc<dyn ExecutionPlan>) -> SqlResult<Arc<dyn ExecutionPlan>> {
703 use datafusion::datasource::source::DataSourceExec;
704 if let Some(source_exec) = plan.downcast_ref::<DataSourceExec>() {
705 if let Some(pinned) = source_exec.data_source().with_preserve_order(true) {
706 return Ok(Arc::new(DataSourceExec::new(pinned)));
707 }
708 return Ok(plan);
709 }
710 let children = plan.children();
711 if children.is_empty() {
712 return Ok(plan);
713 }
714 let mut new_children = Vec::with_capacity(children.len());
715 let mut changed = false;
716 for child in children {
717 let pinned = pin_file_scans_to_partitions(Arc::clone(child))?;
718 changed = changed || !Arc::ptr_eq(&pinned, child);
719 new_children.push(pinned);
720 }
721 if !changed {
722 return Ok(plan);
723 }
724 plan.with_new_children(new_children)
725 .map_err(|e| SqlError::DataFusion {
726 message: format!("scan pinning rewrite: {e}"),
727 })
728}
729
730/// True when a task-fragment body carries a proto-encoded physical plan.
731///
732/// Tolerates a leading Python-UDF registration directive (a staged Python-UDF
733/// fragment prepends one ahead of its `dfplan:` body), so the coordinator's
734/// shuffle-input wiring and AQE analysis classify it correctly.
735pub fn is_dfplan_body(body: &str) -> bool {
736 strip_leading_python_udf_directives(body).starts_with(DFPLAN_BODY_PREFIX)
737}
738
739/// Decode a dfplan body and execute its assigned partition (executor seam).
740///
741/// Keeps DataFusion types out of the executor crate: the result streams as
742/// the crate-level [`crate::SqlStream`]. `session` supplies the runtime
743/// environment (memory pool, object stores); the decoded plan needs no
744/// tables registered on it. Map-stage plans read upstream shuffle data
745/// through `reader`; passing `None` leaves any [`ShuffleReadExec`] leaves
746/// unexecutable (coordinator-side decode).
747/// Convert over-budget hash joins in a decoded fragment to the grace hash join.
748///
749/// # Why this runs after decode, and not during planning
750///
751/// [`crate::grace_hash_join::GraceHashJoinExec`] is a Krishiv node, and
752/// `datafusion-proto` cannot serialize it. When the rule that produces it ran on
753/// the *coordinator*, the encoded stage plan became unencodable, and the
754/// scheduler's answer to an unencodable stage is to give up on staging and run
755/// the query as a **single task**. Turning the flag on therefore looked like a
756/// memory fix while quietly un-distributing q10 and q21 — a silent Bar-2
757/// regression, which is the worst shape a bug can take here.
758///
759/// Running it here is not a workaround, it is the right layer. Which algorithm
760/// an operator uses to spill depends on the memory *this executor* has at *this
761/// moment*; it is not part of what the plan means, so it does not belong on the
762/// wire. The coordinator keeps converting known-large joins to sort-merge (which
763/// proto handles), and this pass picks up what is left.
764///
765/// That residue is exactly the failure case: the joins the coordinator declined
766/// because their build sides looked small enough are the ones that together
767/// exhaust the pool and refuse a later join 877 bytes.
768///
769/// Off unless [`crate::grace_hash_join::enabled`]. A failure to rewrite returns
770/// the plan untouched — a spill strategy must never be why a query dies.
771fn apply_local_spill_strategy(plan: Arc<dyn ExecutionPlan>) -> Arc<dyn ExecutionPlan> {
772 use datafusion::physical_optimizer::PhysicalOptimizerRule;
773
774 if !crate::grace_hash_join::enabled() {
775 return plan;
776 }
777 let rule = crate::spillable_join::SpillableJoinSelection::for_local_execution();
778 match rule.optimize(
779 Arc::clone(&plan),
780 &datafusion::common::config::ConfigOptions::default(),
781 ) {
782 Ok(rewritten) => rewritten,
783 Err(error) => {
784 tracing::warn!(%error, "local spill strategy declined; running the decoded plan as-is");
785 plan
786 }
787 }
788}
789
790pub fn execute_dfplan_body(
791 body: &str,
792 session: &SessionContext,
793 reader: Option<Arc<dyn ShufflePartitionReader>>,
794) -> SqlResult<(SchemaRef, crate::SqlStream)> {
795 // Peek the spec first: a skew-split map range wraps the reader BEFORE
796 // codec construction so every ShuffleReadExec decoded from this body
797 // sees the restricted view.
798 let spec_peek = dfplan_body_partition_spec(body)?;
799 let reader = match (&spec_peek.map_range, reader) {
800 (Some(range), Some(inner)) => Some(Arc::new(MapRangeShuffleReader {
801 inner,
802 range: range.clone(),
803 }) as Arc<dyn ShufflePartitionReader>),
804 (_, reader) => reader,
805 };
806 let codec = match reader {
807 Some(reader) => KrishivPhysicalCodec::executor(reader),
808 None => KrishivPhysicalCodec::coordinator(),
809 };
810 let task_ctx = session.task_ctx();
811 let (spec, plan) = decode_dfplan_task(body, &task_ctx, &codec)?;
812 // Choose the spill strategy here, on the executor, not upstream: see
813 // `apply_local_spill_strategy`.
814 let plan = apply_local_spill_strategy(plan);
815 let partition_count = plan.output_partitioning().partition_count();
816 if let Some(&bad) = spec.partitions.iter().find(|&&p| p >= partition_count) {
817 return Err(SqlError::DataFusion {
818 message: format!(
819 "dfplan partition {bad} out of range: decoded plan has \
820 {partition_count} partitions"
821 ),
822 });
823 }
824 let schema = plan.schema();
825 // Execute each listed root partition and chain the streams. Root
826 // partitions are independent hash groups, so concatenation is exactly
827 // the union the original one-task-per-partition layout produces.
828 let mut streams = Vec::with_capacity(spec.partitions.len());
829 for &partition in &spec.partitions {
830 let stream = plan
831 .execute(partition, Arc::clone(&task_ctx))
832 .map_err(|e| SqlError::DataFusion {
833 message: format!("dfplan execute (partition {partition}): {e}"),
834 })?;
835 streams.push(stream.map_err(|e| SqlError::DataFusion {
836 message: e.to_string(),
837 }));
838 }
839 let chained = futures::stream::iter(streams).flatten();
840 Ok((schema, Box::pin(chained)))
841}
842
843/// Reader wrapper implementing the skew-split map-task restriction: reads
844/// of the restricted upstream stage outside `[start, end)` return empty
845/// (those map tasks belong to sibling split tasks); every other read passes
846/// through untouched.
847#[derive(Debug)]
848struct MapRangeShuffleReader {
849 inner: Arc<dyn ShufflePartitionReader>,
850 range: DfplanMapRange,
851}
852
853impl ShufflePartitionReader for MapRangeShuffleReader {
854 fn open_partition(
855 &self,
856 upstream_stage_index: usize,
857 map_task_index: usize,
858 partition: usize,
859 ) -> futures::future::BoxFuture<'static, Result<ShuffleFragmentStream, String>> {
860 if upstream_stage_index == self.range.upstream_stage_index
861 && !(self.range.start..self.range.end).contains(&map_task_index)
862 {
863 return Box::pin(async {
864 Ok(Box::pin(futures::stream::empty()) as ShuffleFragmentStream)
865 });
866 }
867 self.inner
868 .open_partition(upstream_stage_index, map_task_index, partition)
869 }
870}
871
872/// True when a dfplan body's decoded plan may be split by map-task ranges
873/// (Phase 54 skew split) without changing results.
874///
875/// Splitting hands each split task a disjoint subset of the skewed
876/// upstream's map outputs, so any operator that must observe the WHOLE
877/// partition before emitting (final-mode aggregation, sort, window, limit,
878/// distinct) would produce partial results per split. Safe plans are
879/// whitelisted structurally: shuffle reads, projections, filters, batch
880/// coalescing, and INNER hash joins (each row of the restricted side lands
881/// in exactly one split and joins against the other side read in full, so
882/// every match pair appears exactly once across splits; outer joins are
883/// excluded — unmatched-row padding would be emitted per split).
884pub fn dfplan_body_is_split_safe(body: &str) -> bool {
885 let ctx = SessionContext::new();
886 let codec = KrishivPhysicalCodec::coordinator();
887 let Ok((_, plan)) = decode_dfplan_task(body, &ctx.task_ctx(), &codec) else {
888 return false;
889 };
890 plan_is_split_safe(&plan)
891}
892
893fn plan_is_split_safe(plan: &Arc<dyn ExecutionPlan>) -> bool {
894 use datafusion::physical_plan::filter::FilterExec;
895 use datafusion::physical_plan::joins::HashJoinExec;
896 use datafusion::physical_plan::projection::ProjectionExec;
897 let safe = if let Some(join) = plan.downcast_ref::<HashJoinExec>() {
898 *join.join_type() == datafusion::logical_expr::JoinType::Inner
899 } else {
900 plan.is::<ShuffleReadExec>()
901 || plan.is::<ProjectionExec>()
902 || plan.is::<FilterExec>()
903 // Name match: the concrete type is deprecated in DataFusion 54
904 // (BatchCoalescer replaces it) but still appears in plans.
905 || plan.name() == "CoalesceBatchesExec"
906 };
907 safe && plan.children().iter().all(|c| plan_is_split_safe(c))
908}
909
910/// Register the backing object store for an `s3://`/`s3a://` `path` on `ctx`.
911///
912/// DataFusion keys object stores by scheme+authority, so this registers once
913/// per bucket; re-registering the same bucket replaces the prior store. A
914/// no-op for local filesystem paths.
915///
916/// This mirrors `SqlEngine::register_s3_object_store_for_warehouse`, which
917/// does the same thing for the engine's long-lived context. The stage builder
918/// plans on a throwaway context instead, so it needs its own registration —
919/// that asymmetry is exactly what made object-store tables un-stageable.
920fn register_object_store_for_path(ctx: &SessionContext, path: &str) -> SqlResult<()> {
921 if !(path.starts_with("s3://") || path.starts_with("s3a://")) {
922 return Ok(());
923 }
924 let url = url::Url::parse(path).map_err(|e| SqlError::DataFusion {
925 message: format!("staged planning: invalid object-store url {path}: {e}"),
926 })?;
927 let bucket = url.host_str().unwrap_or_default();
928 let store_url =
929 url::Url::parse(&format!("s3://{bucket}")).map_err(|e| SqlError::DataFusion {
930 message: format!("staged planning: invalid bucket url for {path}: {e}"),
931 })?;
932 let store = crate::build_s3_object_store(bucket).map_err(|e| SqlError::DataFusion {
933 message: format!("staged planning: object store init for {path}: {e}"),
934 })?;
935 ctx.register_object_store(&store_url, store);
936 Ok(())
937}
938
939/// A parquet table to plan against, and what is known to be true about it.
940///
941/// `primary_key` is an **informational constraint**, in the sense Spark and
942/// Databricks use the word (`RELY`): the engine does not verify it, it trusts
943/// it. Declaring a key that does not hold changes results, exactly as a wrong
944/// `RELY` constraint does there. It is empty by default, so a caller that says
945/// nothing gets the previous behaviour.
946///
947/// # Why the engine wants to be told
948///
949/// DataFusion already derives [`FunctionalDependencies`] from a provider's
950/// constraints, and `optimize_projections` already uses
951/// `get_required_group_by_exprs_indices` to shrink a `GROUP BY` to the minimal
952/// functionally-equivalent subset. All of that machinery is live and does
953/// nothing here, because a parquet file carries no key and every table we
954/// register declares none — so the dependency set is always empty.
955///
956/// # What this does and does NOT buy — read before assuming
957///
958/// DataFusion's rule keeps `(columns the parent requires) ∪ (minimal FD
959/// subset)`. So a declared key removes a column from a `GROUP BY` **only when
960/// nothing downstream selects it**. That is a real and common shape —
961/// `GROUP BY a, b` projecting only `a` — and it is what
962/// `a_declared_primary_key_shrinks_the_group_by` proves.
963///
964/// It is **not** TPC-H q10's shape. q10 selects all seven grouped columns, so
965/// the parent requires them and no key declaration can prune them. Measured
966/// 2026-07-31: rewriting q10 to carry only the key is worth **14.8x**
967/// (1784.6 s → 120.9 s; the `orders⋈customer` stage alone 8,968 → 38.9
968/// task-seconds), but capturing that needs **late materialisation** —
969/// aggregate on the key, take the top N, then re-join for the display columns.
970/// DataFusion has no such rule and neither do we. Declaring a key is a
971/// precondition for writing one, not a substitute.
972///
973/// The 230x on that stage is superlinear in the columns rather than the bytes:
974/// per-row string handling (hashing and copying ~227 B rows), which is why
975/// neither bandwidth nor fetch concurrency moved it
976/// (`q10-dist-s2-is-the-whole-query`).
977///
978/// [`FunctionalDependencies`]: datafusion::common::FunctionalDependencies
979#[derive(Debug, Clone)]
980pub struct ParquetTableSpec {
981 /// Name the query refers to the table by.
982 pub name: String,
983 /// Single parquet file or a directory dataset; local or object storage.
984 pub path: String,
985 /// Columns that jointly form a primary key, if the caller declares one.
986 pub primary_key: Vec<String>,
987}
988
989impl ParquetTableSpec {
990 /// A table with nothing declared about it.
991 pub fn new(name: impl Into<String>, path: impl Into<String>) -> Self {
992 Self {
993 name: name.into(),
994 path: path.into(),
995 primary_key: Vec::new(),
996 }
997 }
998
999 /// Declare a (possibly composite) primary key.
1000 #[must_use]
1001 pub fn with_primary_key<I, S>(mut self, columns: I) -> Self
1002 where
1003 I: IntoIterator<Item = S>,
1004 S: Into<String>,
1005 {
1006 self.primary_key = columns.into_iter().map(Into::into).collect();
1007 self
1008 }
1009}
1010
1011/// Mark an extension-less object-store path as a directory so
1012/// [`ListingTableUrl`] treats it as a prefix rather than a single file.
1013///
1014/// # The failure this fixes
1015///
1016/// `ListingTableUrl::parse` decides file-vs-directory by **statting the
1017/// filesystem**. For a local path that works: `/data/sf100/lineitem` is seen to
1018/// be a directory and gets a trailing slash added. For an object store there is
1019/// nothing to stat, so `s3://bucket/sf100/lineitem` is taken to be a file, and
1020/// the `.parquet` extension filter then rejects it:
1021///
1022/// ```text
1023/// File path 's3://krishiv-bench/tpch/sf100/lineitem' does not match the
1024/// expected extension '.parquet'
1025/// ```
1026///
1027/// The message blames the extension, but the data is a directory of parts and
1028/// nothing is wrong with it — so the reader goes looking for a naming problem
1029/// that does not exist. The same registration works locally and fails remotely,
1030/// which is the kind of asymmetry that reads as "object stores are broken".
1031///
1032/// The rule is the one a caller means: a final segment containing no `.` is a
1033/// directory. `s3://b/data.parquet` keeps its file semantics; a path that
1034/// already ends in `/` is left alone; local paths are untouched, because
1035/// statting them is strictly better information than this heuristic.
1036fn directory_aware_url(path: &str) -> String {
1037 if !path.contains("://") || path.ends_with('/') {
1038 return path.to_owned();
1039 }
1040 let looks_like_a_file = path
1041 .rsplit('/')
1042 .next()
1043 .is_some_and(|segment| segment.contains('.'));
1044 if looks_like_a_file {
1045 path.to_owned()
1046 } else {
1047 format!("{path}/")
1048 }
1049}
1050
1051/// Register one parquet table, attaching its declared key as a DataFusion
1052/// constraint so the optimizer's functional-dependency machinery can see it.
1053///
1054/// With no declared key this is exactly `register_parquet`. With one, the
1055/// table is built explicitly so [`ListingTable::with_constraints`] can be
1056/// applied — `register_parquet` has no way to pass them.
1057///
1058/// A declared column that is not in the file's schema is an error rather than
1059/// a silent no-op: a typo would otherwise turn into "the optimization
1060/// mysteriously does not apply", which is the least debuggable outcome.
1061///
1062/// # Why the options come from `ParquetReadOptions` instead of `ListingOptions::new`
1063///
1064/// The two branches below must describe the *same table*; the only intended
1065/// difference is the constraint. Building the keyed branch's options by hand
1066/// silently made them differ, because `ListingOptions::new` does not mean
1067/// "defaults" — it means:
1068///
1069/// ```text
1070/// collect_stat: false // vs. session `collect_statistics`, default true
1071/// target_partitions: 1 // vs. session `target_partitions`
1072/// ```
1073///
1074/// `register_parquet` reaches `ListingOptions` through
1075/// [`ReadOptions::to_listing_options`], which ends in
1076/// `.with_session_config_options(config)` and sets both from the session. The
1077/// hand-built branch never did, so **declaring a primary key turned that
1078/// table's statistics off** and collapsed its scan to one partition.
1079///
1080/// Statistics off is not a slow path, it is a blind one. Every rule that keys
1081/// on a known size stops seeing anything: measured on the SF100 cluster,
1082/// `SpillableJoinSelection` reported `unmeasurable == hash_joins` in **all 414
1083/// passes** across coordinator and executors and converted **zero** joins, so
1084/// no oversized hash join could ever be made spillable and q21 died on a build
1085/// side nothing was left to catch. It also blinds broadcast selection,
1086/// `ShuffleReadExec`'s cut-subtree estimate, and join ordering.
1087///
1088/// So the options are produced by DataFusion's own conversion, from the same
1089/// `ParquetReadOptions::default()` the unkeyed branch passes. The two paths
1090/// cannot drift again without DataFusion changing under both at once.
1091pub async fn register_parquet_table(
1092 ctx: &SessionContext,
1093 spec: &ParquetTableSpec,
1094) -> SqlResult<()> {
1095 use datafusion::common::{Constraint, Constraints};
1096 use datafusion::datasource::TableProvider as _;
1097 use datafusion::datasource::file_format::options::ReadOptions as _;
1098 use datafusion::datasource::listing::{ListingTable, ListingTableConfig, ListingTableUrl};
1099
1100 let read_options = datafusion::prelude::ParquetReadOptions::default();
1101
1102 // `directory_aware_url` on BOTH branches. Applying it only to the keyed one
1103 // left the common case broken: with the minimal-key corpus (keys on
1104 // customer and nation), `lineitem` takes this branch, so the very first
1105 // query still failed with "does not match the expected extension" while the
1106 // fix looked applied. The two branches must agree about what a path MEANS,
1107 // exactly as they must agree about `ListingOptions` — this is the same
1108 // divergence that turned statistics off for keyed tables.
1109 if spec.primary_key.is_empty() {
1110 return ctx
1111 .register_parquet(&spec.name, &directory_aware_url(&spec.path), read_options)
1112 .await
1113 .map_err(|e| SqlError::DataFusion {
1114 message: format!("staged planning: register '{}': {e}", spec.name),
1115 });
1116 }
1117
1118 let url = ListingTableUrl::parse(directory_aware_url(&spec.path)).map_err(|e| {
1119 SqlError::DataFusion {
1120 message: format!("staged planning: table url for '{}': {e}", spec.name),
1121 }
1122 })?;
1123 let options = read_options.to_listing_options(&ctx.copied_config(), ctx.copied_table_options());
1124 let config = ListingTableConfig::new(url)
1125 .with_listing_options(options)
1126 .infer_schema(&ctx.state())
1127 .await
1128 .map_err(|e| SqlError::DataFusion {
1129 message: format!("staged planning: infer schema for '{}': {e}", spec.name),
1130 })?;
1131 let table = ListingTable::try_new(config).map_err(|e| SqlError::DataFusion {
1132 message: format!("staged planning: listing table '{}': {e}", spec.name),
1133 })?;
1134
1135 let schema = table.schema();
1136 let mut indices = Vec::with_capacity(spec.primary_key.len());
1137 for column in &spec.primary_key {
1138 let index = schema.index_of(column).map_err(|_| SqlError::DataFusion {
1139 message: format!(
1140 "declared primary key column '{column}' is not in table '{}' \
1141 (columns: {})",
1142 spec.name,
1143 schema
1144 .fields()
1145 .iter()
1146 .map(|field| field.name().clone())
1147 .collect::<Vec<String>>()
1148 .join(", ")
1149 ),
1150 })?;
1151 indices.push(index);
1152 }
1153
1154 let table = table.with_constraints(Constraints::new_unverified(vec![Constraint::PrimaryKey(
1155 indices,
1156 )]));
1157 ctx.register_table(spec.name.as_str(), Arc::new(table))
1158 .map_err(|e| SqlError::DataFusion {
1159 message: format!("staged planning: register '{}': {e}", spec.name),
1160 })?;
1161 tracing::debug!(
1162 table = %spec.name,
1163 primary_key = ?spec.primary_key,
1164 "registered parquet table with a declared primary key"
1165 );
1166 Ok(())
1167}
1168
1169/// Plan a query over parquet tables and cut it into stages
1170/// (coordinator seam — keeps DataFusion types out of the scheduler crate).
1171///
1172/// `tables` are `(table_name, path)` pairs; a path may be a single parquet
1173/// file or a directory dataset, on the local filesystem or in object storage.
1174/// To declare a primary key, use [`build_stages_for_parquet_tables`].
1175/// Planning happens on a fresh [`planning_session_context`], so krishiv SQL
1176/// extensions (streaming windows, catalog DML, UDFs) fail to plan here and
1177/// surface as `Err` — callers treat any error as "fall back to the
1178/// single-task path".
1179/// `cluster` sizes the plan to the capacity it will run on; `None` falls back
1180/// to the local machine (see [`resolve_stage_target_partitions`]).
1181pub async fn build_stages_for_parquet_query(
1182 query: &str,
1183 tables: &[(String, String)],
1184 cluster: Option<ClusterCapacity>,
1185) -> SqlResult<Option<DistributedStagePlan>> {
1186 let specs: Vec<ParquetTableSpec> = tables
1187 .iter()
1188 .map(|(name, path)| ParquetTableSpec::new(name, path))
1189 .collect();
1190 build_stages_for_parquet_tables(query, &specs, cluster).await
1191}
1192
1193/// As [`build_stages_for_parquet_query`], but each table may declare what is
1194/// known about it — currently a primary key, which the optimizer turns into a
1195/// functional dependency and uses to shrink `GROUP BY` lists.
1196///
1197/// See [`ParquetTableSpec`] for the semantics and for what it is worth.
1198pub async fn build_stages_for_parquet_tables(
1199 query: &str,
1200 tables: &[ParquetTableSpec],
1201 cluster: Option<ClusterCapacity>,
1202) -> SqlResult<Option<DistributedStagePlan>> {
1203 let target_partitions = resolve_stage_target_partitions(cluster);
1204 tracing::debug!(
1205 target_partitions,
1206 total_slots = cluster.map(|c| c.total_slots),
1207 "planning distributed stages"
1208 );
1209 let ctx = planning_session_context(target_partitions);
1210 for spec in tables {
1211 // An `s3://` table needs its object store on the planning context
1212 // before the schema can be inferred. Without this the registration
1213 // errors, the caller swallows the error as "decline to stage", and the
1214 // job silently runs as a SINGLE task — an entire object-store-backed
1215 // dataset scanned by one executor while the rest of the cluster idles.
1216 // That degradation is invisible: the query still returns correct rows,
1217 // just without any distribution. Local paths are unaffected (the
1218 // helper is a no-op for them).
1219 register_object_store_for_path(&ctx, &spec.path)?;
1220 register_parquet_table(&ctx, spec).await?;
1221 }
1222 // A Python scalar UDF shipped inline (`/* krishiv-register-python-udf */`)
1223 // must be known by name/signature for planning to resolve it, then stripped
1224 // so the parser sees clean SQL. The stage bodies carry the same directive so
1225 // the executor reconstructs the worker-backed UDF before decoding the plan;
1226 // here the coordinator only needs the signature (the closure is never
1227 // invoked during planning — Volatile keeps it out of const-folding).
1228 let udf_directive_source = query;
1229 let query = register_python_udf_signatures_and_strip(&ctx, query)?;
1230 let df = ctx.sql(&query).await.map_err(|e| SqlError::DataFusion {
1231 message: format!("staged planning: {e}"),
1232 })?;
1233 // q22: fold uncorrelated scalar subqueries to constants before physical
1234 // planning, or the whole query silently runs as a single task. See
1235 // `inline_uncorrelated_scalar_subqueries`.
1236 let df = inline_uncorrelated_scalar_subqueries(&ctx, df).await?;
1237 let plan = df
1238 .create_physical_plan()
1239 .await
1240 .map_err(|e| SqlError::DataFusion {
1241 message: format!("staged physical planning: {e}"),
1242 })?;
1243 build_distributed_stages_with_udf_directives(plan, udf_directive_source)
1244}
1245
1246/// Evaluate **uncorrelated** scalar subqueries on the coordinator and replace
1247/// each with the constant it produces.
1248///
1249/// **q22 (review 2026-07-27).** `datafusion-proto` cannot round-trip a
1250/// `ScalarSubqueryExpr` — it decodes only "as part of a surrounding
1251/// ScalarSubqueryExec" — so `verify_dfplan_roundtrip` refuses the fragment and
1252/// the caller reads that refusal as "decline to stage". The query then runs as
1253/// ONE task on ONE executor. It returns the right answer, so the sweep recorded
1254/// a clean pass while an entire query class had silently stopped being
1255/// distributed. That is worse than a failure, because nothing points at it.
1256///
1257/// An uncorrelated scalar subquery *is a constant*: it references no column of
1258/// the outer query, so evaluating it once up front and substituting the literal
1259/// is semantically exact, not an approximation. It also removes the
1260/// un-encodable node outright rather than routing around it, which is why this
1261/// is preferred over teaching the codec a new trick.
1262///
1263/// Deliberately conservative: anything unexpected — a correlated subquery, more
1264/// than one row, an execution error, a shape we do not recognise — is left
1265/// untouched, so the worst case is exactly today's behaviour rather than a new
1266/// failure mode. The subquery runs on the coordinator, which is the right place
1267/// for it: it is by definition small (one row), and folding it is what lets the
1268/// *expensive* outer query distribute.
1269/// Input bytes above which a scalar subquery is not worth folding on the
1270/// coordinator.
1271///
1272/// The coordinator is a control plane, not a worker: it typically has a
1273/// fraction of an executor's cores and pool. Anything it evaluates inline is
1274/// single-node, un-spillable in practice, and blocks the submit handler. 256
1275/// MiB is generous for a genuine constant lookup and far below a fact-table
1276/// scan.
1277const MAX_FOLDABLE_SUBQUERY_INPUT_BYTES: usize = 256 * 1024 * 1024;
1278
1279/// Whether `df`'s subquery is small enough to evaluate on the coordinator.
1280///
1281/// Unknown size counts as *not* cheap. That is the conservative direction
1282/// here: the penalty for declining to fold is the pre-existing single-task
1283/// fallback, while the penalty for folding something huge is a coordinator
1284/// that stops answering submits.
1285async fn subquery_is_cheap_to_fold(df: &datafusion::dataframe::DataFrame) -> bool {
1286 use datafusion::common::stats::Precision;
1287
1288 let Ok(plan) = df.clone().create_physical_plan().await else {
1289 return false;
1290 };
1291 let Ok(stats) = plan.partition_statistics(None) else {
1292 return false;
1293 };
1294 match stats.total_byte_size {
1295 Precision::Exact(bytes) | Precision::Inexact(bytes) => {
1296 bytes <= MAX_FOLDABLE_SUBQUERY_INPUT_BYTES
1297 }
1298 Precision::Absent => false,
1299 }
1300}
1301
1302async fn inline_uncorrelated_scalar_subqueries(
1303 ctx: &SessionContext,
1304 df: datafusion::dataframe::DataFrame,
1305) -> SqlResult<datafusion::dataframe::DataFrame> {
1306 use datafusion::common::tree_node::{Transformed, TreeNode, TreeNodeRecursion};
1307 use datafusion::logical_expr::{Expr, LogicalPlan};
1308
1309 let plan = df.logical_plan().clone();
1310
1311 // Pass 1 — collect the distinct uncorrelated scalar subqueries. Keyed by
1312 // the subquery plan's rendering so the same subquery written twice is
1313 // executed once.
1314 let mut pending: Vec<(String, LogicalPlan)> = Vec::new();
1315 let collect = plan.apply(|node| {
1316 for expr in node.expressions() {
1317 expr.apply(|e| {
1318 if let Expr::ScalarSubquery(sub) = e
1319 && sub.outer_ref_columns.is_empty()
1320 {
1321 let key = sub.subquery.display_indent().to_string();
1322 if !pending.iter().any(|(k, _)| *k == key) {
1323 pending.push((key, sub.subquery.as_ref().clone()));
1324 }
1325 }
1326 Ok(TreeNodeRecursion::Continue)
1327 })?;
1328 }
1329 Ok(TreeNodeRecursion::Continue)
1330 });
1331 if collect.is_err() || pending.is_empty() {
1332 return Ok(df);
1333 }
1334
1335 // Pass 2 — execute each. A failure here is not fatal: drop that entry and
1336 // the expression stays as it was.
1337 let mut folded: Vec<(String, datafusion::scalar::ScalarValue)> = Vec::new();
1338 for (key, sub_plan) in pending {
1339 let sub_df = datafusion::dataframe::DataFrame::new(ctx.state(), sub_plan);
1340 // Cheapness gate. A scalar subquery *returns* one row; that says
1341 // nothing about what it costs to *compute*. TPC-H q15's
1342 // `(SELECT max(total_revenue) FROM revenue0)` aggregates 600 M
1343 // lineitem rows to produce its single value — so folding it ran a
1344 // full SF100 aggregate single-node on the coordinator, inside the
1345 // synchronous submit handler, and `/batch-sql/submit` stopped
1346 // answering within the client's 60 s timeout. The client then
1347 // retried, and each retry started another one.
1348 //
1349 // Judge by the subquery's *input*, not its output. Over the
1350 // threshold, leave the expression alone: that is exactly the
1351 // pre-existing behaviour (the query declines to stage and runs as one
1352 // task), which is a known, survivable cost — unlike a coordinator
1353 // that stops accepting work.
1354 if !subquery_is_cheap_to_fold(&sub_df).await {
1355 tracing::info!(
1356 subquery = %key.lines().next().unwrap_or_default(),
1357 "scalar subquery too large to fold on the coordinator; leaving it in the plan"
1358 );
1359 continue;
1360 }
1361 let Ok(batches) = sub_df.collect().await else {
1362 continue;
1363 };
1364 let rows: usize = batches
1365 .iter()
1366 .map(arrow::array::RecordBatch::num_rows)
1367 .sum();
1368 // Zero rows is SQL NULL; more than one row is a runtime error that the
1369 // normal path must keep raising, so leave it alone.
1370 if rows > 1 {
1371 continue;
1372 }
1373 let Some(batch) = batches.iter().find(|b| b.num_rows() == 1) else {
1374 // No rows at all: the subquery is NULL of its declared type.
1375 let Some(first) = batches.first() else {
1376 continue;
1377 };
1378 let Some(field) = first.schema().fields().first().cloned() else {
1379 continue;
1380 };
1381 if let Ok(null) = datafusion::scalar::ScalarValue::try_from(field.data_type()) {
1382 folded.push((key, null));
1383 }
1384 continue;
1385 };
1386 let Some(column) = batch.columns().first() else {
1387 continue;
1388 };
1389 if let Ok(value) = datafusion::scalar::ScalarValue::try_from_array(column, 0) {
1390 folded.push((key, value));
1391 }
1392 }
1393 if folded.is_empty() {
1394 return Ok(df);
1395 }
1396
1397 // Pass 3 — substitute.
1398 let rewritten = plan.transform_up(|node| {
1399 let exprs = node.expressions();
1400 if !exprs.iter().any(Expr::contains_scalar_subquery) {
1401 return Ok(Transformed::no(node));
1402 }
1403 let mut changed = false;
1404 let mut new_exprs = Vec::with_capacity(exprs.len());
1405 for expr in exprs {
1406 let out = expr.transform_up(|e| {
1407 if let Expr::ScalarSubquery(sub) = &e
1408 && sub.outer_ref_columns.is_empty()
1409 {
1410 let key = sub.subquery.display_indent().to_string();
1411 if let Some((_, value)) = folded.iter().find(|(k, _)| *k == key) {
1412 return Ok(Transformed::yes(datafusion::prelude::lit(value.clone())));
1413 }
1414 }
1415 Ok(Transformed::no(e))
1416 })?;
1417 changed |= out.transformed;
1418 new_exprs.push(out.data);
1419 }
1420 if !changed {
1421 return Ok(Transformed::no(node));
1422 }
1423 let inputs = node.inputs().into_iter().cloned().collect::<Vec<_>>();
1424 let rebuilt = node.with_new_exprs(new_exprs, inputs)?;
1425 Ok(Transformed::yes(rebuilt))
1426 });
1427
1428 match rewritten {
1429 Ok(t) if t.transformed => {
1430 tracing::info!(
1431 folded = folded.len(),
1432 "q22: folded uncorrelated scalar subqueries to constants so the \
1433 query can be staged instead of running as a single task"
1434 );
1435 Ok(datafusion::dataframe::DataFrame::new(ctx.state(), t.data))
1436 }
1437 // Rewrite declined or failed: the original plan is still correct, and
1438 // the pre-existing single-task fallback still applies.
1439 _ => Ok(df),
1440 }
1441}
1442
1443/// Register a signature-only DataFusion scalar UDF for every inline
1444/// `/* krishiv-register-python-udf:name:in,…:out:pickle */` directive in
1445/// `query`, so the coordinator can plan a staged query that references it, and
1446/// return `query` with the directives stripped (clean SQL for the parser).
1447///
1448/// The registered UDF's implementation errors if invoked — it exists only to
1449/// carry the name, argument types, and return type through planning and physical
1450/// serialization (the plan references the UDF by name; the executor supplies the
1451/// real worker-backed implementation on decode). Marked `Volatile` so the
1452/// optimizer never tries to const-fold it at plan time. Aggregate directives
1453/// (`python-udaf`) are intentionally left in place: staged aggregation is not
1454/// planned here, so those queries fall back to the single-task path.
1455pub fn register_python_udf_signatures_and_strip(
1456 ctx: &SessionContext,
1457 query: &str,
1458) -> SqlResult<String> {
1459 use datafusion::logical_expr::{ColumnarValue, Volatility, create_udf};
1460 const PREFIX: &str = "/* krishiv-register-python-udf:";
1461 if !query.contains(PREFIX) {
1462 return Ok(query.to_string());
1463 }
1464 let mut out = String::with_capacity(query.len());
1465 let mut rest = query;
1466 while let Some(start) = rest.find(PREFIX) {
1467 out.push_str(&rest[..start]);
1468 let after = &rest[start + PREFIX.len()..];
1469 let Some(end) = after.find(" */") else {
1470 out.push_str(&rest[start..]);
1471 return Ok(out);
1472 };
1473 let body = &after[..end];
1474 rest = &after[end + " */".len()..];
1475 // name:in1,in2:out:pickle_b64 (pickle unused for planning)
1476 let mut parts = body.splitn(4, ':');
1477 let (name, in_types, out_type) = match (parts.next(), parts.next(), parts.next()) {
1478 (Some(n), Some(i), Some(o)) => (n, i, o),
1479 _ => continue,
1480 };
1481 let input_types: Vec<arrow::datatypes::DataType> = if in_types.is_empty() {
1482 Vec::new()
1483 } else {
1484 in_types
1485 .split(',')
1486 .map(crate::python_udf_arrow_type)
1487 .collect()
1488 };
1489 let return_type = crate::python_udf_arrow_type(out_type);
1490 let name_owned = name.to_string();
1491 let udf = create_udf(
1492 name,
1493 input_types,
1494 return_type,
1495 Volatility::Volatile,
1496 Arc::new(move |_: &[ColumnarValue]| {
1497 Err(DataFusionError::NotImplemented(format!(
1498 "python UDF '{name_owned}' executes on the executor, not during \
1499 coordinator planning"
1500 )))
1501 }),
1502 );
1503 ctx.register_udf(udf);
1504 }
1505 out.push_str(rest);
1506 Ok(out)
1507}
1508
1509// ── Shuffle partition reader (executor-injected) ───────────────────────────
1510
1511/// Executor-side access to upstream shuffle partitions.
1512///
1513/// The executor implements this over its shuffle store (local reads) and
1514/// the Flight endpoints delivered with the task assignment (remote reads);
1515/// `krishiv-sql` stays free of shuffle/transport dependencies.
1516pub trait ShufflePartitionReader: fmt::Debug + Send + Sync {
1517 /// Open one map task's output for `partition` of `upstream_stage_index`.
1518 ///
1519 /// The returned future resolves once the fragment has been *located* — a
1520 /// missing partition is an error here, before any rows are produced — and
1521 /// the stream then yields its batches as they are decoded.
1522 ///
1523 /// # Why this streams
1524 ///
1525 /// This used to return `Vec<RecordBatch>`: a reduce task materialised each
1526 /// upstream fragment whole, and neither the Flight decode buffers nor the
1527 /// resulting batches passed through the DataFusion memory pool. With three
1528 /// task slots and several fragments in flight per slot, that is hundreds of
1529 /// megabytes the pool cannot see and therefore cannot make anyone spill for
1530 /// — which is how an executor with a 2.6 GB pool reached 4.5 GiB of heap and
1531 /// was OOM-killed on TPC-H q10. Streaming makes a reduce task's fragment
1532 /// cost one batch instead of one fragment.
1533 ///
1534 /// A missing fragment (the map task produced no rows for this partition)
1535 /// yields an empty stream, not an error.
1536 fn open_partition(
1537 &self,
1538 upstream_stage_index: usize,
1539 map_task_index: usize,
1540 partition: usize,
1541 ) -> futures::future::BoxFuture<'static, Result<ShuffleFragmentStream, String>>;
1542}
1543
1544/// One upstream fragment's batches, in write order.
1545pub type ShuffleFragmentStream =
1546 futures::stream::BoxStream<'static, Result<arrow::record_batch::RecordBatch, String>>;
1547
1548/// Env var overriding how many map fragments ONE reduce partition fetches at
1549/// once. See the `buffered` call in [`ShuffleReadExec::execute`].
1550///
1551/// Deliberately **not** `KRISHIV_SHUFFLE_FETCH_CONCURRENCY`: that name is
1552/// already taken by the executor-wide Flight semaphore
1553/// (`fragment/common.rs`, default 8 in-flight requests per process). The two
1554/// are different limits — this one is per reduce partition, that one is the
1555/// process-wide ceiling every partition's fetches must also pass through — and
1556/// giving them one name would mean a single number silently driving both.
1557pub const SHUFFLE_FETCH_BUFFER_ENV: &str = "KRISHIV_SHUFFLE_FETCH_BUFFER";
1558
1559/// Map fragment opens in flight per reduce partition, by default.
1560///
1561/// **Must be 1 unless the shuffle *server* is changed first.** Raising it hangs
1562/// the cluster, and the hang is total rather than slow.
1563///
1564/// # Why prefetching fragments deadlocks
1565///
1566/// `ShuffleFlightService::do_get` takes a permit from `serve_permits` *before*
1567/// the store reads anything and holds it for the **response stream's lifetime**
1568/// (`PermitHoldingStream`) — because for any partition at or below
1569/// `INLINE_READ_LIMIT` the store has already read the whole file into memory,
1570/// and that buffer lives as long as the response. So an open-but-undrained
1571/// response occupies a server permit.
1572///
1573/// A collecting client could not expose that: it drained each fragment
1574/// immediately, so permits were released promptly. A client that opens `n`
1575/// fragments ahead and consumes them in order holds `n` server permits while
1576/// draining one. Demand is then `executors x slots x n` against a per-server
1577/// limit derived from its page-cache budget (`serve_concurrency_limit`, single
1578/// digits on these 5 GiB nodes), and because every executor is both a client and
1579/// a server the waits form cycles across nodes. Nothing times out; the job sits
1580/// at 0% CPU forever.
1581///
1582/// Measured 2026-07-30 on the 3-node SF100 cluster: with this at 8, TPC-H q2
1583/// wedged permanently at 132/181 tasks with executors at 0-5% CPU and no errors
1584/// logged. With it at 1, the identical image ran q2 in **104.3 s**.
1585///
1586/// # Why it is no longer 1
1587///
1588/// The precondition named above has been met: `ShuffleFlightService` now admits
1589/// a `do_get` by the **bytes** it will hold resident (one `stat`, no read)
1590/// rather than by counting open responses. A response-count cap had to price
1591/// every response at `INLINE_READ_LIMIT`, so at SF100's ~3.5 MB average
1592/// fragment it reserved ~9x the headroom each fetch actually needed — which is
1593/// what made single-digit response caps the binding constraint.
1594///
1595/// What the pin cost, measured on q10 at SF100 (`fast-c91a7b26`):
1596///
1597/// ```text
1598/// dist-s2 reads 3.49 GB -> 11,304 task-seconds (0.31 MB/s, CPU 0.3 of 3)
1599/// dist-s4 reads 7.27 GB -> 2,339 task-seconds
1600/// ```
1601///
1602/// Twice the bytes, a fifth of the time: both stages make the same 36 round
1603/// trips, so the stage with fewer bytes per fetch is the one the fixed per-fetch
1604/// stall dominates. That is the signature of serialised latency, not bandwidth.
1605/// The tail is worse — the final gather stage is a single task making
1606/// 18 partitions x 18 map tasks = 324 strictly serial fetches.
1607///
1608/// # The bound that decides how high this may go
1609///
1610/// With prefetch `P`, a reduce stream may hold `P - 1` admitted-but-unconsumed
1611/// responses while waiting for its head. Cluster-wide that is
1612/// `executors x slots x reads_per_task x (P - 1)` responses pinned against each
1613/// server's budget. The deadlock the old note describes is exactly the case
1614/// where those pinned responses exhaust every server, so no head can be
1615/// admitted and nothing can drain.
1616///
1617/// FIFO admission does *not* by itself rule this out, because a stream's head
1618/// and its siblings can be queued on **different** executors' semaphores, so the
1619/// head has no ordering guarantee relative to them.
1620///
1621/// That paragraph used to continue: "the safety margin is therefore
1622/// quantitative, not structural … 3 x 3 x 2 = 18 cluster-wide, ~72 granules of
1623/// a 256-granule budget". **Both halves of that were wrong, and were measured
1624/// wrong on 2026-07-31.**
1625///
1626/// * The count omitted `reads_per_task`. q10's reduce stage reads 18 map
1627/// fragments, so the real figure is 3 x 3 x **18** x (P-1), an order of
1628/// magnitude larger.
1629/// * The margin is **not** quantitative. Re-run with
1630/// `KRISHIV_SHUFFLE_SERVE_CONCURRENCY=24` — a 768 MiB budget, 3x the
1631/// default — and `P = 2` **still deadlocked**: q10's `dist-s2` stalled at
1632/// 15/18 tasks having moved 0.00 MB of network in 40 s. A cycle can form at
1633/// any budget, because the head has no ordering guarantee relative to the
1634/// siblings holding the capacity it needs.
1635///
1636/// It was also *slower* before it stalled: `dist-s2` median 497 s at P = 2
1637/// versus 433 s at P = 1.
1638///
1639/// So there is no number to tune here. Prefetch above 1 requires the server to
1640/// stop holding a permit for the response stream's lifetime — which means not
1641/// buffering the whole fragment (`INLINE_READ_LIMIT`), not pricing the buffer
1642/// more cleverly.
1643///
1644/// Raising this further is a measurement, not a judgement call: it needs a live
1645/// SF100 run that both completes and shows the win, because the failure mode is
1646/// a silent cluster-wide hang with no error logged.
1647///
1648/// # That measurement was taken, and `2` FAILED — back to 1
1649///
1650/// TPC-H q10 at SF100 on 2026-07-31, stage `dist-s2`: nine tasks reported
1651/// `Running` for **40+ minutes** while the cluster did nothing at all.
1652///
1653/// ```text
1654/// network moved in 45 s (3 executors, eth0 rx)
1655/// prefetch = 2 ~2.5 KB <- heartbeats only
1656/// prefetch = 1 1.77 GB <- 39 MB/s aggregate
1657/// ```
1658///
1659/// Executors sat at 10-20% CPU, `shuffle_bytes_written` did not advance across
1660/// a 60 s window, no task completed, and **nothing was logged**. That is
1661/// precisely the hang described above, and the quantitative safety margin
1662/// argued for `P = 2` did not hold: it assumed a small `reads_per_task`, but
1663/// the bound is `executors x slots x reads_per_task x (P - 1)`, and q10's
1664/// reduce stage reads 18 map fragments per task, so the pinned-response count
1665/// is an order of magnitude past the estimate.
1666///
1667/// The same change is the likeliest cause of q3 regressing from 505 s to over
1668/// 3300 s on the preceding image.
1669///
1670/// So: **1 until the server stops holding a permit for the response's
1671/// lifetime.** Byte-based admission was a necessary step, not a sufficient one
1672/// — the permit is still held across the whole stream, so an
1673/// admitted-but-undrained response still pins server capacity. Making the
1674/// prefetch safe needs the *hold* removed, not the accounting improved.
1675const DEFAULT_SHUFFLE_FETCH_BUFFER: usize = 1;
1676
1677/// Resolve [`DEFAULT_SHUFFLE_FETCH_BUFFER`], honouring the env override.
1678///
1679/// Clamped to at least 1: a zero would make `buffered` yield nothing and hang
1680/// the query, which is a worse failure than any concurrency choice.
1681fn shuffle_fetch_buffer() -> usize {
1682 std::env::var(SHUFFLE_FETCH_BUFFER_ENV)
1683 .ok()
1684 .and_then(|v| v.trim().parse::<usize>().ok())
1685 .unwrap_or(DEFAULT_SHUFFLE_FETCH_BUFFER)
1686 .max(1)
1687}
1688
1689// ── ShuffleReadExec ────────────────────────────────────────────────────────
1690
1691/// Leaf node that streams an upstream ShuffleMap stage's output partitions.
1692///
1693/// `execute(p)` merges partition `p` across all map tasks of the upstream
1694/// stage. On the coordinator (encode side) the node carries no reader and
1695/// cannot execute; the executor's codec injects one at decode time.
1696#[derive(Debug)]
1697pub struct ShuffleReadExec {
1698 upstream_stage_index: usize,
1699 num_map_tasks: usize,
1700 schema: SchemaRef,
1701 properties: Arc<PlanProperties>,
1702 reader: Option<Arc<dyn ShufflePartitionReader>>,
1703 /// D3(2): estimated size of the stage feeding this read.
1704 ///
1705 /// Without this, `partition_statistics` falls through to DataFusion's
1706 /// `Statistics::new_unknown` and **every shuffle-fed join side reports
1707 /// `Precision::Absent`**. `SpillableJoinSelection` keeps hash join on
1708 /// absent statistics by design (guessing "big" is what took q2 from 189 s
1709 /// past a 2400 s timeout), so the rule could never fire on a distributed
1710 /// plan no matter where it was registered — q18 failed with
1711 /// `Resources exhausted: HashJoinInput` on every run. A6 registers the
1712 /// rule; this is what lets it decide anything.
1713 ///
1714 /// It is the *planning-time estimate* of the subtree that was cut, taken
1715 /// at the moment of the cut, not a post-execution measurement: the plan is
1716 /// built and optimized before any stage runs, so measured sizes do not
1717 /// exist yet. Reported as `Inexact` for exactly that reason.
1718 upstream_rows: Option<usize>,
1719 upstream_bytes: Option<usize>,
1720}
1721
1722impl ShuffleReadExec {
1723 pub fn new(
1724 upstream_stage_index: usize,
1725 num_map_tasks: usize,
1726 partition_count: usize,
1727 schema: SchemaRef,
1728 reader: Option<Arc<dyn ShufflePartitionReader>>,
1729 ) -> Self {
1730 let properties = Arc::new(PlanProperties::new(
1731 EquivalenceProperties::new(Arc::clone(&schema)),
1732 Partitioning::UnknownPartitioning(partition_count.max(1)),
1733 EmissionType::Incremental,
1734 Boundedness::Bounded,
1735 ));
1736 Self {
1737 upstream_stage_index,
1738 num_map_tasks,
1739 schema,
1740 properties,
1741 reader,
1742 upstream_rows: None,
1743 upstream_bytes: None,
1744 }
1745 }
1746
1747 /// Attach the cut subtree's estimated size. See [`Self::upstream_rows`].
1748 #[must_use]
1749 pub fn with_upstream_estimate(mut self, rows: Option<usize>, bytes: Option<usize>) -> Self {
1750 self.upstream_rows = rows;
1751 self.upstream_bytes = bytes;
1752 self
1753 }
1754
1755 /// The estimate as `(rows, bytes)`, for encoding onto the wire.
1756 pub fn upstream_estimate(&self) -> (Option<usize>, Option<usize>) {
1757 (self.upstream_rows, self.upstream_bytes)
1758 }
1759
1760 /// Read the usable part of a `Statistics`: a `Precision::Absent` value
1761 /// stays `None` so an unknown estimate is never laundered into a
1762 /// confident one.
1763 fn precision_value(p: &datafusion::common::stats::Precision<usize>) -> Option<usize> {
1764 match p {
1765 datafusion::common::stats::Precision::Exact(v)
1766 | datafusion::common::stats::Precision::Inexact(v) => Some(*v),
1767 datafusion::common::stats::Precision::Absent => None,
1768 }
1769 }
1770
1771 /// Capture a cut subtree's estimate at the point the exchange is replaced.
1772 pub fn estimate_of(plan: &Arc<dyn ExecutionPlan>) -> (Option<usize>, Option<usize>) {
1773 plan.partition_statistics(None).map_or((None, None), |s| {
1774 (
1775 Self::precision_value(&s.num_rows),
1776 Self::precision_value(&s.total_byte_size),
1777 )
1778 })
1779 }
1780
1781 pub fn upstream_stage_index(&self) -> usize {
1782 self.upstream_stage_index
1783 }
1784
1785 /// Copy of this read pointed at a different upstream stage.
1786 ///
1787 /// Used by stage reuse, which collapses identical stages and must then
1788 /// repoint every reader at the surviving index. Everything else — map-task
1789 /// count, partition count, schema, size estimates — is unchanged, because
1790 /// the stage being pointed at computes exactly the same thing; that
1791 /// identity is what made the collapse legal in the first place.
1792 pub(crate) fn clone_with_upstream_stage_index(&self, upstream_stage_index: usize) -> Self {
1793 Self {
1794 upstream_stage_index,
1795 num_map_tasks: self.num_map_tasks,
1796 schema: Arc::clone(&self.schema),
1797 properties: Arc::clone(&self.properties),
1798 reader: self.reader.clone(),
1799 upstream_rows: self.upstream_rows,
1800 upstream_bytes: self.upstream_bytes,
1801 }
1802 }
1803
1804 pub fn num_map_tasks(&self) -> usize {
1805 self.num_map_tasks
1806 }
1807
1808 pub fn partition_count(&self) -> usize {
1809 self.properties.partitioning.partition_count()
1810 }
1811}
1812
1813impl DisplayAs for ShuffleReadExec {
1814 fn fmt_as(&self, _t: DisplayFormatType, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1815 write!(
1816 f,
1817 "ShuffleReadExec: upstream_stage={}, map_tasks={}, partitions={}",
1818 self.upstream_stage_index,
1819 self.num_map_tasks,
1820 self.partition_count()
1821 )
1822 }
1823}
1824
1825impl ExecutionPlan for ShuffleReadExec {
1826 fn name(&self) -> &str {
1827 "ShuffleReadExec"
1828 }
1829
1830 fn properties(&self) -> &Arc<PlanProperties> {
1831 &self.properties
1832 }
1833
1834 fn children(&self) -> Vec<&Arc<dyn ExecutionPlan>> {
1835 Vec::new()
1836 }
1837
1838 fn with_new_children(
1839 self: Arc<Self>,
1840 _children: Vec<Arc<dyn ExecutionPlan>>,
1841 ) -> datafusion::error::Result<Arc<dyn ExecutionPlan>> {
1842 Ok(self)
1843 }
1844
1845 /// D3(2): report the cut subtree's estimate instead of "unknown".
1846 ///
1847 /// `Inexact` is the honest precision — this is a planning-time estimate of
1848 /// a stage that has not run. Absent stays absent: a rule that keys on
1849 /// known-size (as `SpillableJoinSelection` deliberately does) must still
1850 /// be able to tell "no idea" from "small".
1851 fn partition_statistics(
1852 &self,
1853 partition: Option<usize>,
1854 ) -> datafusion::error::Result<Arc<datafusion::common::Statistics>> {
1855 use datafusion::common::stats::Precision;
1856 let partitions = self.properties.partitioning.partition_count().max(1);
1857 // A per-partition question gets the even-split share. Shuffle output is
1858 // hash-partitioned, so even split is the right null hypothesis; skew is
1859 // AQE's business and it works from measured sizes, not from this.
1860 let divisor = if partition.is_some() { partitions } else { 1 };
1861 let scale = |v: Option<usize>| -> Precision<usize> {
1862 v.map_or(Precision::Absent, |v| Precision::Inexact(v / divisor))
1863 };
1864 let mut stats = datafusion::common::Statistics::new_unknown(&self.schema);
1865 stats.num_rows = scale(self.upstream_rows);
1866 stats.total_byte_size = scale(self.upstream_bytes);
1867 Ok(Arc::new(stats))
1868 }
1869
1870 fn execute(
1871 &self,
1872 partition: usize,
1873 _context: Arc<TaskContext>,
1874 ) -> datafusion::error::Result<SendableRecordBatchStream> {
1875 let reader = self.reader.clone().ok_or_else(|| {
1876 DataFusionError::Execution(String::from(
1877 "ShuffleReadExec has no shuffle reader: this plan was decoded without an \
1878 executor-side codec (coordinator-side plans are not executable)",
1879 ))
1880 })?;
1881 let stage = self.upstream_stage_index;
1882 let schema = Arc::clone(&self.schema);
1883 let expected = Arc::clone(&self.schema);
1884 let stream = futures::stream::iter(0..self.num_map_tasks)
1885 .map(move |map_task| {
1886 let reader = Arc::clone(&reader);
1887 async move {
1888 reader
1889 .open_partition(stage, map_task, partition)
1890 .await
1891 .map(|batches| (map_task, batches))
1892 .map_err(|e| {
1893 DataFusionError::Execution(format!(
1894 "shuffle read (stage {stage}, map {map_task}, partition \
1895 {partition}): {e}"
1896 ))
1897 })
1898 }
1899 })
1900 // Open several map fragments at once instead of one at a time.
1901 //
1902 // This was `.then(..)`, which awaits each future before creating
1903 // the next: a reduce task fetched its fragment from map task 0,
1904 // waited for the whole round trip, then map task 1, and so on. With
1905 // 18 map tasks that is 18 strictly serial network round trips per
1906 // reduce partition, and a query with five shuffles pays it five
1907 // times over — all of it latency, none of it overlapped, while the
1908 // CPU that is meant to be joining sits idle.
1909 //
1910 // `buffered` keeps the output in map-task order, so the merge stays
1911 // deterministic, and bounds how many fragments are open. Because
1912 // `open_partition` streams, an open-but-not-yet-consumed fragment
1913 // costs a flow-control window rather than its full size.
1914 .buffered(shuffle_fetch_buffer())
1915 .map_ok(move |(map_task, batches)| {
1916 let expected = Arc::clone(&expected);
1917 batches.map(move |batch| {
1918 let batch = batch.map_err(|e| {
1919 DataFusionError::Execution(format!(
1920 "shuffle read (stage {stage}, map {map_task}, partition \
1921 {partition}): {e}"
1922 ))
1923 })?;
1924 check_shuffle_batch_schema(&expected, batch, stage, map_task, partition)
1925 })
1926 })
1927 .try_flatten();
1928 Ok(Box::pin(RecordBatchStreamAdapter::new(schema, stream)))
1929 }
1930}
1931
1932/// Reject a shuffle batch whose columns disagree with the schema this read
1933/// declares, naming the stage, map task, partition and column.
1934///
1935/// `ShuffleReadExec` labels its stream with the schema the **coordinator**
1936/// baked into the fragment, and `RecordBatchStreamAdapter` does not check that
1937/// the batches it yields match. So when a map stage produces something else,
1938/// nothing objects here — the rows flow on and die later, in whichever
1939/// downstream operator first builds a `RecordBatch` against the plan's schema,
1940/// as a bare Arrow error with no stage, no partition and no producer:
1941///
1942/// column types must match schema types, expected Decimal128(15, 2)
1943/// but found Decimal128(30, 15) at column index 0
1944///
1945/// That is TPC-H q17 at SF100, and it names nothing that identifies where the
1946/// disagreement came from. Checking at the seam turns it into an error that
1947/// does. This is the boundary between two independently-produced schemas, so
1948/// it is the only place with both of them in hand.
1949///
1950/// Deliberately narrow: column **count** and column **types** only. Those are
1951/// exactly what makes Arrow fail, and they cannot differ legitimately. Field
1952/// metadata and nullability can and do differ harmlessly across a Parquet read
1953/// and an IPC round trip, so comparing whole `Schema`s here would reject
1954/// correct queries.
1955fn check_shuffle_batch_schema(
1956 expected: &SchemaRef,
1957 batch: arrow::record_batch::RecordBatch,
1958 stage: usize,
1959 map_task: usize,
1960 partition: usize,
1961) -> Result<arrow::record_batch::RecordBatch, DataFusionError> {
1962 let actual = batch.schema();
1963 // Same allocation is the overwhelmingly common case — the map task and the
1964 // reader share it — so this costs a pointer compare per batch.
1965 if Arc::ptr_eq(expected, &actual) {
1966 return Ok(batch);
1967 }
1968 let where_ = || format!("stage {stage}, map {map_task}, partition {partition}");
1969 if expected.fields().len() != actual.fields().len() {
1970 return Err(DataFusionError::Execution(format!(
1971 "shuffle read ({}) produced {} columns but the plan declares {}; \
1972 the map stage did not produce the schema the reduce side was planned \
1973 against.\n declared: {:?}\n produced: {:?}",
1974 where_(),
1975 actual.fields().len(),
1976 expected.fields().len(),
1977 expected.fields(),
1978 actual.fields(),
1979 )));
1980 }
1981 for (index, (want, got)) in expected
1982 .fields()
1983 .iter()
1984 .zip(actual.fields().iter())
1985 .enumerate()
1986 {
1987 if want.data_type() != got.data_type() {
1988 return Err(DataFusionError::Execution(format!(
1989 "shuffle read ({}) column {index} ({}) is {:?} but the plan declares {:?} \
1990 ({}); the map stage did not produce the schema the reduce side was \
1991 planned against",
1992 where_(),
1993 got.name(),
1994 got.data_type(),
1995 want.data_type(),
1996 want.name(),
1997 )));
1998 }
1999 }
2000 Ok(batch)
2001}
2002
2003// ── Extension codec ────────────────────────────────────────────────────────
2004
2005/// Serialized form of a [`ShuffleReadExec`] inside the plan proto.
2006#[derive(serde::Serialize, serde::Deserialize)]
2007struct ShuffleReadNodePayload {
2008 v: u32,
2009 stage: usize,
2010 map_tasks: usize,
2011 partitions: usize,
2012 schema_ipc_b64: String,
2013 /// D3(2): planning-time estimate of the upstream stage. `#[serde(default)]`
2014 /// keeps `v: 1` fragments from an older coordinator decodable — they simply
2015 /// carry no estimate, which is the pre-fix behaviour and stays correct.
2016 #[serde(default, skip_serializing_if = "Option::is_none")]
2017 upstream_rows: Option<usize>,
2018 #[serde(default, skip_serializing_if = "Option::is_none")]
2019 upstream_bytes: Option<usize>,
2020}
2021
2022/// Serialized form of the runtime-filter nodes inside the plan proto.
2023///
2024/// Tagged on `node`, which [`ShuffleReadNodePayload`] does not carry — so the
2025/// decoder can tell the two apart by whether this parse succeeds, without a
2026/// version bump that would make new coordinators and old executors disagree
2027/// about a field they both already understand.
2028#[derive(serde::Serialize, serde::Deserialize)]
2029#[serde(tag = "node")]
2030enum KrishivNodePayload {
2031 RuntimeFilterBuild {
2032 key_index: usize,
2033 filter_bytes: usize,
2034 },
2035 RuntimeFilterProbe {
2036 key_index: usize,
2037 },
2038}
2039
2040fn schema_to_ipc_bytes(schema: &arrow::datatypes::Schema) -> Result<Vec<u8>, String> {
2041 let mut buf = Vec::new();
2042 let mut writer = arrow::ipc::writer::StreamWriter::try_new(&mut buf, schema)
2043 .map_err(|e| format!("schema ipc writer: {e}"))?;
2044 writer
2045 .finish()
2046 .map_err(|e| format!("schema ipc finish: {e}"))?;
2047 Ok(buf)
2048}
2049
2050fn schema_from_ipc_bytes(bytes: &[u8]) -> Result<SchemaRef, String> {
2051 let reader = arrow::ipc::reader::StreamReader::try_new(std::io::Cursor::new(bytes), None)
2052 .map_err(|e| format!("schema ipc reader: {e}"))?;
2053 Ok(reader.schema())
2054}
2055
2056/// Krishiv physical extension codec: (de)serializes [`ShuffleReadExec`].
2057///
2058/// The coordinator constructs it without a reader (encode only); the
2059/// executor constructs it with its shuffle reader so decoded plans execute.
2060#[derive(Debug, Default)]
2061pub struct KrishivPhysicalCodec {
2062 reader: Option<Arc<dyn ShufflePartitionReader>>,
2063}
2064
2065impl KrishivPhysicalCodec {
2066 pub fn coordinator() -> Self {
2067 Self { reader: None }
2068 }
2069
2070 pub fn executor(reader: Arc<dyn ShufflePartitionReader>) -> Self {
2071 Self {
2072 reader: Some(reader),
2073 }
2074 }
2075}
2076
2077impl PhysicalExtensionCodec for KrishivPhysicalCodec {
2078 fn try_decode(
2079 &self,
2080 buf: &[u8],
2081 inputs: &[Arc<dyn ExecutionPlan>],
2082 _ctx: &TaskContext,
2083 ) -> datafusion::error::Result<Arc<dyn ExecutionPlan>> {
2084 use crate::runtime_filter_exec::{RuntimeFilterBuildExec, RuntimeFilterProbeExec};
2085 // The runtime-filter nodes carry a `node` tag; a shuffle-read payload
2086 // does not, so this parse is an unambiguous discriminator.
2087 if let Ok(payload) = serde_json::from_slice::<KrishivNodePayload>(buf) {
2088 return match payload {
2089 KrishivNodePayload::RuntimeFilterBuild {
2090 key_index,
2091 filter_bytes,
2092 } => {
2093 let [input] = inputs else {
2094 return Err(DataFusionError::Internal(format!(
2095 "RuntimeFilterBuildExec expects one input, got {}",
2096 inputs.len()
2097 )));
2098 };
2099 Ok(Arc::new(RuntimeFilterBuildExec::try_new(
2100 Arc::clone(input),
2101 key_index,
2102 filter_bytes,
2103 )?))
2104 }
2105 KrishivNodePayload::RuntimeFilterProbe { key_index } => {
2106 let [data, filter] = inputs else {
2107 return Err(DataFusionError::Internal(format!(
2108 "RuntimeFilterProbeExec expects two inputs, got {}",
2109 inputs.len()
2110 )));
2111 };
2112 Ok(Arc::new(RuntimeFilterProbeExec::try_new(
2113 Arc::clone(data),
2114 Arc::clone(filter),
2115 key_index,
2116 )?))
2117 }
2118 };
2119 }
2120 let payload: ShuffleReadNodePayload = serde_json::from_slice(buf)
2121 .map_err(|e| DataFusionError::Internal(format!("shuffle-read node decode: {e}")))?;
2122 if payload.v != 1 {
2123 return Err(DataFusionError::Internal(format!(
2124 "unsupported shuffle-read node version {}",
2125 payload.v
2126 )));
2127 }
2128 let schema_bytes = base64::engine::general_purpose::STANDARD
2129 .decode(payload.schema_ipc_b64.as_bytes())
2130 .map_err(|e| DataFusionError::Internal(format!("shuffle-read schema b64: {e}")))?;
2131 let schema = schema_from_ipc_bytes(&schema_bytes).map_err(DataFusionError::Internal)?;
2132 Ok(Arc::new(
2133 ShuffleReadExec::new(
2134 payload.stage,
2135 payload.map_tasks,
2136 payload.partitions,
2137 schema,
2138 self.reader.clone(),
2139 )
2140 // D3(2): carry the estimate across the wire so the plan the
2141 // executor holds describes the same sizes the coordinator
2142 // optimized against. Absent on a fragment encoded by an older
2143 // coordinator, which reads exactly as it did before.
2144 .with_upstream_estimate(payload.upstream_rows, payload.upstream_bytes),
2145 ))
2146 }
2147
2148 fn try_encode(
2149 &self,
2150 node: Arc<dyn ExecutionPlan>,
2151 buf: &mut Vec<u8>,
2152 ) -> datafusion::error::Result<()> {
2153 use crate::runtime_filter_exec::{RuntimeFilterBuildExec, RuntimeFilterProbeExec};
2154 let filter_payload = if let Some(build) = node.downcast_ref::<RuntimeFilterBuildExec>() {
2155 Some(KrishivNodePayload::RuntimeFilterBuild {
2156 key_index: build.key_index(),
2157 filter_bytes: build.filter_bytes(),
2158 })
2159 } else {
2160 node.downcast_ref::<RuntimeFilterProbeExec>().map(|probe| {
2161 KrishivNodePayload::RuntimeFilterProbe {
2162 key_index: probe.key_index(),
2163 }
2164 })
2165 };
2166 if let Some(payload) = filter_payload {
2167 let json = serde_json::to_vec(&payload).map_err(|e| {
2168 DataFusionError::Internal(format!("runtime filter node encode: {e}"))
2169 })?;
2170 buf.extend_from_slice(&json);
2171 return Ok(());
2172 }
2173 let read = node.downcast_ref::<ShuffleReadExec>().ok_or_else(|| {
2174 DataFusionError::NotImplemented(format!(
2175 "KrishivPhysicalCodec cannot encode node {}",
2176 node.name()
2177 ))
2178 })?;
2179 let schema_bytes = schema_to_ipc_bytes(&read.schema).map_err(DataFusionError::Internal)?;
2180 let (upstream_rows, upstream_bytes) = read.upstream_estimate();
2181 let payload = ShuffleReadNodePayload {
2182 v: 1,
2183 stage: read.upstream_stage_index,
2184 map_tasks: read.num_map_tasks,
2185 partitions: read.partition_count(),
2186 schema_ipc_b64: base64::engine::general_purpose::STANDARD.encode(&schema_bytes),
2187 upstream_rows,
2188 upstream_bytes,
2189 };
2190 let json = serde_json::to_vec(&payload)
2191 .map_err(|e| DataFusionError::Internal(format!("shuffle-read node encode: {e}")))?;
2192 buf.extend_from_slice(&json);
2193 Ok(())
2194 }
2195}
2196
2197// ── Stage builder ──────────────────────────────────────────────────────────
2198
2199/// Shuffle-output contract of a ShuffleMap stage.
2200#[derive(Debug, Clone)]
2201pub struct StageShuffleOutput {
2202 /// Hash-partitioning key columns (names in the stage output schema).
2203 pub key_columns: Vec<String>,
2204 /// Number of reduce partitions the map output is split into.
2205 pub num_output_partitions: usize,
2206}
2207
2208/// One stage of a distributed batch plan.
2209#[derive(Debug, Clone)]
2210pub struct DistributedStage {
2211 /// Per-task fragment bodies (`dfplan:v1:<partition>:<b64>`), one per
2212 /// output partition of the stage subtree.
2213 pub task_bodies: Vec<String>,
2214 /// `Some` for ShuffleMap stages; `None` for the terminal Result stage.
2215 pub shuffle: Option<StageShuffleOutput>,
2216 /// Builder indexes of stages this stage reads via [`ShuffleReadExec`].
2217 pub upstream_stage_indexes: Vec<usize>,
2218}
2219
2220impl DistributedStage {
2221 pub fn task_count(&self) -> usize {
2222 self.task_bodies.len()
2223 }
2224}
2225
2226/// A batch query cut into shuffle-connected stages (Result stage last).
2227#[derive(Debug, Clone)]
2228pub struct DistributedStagePlan {
2229 pub stages: Vec<DistributedStage>,
2230}
2231
2232struct StageDraft {
2233 plan: Arc<dyn ExecutionPlan>,
2234 shuffle: Option<StageShuffleOutput>,
2235 /// Set when this stage subtree was cut out from beneath a
2236 /// [`ScalarSubqueryExec`]. See [`StageSubqueryContext`].
2237 subqueries: Option<StageSubqueryContext>,
2238}
2239
2240/// The uncorrelated-scalar-subquery context a stage subtree was cut out from.
2241///
2242/// DataFusion's physical planner wraps the WHOLE plan in a single
2243/// [`ScalarSubqueryExec`] at the root (`physical_planner.rs`,
2244/// `create_initial_plan`): that node runs each subquery once and stores the
2245/// scalar in a shared results container, and every `ScalarSubqueryExpr` left
2246/// in the plan reads its value out of that container by index.
2247///
2248/// Cutting the plan into stages severs that relationship. TPC-H q22's
2249/// `c_acctbal > (SELECT avg(c_acctbal) …)` sits in a filter *below* the hash
2250/// exchange, so the map stage ships the `ScalarSubqueryExpr` while the
2251/// `ScalarSubqueryExec` that populates it stays behind in the result stage.
2252/// The fragment encodes happily and then refuses to decode —
2253///
2254/// > ScalarSubqueryExpr can only be deserialized as part of a surrounding
2255/// > ScalarSubqueryExec
2256///
2257/// — which the caller reads as "decline to stage", running all of q22 as ONE
2258/// task. That is our stage cut breaking an invariant, not an upstream
2259/// serialization gap: `datafusion-proto` round-trips `ScalarSubqueryExec`
2260/// perfectly well, and re-establishes the container↔expr link on decode.
2261///
2262/// So a severed stage is repaired by giving it back the wrapper it lost. The
2263/// links are carried here, uncut, and re-applied to whichever stages actually
2264/// need them (see `build_distributed_stages`).
2265struct StageSubqueryContext {
2266 /// The subquery plans, exactly as planned — never cut into stages.
2267 ///
2268 /// `ScalarSubqueryExec` evaluates each through
2269 /// `execute_stream`, which coalesces the plan to one partition and runs it
2270 /// whole. A subquery containing a `ShuffleReadExec` would therefore have a
2271 /// task read a sibling stage's output out of dependency order, so
2272 /// `cut_exchanges` deliberately does not descend into them.
2273 links: Vec<ScalarSubqueryLink>,
2274 /// The root exec's results container.
2275 ///
2276 /// Shared rather than freshly allocated so the coordinator-side plan stays
2277 /// internally consistent (its exprs hold this same container). Identity is
2278 /// irrelevant to what executors run: decoding a fragment mints a fresh
2279 /// container and wires that stage's exprs to it.
2280 results: ScalarSubqueryResults,
2281}
2282
2283/// Internal marker for shapes the builder cannot prove correct.
2284struct Unsupported(String);
2285
2286/// Cut a physical plan into shuffle-connected stages.
2287///
2288/// Returns `Ok(None)` when the plan has no hash exchange (nothing to gain)
2289/// or uses a shape the builder cannot prove correct (fallback to the
2290/// single-task path). The result stage is always last; map stages appear in
2291/// dependency order before it.
2292pub fn build_distributed_stages(
2293 plan: Arc<dyn ExecutionPlan>,
2294) -> SqlResult<Option<DistributedStagePlan>> {
2295 build_distributed_stages_with_udf_directives(plan, "")
2296}
2297
2298/// See `build_distributed_stages`; `udf_directive_source` supplies the query's
2299/// inline Python-UDF directives so the decode rehearsal can resolve them.
2300pub fn build_distributed_stages_with_udf_directives(
2301 plan: Arc<dyn ExecutionPlan>,
2302 udf_directive_source: &str,
2303) -> SqlResult<Option<DistributedStagePlan>> {
2304 // First: reduce a fact stream by a dimension the planner has already decided
2305 // to broadcast, before that stream is carried through the big joins. Runs
2306 // ahead of the redistribution below so the rows it removes are removed
2307 // before any decision is made about how to distribute what remains.
2308 let plan = reduce_by_broadcast_dimension(plan)?;
2309
2310 // Before cutting: a broadcast join whose unmatched build rows are emitted
2311 // only after the last probe partition cannot be split one-partition-per-task
2312 // without silently dropping those rows. Convert such joins to
2313 // hash-partitioned ones, which are split-safe by construction.
2314 let plan = redistribute_unsplittable_broadcast_joins(plan)?;
2315
2316 // Re-run the spillable-join rule over the rewritten plan.
2317 //
2318 // This is a sequencing fix, not a belt-and-braces repeat. `SpillableJoinSelection`
2319 // is a physical optimizer rule, so it ran BEFORE the rewrite above — when
2320 // q21's joins were still `CollectLeft` over a multi-partition probe, a shape
2321 // it declines by design (`convertible_mode`). The rewrite then turns them
2322 // into `Partitioned` joins whose build side each task must hold as a hash
2323 // table, and nothing had re-examined whether it fits.
2324 //
2325 // Measured: with the rewrite but without this pass, q21 at SF100 stopped
2326 // being slow and started FAILING — `Resources exhausted: HashJoinInput[4]
2327 // with 806.0 MB already allocated` out of a 2.6 GB pool. One degenerate
2328 // statistic was poisoning two decisions; fixing only the first turned a slow
2329 // query into a broken one.
2330 let plan = {
2331 use datafusion::physical_optimizer::PhysicalOptimizerRule as _;
2332 crate::spillable_join::SpillableJoinSelection::from_capacity()
2333 // This plan is about to be cut into stages, so an added exchange is
2334 // an added stage boundary — see `without_broadcast_rescue`.
2335 .without_broadcast_rescue()
2336 .optimize(plan, &datafusion::common::config::ConfigOptions::default())
2337 }
2338 .map_err(|e| SqlError::DataFusion {
2339 message: format!("spillable-join pass over the redistributed plan: {e}"),
2340 })?;
2341
2342 let mut drafts: Vec<StageDraft> = Vec::new();
2343 let mut root = match cut_exchanges(plan, &mut drafts) {
2344 Ok(root) => root,
2345 Err(Unsupported(reason)) => {
2346 return Err(SqlError::DataFusion {
2347 message: format!("stage split unsupported: {reason}"),
2348 });
2349 }
2350 };
2351 if drafts.is_empty() {
2352 return Err(SqlError::DataFusion {
2353 message: String::from("plan has no exchange to cut, so it cannot be split into stages"),
2354 });
2355 }
2356 // Collapse identical leaf stages FIRST, so the runtime-filter rule sees the
2357 // deduplicated stage list and cannot build a filter for a stage that is
2358 // about to be removed (and thereby leave a dangling upstream index).
2359 // A no-op unless `KRISHIV_STAGE_REUSE` is on.
2360 dedupe_identical_stages(&mut root, &mut drafts);
2361
2362 // Cross-stage runtime filters, before the root is pushed so the Result
2363 // stage stays last. Appends filter stages and rewrites probe stages in
2364 // place; a no-op unless `KRISHIV_CROSS_STAGE_RUNTIME_FILTER` is on.
2365 inject_runtime_filters(&root, &mut drafts);
2366
2367 drafts.push(StageDraft {
2368 plan: root,
2369 shuffle: None,
2370 // The root keeps whatever `ScalarSubqueryExec` it was planned with, so
2371 // it is never the severed side.
2372 subqueries: None,
2373 });
2374
2375 // Prove every stage subtree is partition-independent: no exchange may
2376 // remain inside a stage (each task executes one root partition; a
2377 // leftover RepartitionExec would re-drive all inputs per task).
2378 for draft in &drafts {
2379 if let Some(reason) = find_unsupported_stage_node(&draft.plan) {
2380 return Err(SqlError::DataFusion {
2381 message: format!("stage subtree not partition-independent: {reason}"),
2382 });
2383 }
2384 }
2385
2386 let codec = KrishivPhysicalCodec::coordinator();
2387 // One executor-equivalent decode context for the whole query: building a
2388 // `SqlEngine` registers the full UDF set, and every stage rehearses against
2389 // the same one the executor would use (A5).
2390 let decode_session = fragment_decode_session_context();
2391 if !udf_directive_source.is_empty() {
2392 register_python_udf_signatures_and_strip(&decode_session, udf_directive_source)?;
2393 }
2394 let decode_ctx = decode_session.task_ctx();
2395 let mut stages = Vec::with_capacity(drafts.len());
2396 for draft in drafts {
2397 let partition_count = draft.plan.output_partitioning().partition_count();
2398 if partition_count == 0 {
2399 return Err(SqlError::DataFusion {
2400 message: String::from("stage subtree has zero output partitions"),
2401 });
2402 }
2403 let upstream_stage_indexes = collect_upstream_stage_indexes(&draft.plan);
2404 // Encoding successfully is not the same as being shippable — a fragment
2405 // can encode and then fail to decode on the executor. Rehearse the
2406 // decode locally (same codec, same object-store registry as the
2407 // executor's runtime) so an encode/decode asymmetry degrades to
2408 // correct-but-serial execution instead of a remote fragment failure.
2409 //
2410 // A stage cut out from beneath a `ScalarSubqueryExec` gets two attempts:
2411 // bare first, then wrapped. Trying bare first is what keeps the repair
2412 // precise — only the stage that genuinely carries a `ScalarSubqueryExpr`
2413 // pays to re-evaluate the subquery, and the rest of the query's stages
2414 // are shipped exactly as before. There is no generic way to ask a
2415 // physical plan "do you contain this expression" (`ExecutionPlan` has no
2416 // expression accessor), and the decoder's own answer is the
2417 // authoritative one anyway.
2418 let attempts = match &draft.subqueries {
2419 Some(context) => vec![
2420 Arc::clone(&draft.plan),
2421 wrap_in_scalar_subquery_exec(Arc::clone(&draft.plan), context),
2422 ],
2423 None => vec![Arc::clone(&draft.plan)],
2424 };
2425 let mut shippable = None;
2426 let mut last_error = None;
2427 for stage_plan in attempts {
2428 let bytes = match encode_dfplan_bytes(Arc::clone(&stage_plan), &codec) {
2429 Ok(bytes) => bytes,
2430 Err(error) => {
2431 // Plans over non-serializable providers (memory tables,
2432 // custom scans) fall back rather than fail the query.
2433 last_error = Some(error.to_string());
2434 continue;
2435 }
2436 };
2437 match verify_dfplan_roundtrip(&bytes, &codec, &decode_ctx, Some(&stage_plan)) {
2438 Ok(()) => {
2439 shippable = Some(bytes);
2440 break;
2441 }
2442 Err(error) => last_error = Some(error.to_string()),
2443 }
2444 }
2445 let Some(bytes) = shippable else {
2446 // At `warn`: declining to distribute is not a detail, it is the
2447 // difference between a query using the cluster and one task
2448 // scanning the whole table. This was `debug` on a coordinator that
2449 // runs at `info`, so TPC-H q22 quietly ran serially for three
2450 // sweeps with nothing in the logs saying why.
2451 tracing::warn!(
2452 error = %last_error.unwrap_or_else(|| String::from("unknown")),
2453 "stage plan cannot be encoded and decoded; running this query as a SINGLE TASK"
2454 );
2455 return Ok(None);
2456 };
2457 let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
2458 let task_bodies = (0..partition_count)
2459 .map(|p| dfplan_task_body(&b64, p))
2460 .collect();
2461 stages.push(DistributedStage {
2462 task_bodies,
2463 shuffle: draft.shuffle,
2464 upstream_stage_indexes,
2465 });
2466 }
2467 Ok(Some(DistributedStagePlan { stages }))
2468}
2469
2470/// Identity of a cut subtree, for deciding whether two exchanges can share one
2471/// stage.
2472///
2473/// # Why reuse is worth having
2474///
2475/// The cutter gave every exchange its own stage, so a subtree feeding two
2476/// consumers was scanned and shuffled twice. TPC-H q21 shuffles
2477/// `["l_orderkey","l_suppkey"]` in **three** separate stages — one per
2478/// EXISTS/NOT EXISTS self-join over `lineitem` — and q7 shuffles the **25-row**
2479/// `nation` table in two. On a cluster whose pod network is the binding
2480/// constraint, a redundant shuffle is redundant wire time. Spark calls this
2481/// `ReusedExchange`.
2482///
2483/// # Why this key is the whole safety argument
2484///
2485/// A false match merges two stages that are *not* equivalent, and the consumers
2486/// then read someone else's rows — a wrong answer with no error. So the key is
2487/// the full indented physical plan of the subtree, which renders operators,
2488/// projections, filter predicates and scanned file groups, plus the schema and
2489/// the shuffle's own key columns and partition count (compared separately by
2490/// the caller). Anything that changes what the stage *emits* changes this
2491/// string.
2492///
2493/// Deliberately conservative in two ways:
2494///
2495/// * Stages carrying a [`StageSubqueryContext`] are never reused. Their tasks
2496/// are parameterised by a subquery result, so identical plan text does not
2497/// imply identical output.
2498/// * It matches on rendered text rather than pointer identity, so it finds the
2499/// real duplicates (separately-planned subqueries) rather than only shared
2500/// `Arc`s — which is the case that actually occurs.
2501///
2502/// The empirical guard is `every_tpch_query_stages_to_the_same_answer_in_every_configuration`
2503/// in krishiv-bench: all 22 queries, four join/broadcast configurations, staged
2504/// through this cutter and compared against single-node execution.
2505fn exchange_reuse_key(
2506 plan: &Arc<dyn ExecutionPlan>,
2507 key_columns: &[String],
2508 num_partitions: usize,
2509) -> String {
2510 use datafusion::physical_plan::displayable;
2511 format!(
2512 "keys={key_columns:?}|parts={num_partitions}|schema={:?}|plan=\n{}",
2513 plan.schema(),
2514 displayable(plan.as_ref()).indent(true)
2515 )
2516}
2517
2518/// Largest `fetch` for which a `SortPreservingMergeExec` is worth turning into
2519/// a gather + re-sort.
2520///
2521/// Cutting the merge buys distribution of everything beneath it, and costs
2522/// buffering `partitions x fetch` rows in the stage that re-sorts them. At
2523/// TPC-H's fetches (10-100) over 18 partitions that is under two thousand
2524/// rows. A very large `LIMIT` inverts the trade: the merge would stream, the
2525/// replacement would buffer, and the subtree below is usually cheap anyway —
2526/// so those keep the streaming k-way merge they were planned with.
2527const MAX_GATHERED_SORT_FETCH: usize = 10_000;
2528
2529/// Does this subtree contain a hash-partitioned join?
2530///
2531/// The signal that a gather is stranding real distributed work. A
2532/// `Partitioned` join exists *because* N tasks should each handle one
2533/// partition; finding one below a gather means the plan paid for the shuffle
2534/// and is about to throw the parallelism away. A `CollectLeft` join says
2535/// nothing — its build side was chosen to be small and a single probe
2536/// partition may be entirely correct.
2537fn contains_partitioned_join(plan: &Arc<dyn ExecutionPlan>) -> bool {
2538 use datafusion::physical_plan::joins::{HashJoinExec, PartitionMode};
2539 if let Some(join) = plan.downcast_ref::<HashJoinExec>()
2540 && *join.partition_mode() == PartitionMode::Partitioned
2541 {
2542 return true;
2543 }
2544 plan.children()
2545 .iter()
2546 .any(|child| contains_partitioned_join(child))
2547}
2548
2549fn cut_exchanges(
2550 plan: Arc<dyn ExecutionPlan>,
2551 stages: &mut Vec<StageDraft>,
2552) -> Result<Arc<dyn ExecutionPlan>, Unsupported> {
2553 if let Some(repartition) = plan.downcast_ref::<RepartitionExec>() {
2554 let Partitioning::Hash(exprs, num_partitions) = repartition.partitioning() else {
2555 return Err(Unsupported(format!(
2556 "non-hash exchange in plan: {}",
2557 repartition.partitioning()
2558 )));
2559 };
2560 let key_columns = hash_expr_column_names(exprs).ok_or_else(|| {
2561 Unsupported(String::from(
2562 "hash exchange uses non-column expressions; cannot derive shuffle keys",
2563 ))
2564 })?;
2565 let input = cut_exchanges(Arc::clone(repartition.input()), stages)?;
2566 let map_task_count = input.output_partitioning().partition_count();
2567 if map_task_count == 0 {
2568 return Err(Unsupported(String::from("hash exchange over empty input")));
2569 }
2570 let schema = input.schema();
2571 // D3(2): capture the estimate before `input` is moved into the stage —
2572 // this is the only point where the cut subtree is still in hand.
2573 let estimate = ShuffleReadExec::estimate_of(&input);
2574
2575 // Exchange reuse: an identical subtree shuffled on identical keys into
2576 // identical partitions produces byte-identical output, so cut it once
2577 // and let both consumers read the same stage.
2578 let reuse_key = exchange_reuse_key(&input, &key_columns, *num_partitions);
2579 let stage_index = match stages.iter().position(|draft| {
2580 draft.subqueries.is_none()
2581 && draft.shuffle.as_ref().is_some_and(|sh| {
2582 sh.key_columns == key_columns && sh.num_output_partitions == *num_partitions
2583 })
2584 && exchange_reuse_key(&draft.plan, &key_columns, *num_partitions) == reuse_key
2585 }) {
2586 Some(existing) => existing,
2587 None => {
2588 let index = stages.len();
2589 stages.push(StageDraft {
2590 plan: input,
2591 shuffle: Some(StageShuffleOutput {
2592 key_columns,
2593 num_output_partitions: *num_partitions,
2594 }),
2595 subqueries: None,
2596 });
2597 index
2598 }
2599 };
2600 return Ok(Arc::new(
2601 ShuffleReadExec::new(stage_index, map_task_count, *num_partitions, schema, None)
2602 .with_upstream_estimate(estimate.0, estimate.1),
2603 ));
2604 }
2605
2606 // A gather (N partitions -> 1) is an exchange too, and cutting it is what
2607 // makes ungrouped aggregates distributable. `SELECT sum(x) FROM lineitem`
2608 // plans as Final(gather(Partial(scan))) with no hash exchange anywhere, so
2609 // a cutter that only recognised RepartitionExec declined the whole query
2610 // and one executor scanned the entire table — 518 s for TPC-H q6 at SF100
2611 // on a 3-node cluster, with the other two nodes idle.
2612 //
2613 // Cutting here puts the Partial aggregate in a map stage (one task per file
2614 // group, running everywhere) and the Final aggregate in a reduce stage
2615 // reading a single shuffle partition. The shuffle writer already routes
2616 // every row to partition 0 when no key column is given, so a keyless
2617 // 1-partition output is exactly a gather.
2618 if let Some(coalesce) = plan.downcast_ref::<CoalescePartitionsExec>() {
2619 let input = cut_exchanges(Arc::clone(coalesce.input()), stages)?;
2620 let map_task_count = input.output_partitioning().partition_count();
2621 if map_task_count <= 1 {
2622 // Nothing to spread: a one-partition input gathers to itself, and
2623 // a stage boundary here would add a shuffle round trip for no
2624 // parallelism. Keep the node as-is.
2625 return plan
2626 .with_new_children(vec![input])
2627 .map_err(|e| Unsupported(format!("gather rewrite: {e}")));
2628 }
2629 let schema = input.schema();
2630 // D3(2): as in the hash-exchange arm, capture before the move.
2631 let estimate = ShuffleReadExec::estimate_of(&input);
2632 let stage_index = stages.len();
2633 stages.push(StageDraft {
2634 plan: input,
2635 shuffle: Some(StageShuffleOutput {
2636 key_columns: Vec::new(),
2637 num_output_partitions: 1,
2638 }),
2639 subqueries: None,
2640 });
2641 // The read replaces the whole gather: coalesce(N->1) and
2642 // shuffle(N->1)+read(partition 0) produce the same single stream, and
2643 // CoalescePartitionsExec carries no ordering guarantee to preserve.
2644 return Ok(Arc::new(
2645 ShuffleReadExec::new(stage_index, map_task_count, 1, schema, None)
2646 .with_upstream_estimate(estimate.0, estimate.1),
2647 ));
2648 }
2649
2650 // A `SortPreservingMergeExec` with a fetch is a **bounded** gather, and not
2651 // cutting it is what leaves the whole query's real work in a one-task stage.
2652 //
2653 // TPC-H q3 at SF100, measured 2026-07-30: the plan is
2654 //
2655 // SortPreservingMerge(fetch=10) <- 1 partition
2656 // SortExec TopK(fetch=10) <- 18 partitions
2657 // Aggregate(SinglePartitioned) <- 18 partitions
2658 // HashJoin(Partitioned) <- 18 partitions
2659 // ShuffleRead(stage 1) / ShuffleRead(stage 2)
2660 //
2661 // The only exchanges are the two `RepartitionExec`s *below* the join, so
2662 // everything above them became one stage — and because the merge outputs a
2663 // single partition, that stage got exactly **one task**. One executor then
2664 // ran the entire 18-partition join and aggregate by itself and pulled both
2665 // shuffles (13.2 GB) across an ~11 MiB/s pod network
2666 // ([[bench-storage-longhorn-bottleneck]]): 13.2 GB / 11 MiB/s is ~20
2667 // minutes, which is exactly what the live run showed, with 8 of 9 slots
2668 // idle throughout.
2669 //
2670 // Cutting here puts the join, the aggregate and the per-partition TopK in a
2671 // real 18-task stage; only each partition's `fetch` rows cross the wire.
2672 //
2673 // **Only when `fetch` is set.** `SortPreservingMerge` streams a k-way merge
2674 // of already-sorted inputs; the replacement is a blocking `SortExec`, which
2675 // buffers what it gathers. With a fetch that is bounded by
2676 // `partitions x fetch` (180 rows at q3's shape) and the sort is trivial.
2677 // Without one it would buffer the entire result to re-sort rows that were
2678 // already sorted — trading a distribution win for an unbounded memory
2679 // liability, so those merges are left exactly as they are.
2680 if let Some(merge) = plan.downcast_ref::<SortPreservingMergeExec>() {
2681 let input = cut_exchanges(Arc::clone(merge.input()), stages)?;
2682 let fetch = merge.fetch();
2683 // Two ways this merge is worth cutting:
2684 //
2685 // * it has a small `fetch`, so the gather is bounded by
2686 // `partitions x fetch` and the re-sort is trivial; or
2687 // * there is no fetch, but a **hash-partitioned join** sits below it,
2688 // which is the q11/q15/q20 shape: without a cut, one task performs
2689 // that whole join. Buffering the gathered rows is worth it because
2690 // what reaches the final task is the join's *output*, while the
2691 // uncut plan drags the join's much larger *inputs* to a single node.
2692 //
2693 // Anything else keeps the streaming k-way merge it was planned with:
2694 // an unbounded gather + blocking sort over a subtree that was never
2695 // distributed in the first place buys nothing and risks a large spill.
2696 let worth_cutting = match fetch {
2697 Some(n) => n <= MAX_GATHERED_SORT_FETCH,
2698 None => contains_partitioned_join(&input),
2699 };
2700 if !worth_cutting {
2701 return plan
2702 .with_new_children(vec![input])
2703 .map_err(|e| Unsupported(format!("sort-merge passthrough: {e}")));
2704 }
2705 let map_task_count = input.output_partitioning().partition_count();
2706 if map_task_count <= 1 {
2707 // Already a single stream: a stage boundary here would add a
2708 // shuffle round trip and buy no parallelism.
2709 return plan
2710 .with_new_children(vec![input])
2711 .map_err(|e| Unsupported(format!("sort-merge gather rewrite: {e}")));
2712 }
2713 let schema = input.schema();
2714 let estimate = ShuffleReadExec::estimate_of(&input);
2715 let stage_index = stages.len();
2716 stages.push(StageDraft {
2717 plan: input,
2718 shuffle: Some(StageShuffleOutput {
2719 key_columns: Vec::new(),
2720 num_output_partitions: 1,
2721 }),
2722 subqueries: None,
2723 });
2724 let read = Arc::new(
2725 ShuffleReadExec::new(stage_index, map_task_count, 1, schema, None)
2726 .with_upstream_estimate(estimate.0, estimate.1),
2727 );
2728 // The gather loses the cross-partition ordering the merge guaranteed,
2729 // so re-establish it. Sorting the gathered rows is equivalent: every
2730 // upstream partition already emitted its own sorted run (its top-`fetch`
2731 // when there is a fetch), so a sort with the same expressions and the
2732 // same fetch yields the same rows in the same order.
2733 return Ok(Arc::new(
2734 SortExec::new(merge.expr().clone(), read).with_fetch(fetch),
2735 ));
2736 }
2737
2738 // An uncorrelated scalar subquery is not an exchange, but it is a boundary:
2739 // `ScalarSubqueryExec::children()` returns `[main_input, subquery…]`, and
2740 // the generic recursion below would treat a subquery plan as ordinary
2741 // pipeline and cut it. It must not: a subquery runs *whole* inside whatever
2742 // task evaluates it, so a `ShuffleReadExec` left in one would read a
2743 // sibling stage's output out of dependency order.
2744 //
2745 // Cut only the main input, and record the subquery context on every stage
2746 // that came out of it — those are exactly the stages that may have been
2747 // severed from the wrapper their `ScalarSubqueryExpr` nodes need. See
2748 // [`StageSubqueryContext`].
2749 if let Some(subquery_exec) = plan.downcast_ref::<ScalarSubqueryExec>() {
2750 let first_new_stage = stages.len();
2751 let input = cut_exchanges(Arc::clone(subquery_exec.input()), stages)?;
2752 let context = || StageSubqueryContext {
2753 links: subquery_exec.subqueries().to_vec(),
2754 results: subquery_exec.results().clone(),
2755 };
2756 if let Some(new_stages) = stages.get_mut(first_new_stage..) {
2757 for draft in new_stages {
2758 // Nested levels compose: an inner exec records its own
2759 // subqueries first, and only stages with no context yet belong
2760 // to this level.
2761 draft.subqueries.get_or_insert_with(context);
2762 }
2763 }
2764 // Rebuild in `children()` order: main input first, subqueries after.
2765 let mut children = Vec::with_capacity(subquery_exec.subqueries().len() + 1);
2766 children.push(input);
2767 children.extend(
2768 subquery_exec
2769 .subqueries()
2770 .iter()
2771 .map(|link| Arc::clone(&link.plan)),
2772 );
2773 return plan
2774 .with_new_children(children)
2775 .map_err(|e| Unsupported(format!("scalar-subquery rewrite: {e}")));
2776 }
2777
2778 let children = plan.children();
2779 if children.is_empty() {
2780 return Ok(plan);
2781 }
2782 let mut new_children = Vec::with_capacity(children.len());
2783 let mut changed = false;
2784 for child in children {
2785 let rewritten = cut_exchanges(Arc::clone(child), stages)?;
2786 changed = changed || !Arc::ptr_eq(&rewritten, child);
2787 new_children.push(rewritten);
2788 }
2789 if !changed {
2790 return Ok(plan);
2791 }
2792 plan.with_new_children(new_children)
2793 .map_err(|e| Unsupported(format!("plan rewrite: {e}")))
2794}
2795
2796/// Join types whose unmatched BUILD-side rows are emitted only after every
2797/// probe partition has been seen.
2798///
2799/// `HashJoinExec` tracks which build rows matched in a shared bitmap and emits
2800/// the unmatched ones from whichever probe partition finishes last
2801/// (`report_probe_completed`). Everything else streams straight through from
2802/// the probe side and needs no such rendezvous.
2803fn emits_unmatched_build_rows(join_type: datafusion::logical_expr::JoinType) -> bool {
2804 use datafusion::logical_expr::JoinType;
2805 matches!(
2806 join_type,
2807 JoinType::Left
2808 | JoinType::LeftAnti
2809 | JoinType::LeftSemi
2810 | JoinType::LeftMark
2811 | JoinType::Full
2812 )
2813}
2814
2815/// Is this join a broadcast join that cannot survive being split across tasks?
2816///
2817/// `PartitionMode::CollectLeft` sizes its probe-completion counter from the
2818/// PLAN's probe partition count (`hash_join/exec.rs`: `probe_threads_count =
2819/// self.right().output_partitioning().partition_count()`). A distributed task
2820/// executes exactly ONE partition of that plan, so the counter is decremented
2821/// once and never reaches "last probe" — and the unmatched build rows are
2822/// never emitted at all.
2823///
2824/// That is a silent wrong answer, not an error: TPC-H q22's `NOT EXISTS`
2825/// anti-join returned ZERO rows per task, and nothing in the plan, the logs or
2826/// the schema said so. `PartitionMode::Partitioned` passes `1` for the same
2827/// counter — each task owns a disjoint hash range and is its own last probe —
2828/// which is why the fix is to convert rather than to decline.
2829///
2830/// A single-partition probe side is safe as it stands: the count is already 1.
2831fn is_unsplittable_broadcast_join(join: &datafusion::physical_plan::joins::HashJoinExec) -> bool {
2832 use datafusion::physical_plan::joins::PartitionMode;
2833 *join.partition_mode() == PartitionMode::CollectLeft
2834 && emits_unmatched_build_rows(*join.join_type())
2835 && join.right().output_partitioning().partition_count() > 1
2836}
2837
2838/// Does this build side's estimate say the relation is **empty**?
2839///
2840/// Not "small" — empty. This is deliberately the narrowest possible
2841/// disagreement with DataFusion, and the narrowness is the whole design.
2842///
2843/// DataFusion's `supports_collect_by_thresholds` asks `estimate < ceiling`,
2844/// which a degenerate estimate of **zero** passes more convincingly than any
2845/// real small table. TPC-H q21 at SF100, verbatim: its `NOT EXISTS` becomes a
2846/// `LeftAnti` join whose two sides are both `lineitem` on `l_orderkey`, so
2847/// `estimate_join_statistics` computes `outer_rows - semi_estimate` =
2848/// `593462145 - 593462145` = **0 rows, 0 bytes** (`joins/utils.rs`, the
2849/// semi/anti arm). The real output is tens of millions of rows. Three
2850/// `CollectLeft` joins stacked above it each believed they were broadcasting
2851/// nothing, so the stage cutter gathered that intermediate to ONE partition
2852/// three times over (`shuffle=([], 1)` in the stage dump) and the whole top
2853/// half of the query ran on a single task.
2854///
2855/// # Why this does not also enforce a ceiling
2856///
2857/// It used to, and that was a **measured regression**. An earlier version of
2858/// this rule demanded a positive estimate *below the ceiling*, which converted
2859/// q8's and q9's `CollectLeft` build sides — estimated at `rows=~4000000,
2860/// bytes=absent`, i.e. above the 1M row ceiling but perfectly plausible. On the
2861/// cluster q8 went 92 s -> 375 s and q9 226 s -> 576 s, because the alternative
2862/// to broadcasting those few million rows is hash-partitioning the 600M-row
2863/// `lineitem` scan across an ~11 MiB/s pod network. That ceiling is DataFusion's
2864/// decision to make and it was already made with the numbers this rule can see;
2865/// second-guessing it lost more than the q21 bug cost.
2866///
2867/// So: a positive estimate is trusted, however large. Only "the planner thinks
2868/// there is nothing here" is overridden — because for a non-empty relation that
2869/// is not a measurement, it is the estimator giving up. A genuinely empty
2870/// relation pays one extra pair of hash exchanges over an empty stream.
2871fn broadcast_build_estimate_is_empty(
2872 join: &datafusion::physical_plan::joins::HashJoinExec,
2873) -> bool {
2874 // One shared reading of the statistics — see `crate::join_estimates` for
2875 // why this is not two hand-rolled matches any more, and for why the
2876 // broadcast override and the spill choice are allowed to want different
2877 // things from the same numbers.
2878 crate::join_estimates::BuildSideEstimate::of(join.left()).is_wholly_degenerate()
2879}
2880
2881/// Is this a broadcast join chosen on an estimate that says its build side is
2882/// empty when it is not?
2883///
2884/// Distinct from [`is_unsplittable_broadcast_join`], which is a *correctness*
2885/// test. This one is about throughput, and the two must stay separate: the
2886/// correctness test also gates [`find_unsupported_stage_node`], which refuses
2887/// to stage a plan rather than return wrong rows. Folding a performance
2888/// heuristic into that gate would turn a merely slow plan into a query that
2889/// declines to distribute at all.
2890///
2891/// Only fires where there is parallelism to lose: if both the probe side and
2892/// the build side's own input are already single-partition, the gather costs
2893/// nothing and the exchanges would be pure overhead.
2894fn is_degenerate_broadcast_join(join: &datafusion::physical_plan::joins::HashJoinExec) -> bool {
2895 use datafusion::physical_plan::joins::PartitionMode;
2896
2897 if *join.partition_mode() != PartitionMode::CollectLeft {
2898 return false;
2899 }
2900 // A null-aware anti join tracks probe-side state across the whole build and
2901 // is only correct as `CollectLeft` (DataFusion rejects any other mode for
2902 // it at construction). Never convert one.
2903 if join.null_aware {
2904 return false;
2905 }
2906 if !broadcast_build_estimate_is_empty(join) {
2907 return false;
2908 }
2909 let build_input_partitions = match join.left().downcast_ref::<CoalescePartitionsExec>() {
2910 Some(coalesce) => coalesce.input().output_partitioning().partition_count(),
2911 None => join.left().output_partitioning().partition_count(),
2912 };
2913 join.right().output_partitioning().partition_count() > 1 || build_input_partitions > 1
2914}
2915
2916/// The broadcast byte ceiling, resolved exactly as
2917/// [`planning_session_context_with_options`] resolves it.
2918///
2919/// Read here rather than threaded through because this pass runs after
2920/// planning, where the `SessionConfig` is gone. A caller that overrode the
2921/// ceiling to 0 ("never broadcast") cannot be affected: DataFusion then never
2922/// picks `CollectLeft`, so nothing downstream of this can fire.
2923fn broadcast_byte_ceiling() -> usize {
2924 std::env::var(BROADCAST_JOIN_BYTES_ENV)
2925 .ok()
2926 .and_then(|v| v.trim().parse::<usize>().ok())
2927 .filter(|n| *n > 0)
2928 .unwrap_or(DEFAULT_BROADCAST_JOIN_BYTES)
2929}
2930
2931/// Is this a broadcast whose build side is *wide* enough that the row ceiling
2932/// admitted something the byte ceiling would have refused?
2933///
2934/// `supports_collect_by_thresholds` prefers `total_byte_size` and falls back to
2935/// `num_rows`. Above a shuffle boundary the byte estimate is frequently absent —
2936/// `ShuffleReadExec` reports what the cut subtree reported, and DataFusion loses
2937/// `total_byte_size` through joins and aggregates — so the decision lands on the
2938/// row ceiling, which cannot see how wide a row is. At q10's customer shape
2939/// (~180 B/row) the 1,000,000-row ceiling admits ~155 MB, five times the 32 MiB
2940/// byte ceiling it stands in for, and `CollectLeft` then copies that to *every*
2941/// task of the stage. That is the shape of q10 moving ~63 GB in 13.5 minutes
2942/// against a plan implying ~3.5 GB of shuffle.
2943///
2944/// Narrow on purpose, because widening it is what regressed four queries
2945/// (see [`crate::join_estimates`]):
2946///
2947/// * Only fires where DataFusion *already chose* `CollectLeft`. A build side it
2948/// left partitioned — q8/q9/q17, whose row counts are far over the ceiling —
2949/// is never examined.
2950/// * Only when the byte estimate is **absent**. A positive byte estimate means
2951/// DataFusion decided on a real number with the same information, and
2952/// overriding that is precisely the regression.
2953fn broadcast_build_is_too_wide(join: &datafusion::physical_plan::joins::HashJoinExec) -> bool {
2954 use datafusion::physical_plan::joins::PartitionMode;
2955
2956 if *join.partition_mode() != PartitionMode::CollectLeft {
2957 return false;
2958 }
2959 // Null-aware anti joins are only correct as `CollectLeft` — never convert.
2960 if join.null_aware {
2961 return false;
2962 }
2963 let build = join.left();
2964 let Some(implied) =
2965 crate::join_estimates::BuildSideEstimate::of(build).bytes_implied_by_rows(&build.schema())
2966 else {
2967 return false;
2968 };
2969 implied > broadcast_byte_ceiling()
2970}
2971
2972/// Environment switch for the broadcast-dimension reducer. Default **off**.
2973pub const DIMENSION_REDUCTION_ENV: &str = "KRISHIV_DIMENSION_REDUCTION";
2974
2975/// Whether the broadcast-dimension reducer runs (default: no).
2976///
2977/// Opt-in, because being right about the architecture is not evidence about
2978/// the behaviour. The logical version of this idea was cleared by an A/B over
2979/// three queries, shipped on, and cost q10 18x.
2980fn dimension_reduction_enabled() -> bool {
2981 matches!(
2982 std::env::var(DIMENSION_REDUCTION_ENV)
2983 .unwrap_or_default()
2984 .trim()
2985 .to_ascii_lowercase()
2986 .as_str(),
2987 "1" | "on" | "true" | "yes"
2988 )
2989}
2990
2991/// [`reduce_by_broadcast_dimension`] with its env gate bypassed.
2992///
2993/// The gate cannot be flipped from a test — `set_var` is unsafe since edition
2994/// 2024 and this workspace forbids it — so without this the rule's own tests
2995/// would assert nothing at all now that the default is off. That is precisely
2996/// the failure mode this codebase keeps finding in its own suite.
2997#[cfg(test)]
2998fn reduce_by_broadcast_dimension_for_test(
2999 plan: Arc<dyn ExecutionPlan>,
3000) -> SqlResult<Arc<dyn ExecutionPlan>> {
3001 reduce_by_broadcast_dimension_inner(plan)
3002}
3003
3004/// Reduce a fact stream by a dimension the planner already chose to broadcast.
3005///
3006/// # The query
3007///
3008/// TPC-H q7's FROM clause ends `… nation n1, nation n2`, so a left-deep plan
3009/// puts both 25-row dimensions above every big join. `s_nationkey` is carried
3010/// as payload from the bottom join upward through two shuffles measured at
3011/// **9.48 GB each**, and only then is `n_name IN (FRANCE, GERMANY)` applied.
3012/// TPC-H spreads supplier nations uniformly over 25, so ~8% of suppliers can
3013/// survive: the bottom join emits about twelve times more rows than are
3014/// reachable. Those two shuffles and the stage consuming them were measured at
3015/// **81% of q7**.
3016///
3017/// Reducing `supplier` by the filtered `nation` before it is broadcast into
3018/// `lineitem` took q7 from 296.8 s to 118.6 s and collapsed both 9.48 GB
3019/// shuffles to 760 MB.
3020///
3021/// # Why this is a PHYSICAL rule, which is the whole point
3022///
3023/// This shipped first as a logical rule (`semi_join_reduction`), where the only
3024/// available test for "is this a dimension" was "does that side carry a
3025/// `Filter`". That is true of `nation` filtered to 2 rows and equally true of
3026/// `orders` filtered to a three-month window (~11M rows) and `lineitem`
3027/// filtered by `l_returnflag` (~150M) — so on q10 it attached a whole extra
3028/// join and cost **18.1x**. It was defaulted off in `72d0dad0`.
3029///
3030/// The missing discriminator is size, and it is **not obtainable at the logical
3031/// level**: `TableProvider::statistics()` defaults to `None` and `ListingTable`
3032/// does not override it, so for the parquet tables this engine actually reads,
3033/// a logical rule cannot tell 25 rows from 150 million.
3034///
3035/// Here it can. `partition_statistics()` is populated — it is what
3036/// [`broadcast_build_estimate_is_empty`] and [`broadcast_build_is_too_wide`]
3037/// already read — and better still, **the planner has already made the
3038/// judgement for us**: a `CollectLeft` join whose build side is provably small
3039/// is the definition of a dimension it is willing to broadcast. q7's nation
3040/// joins are `CollectLeft` with `build: rows=~5`; nothing in q10 that the
3041/// logical rule mistook for a dimension is.
3042///
3043/// # Why it is safe
3044///
3045/// - **Inner only.** An outer join preserves unmatched rows, so removing them
3046/// early changes the result.
3047/// - **Removes exactly what the join above removes.** A fact row whose key has
3048/// no match in the dimension cannot survive `Inner(dim, fact)`. `RightSemi`
3049/// removes precisely those and no others.
3050/// - **No duplication.** A semi join emits each surviving row once however many
3051/// dimension rows match, so counts and sums above are unchanged.
3052/// - **The schema is untouched.** `RightSemi` projects only its right side, so
3053/// the node it replaces keeps its exact schema and every parent's positional
3054/// column references stay valid.
3055///
3056/// # The guard that makes column matching sound
3057///
3058/// Physical columns are positional, and following one down through joins and
3059/// projections means remapping indices at every level — the kind of arithmetic
3060/// that pairs the wrong two columns and returns wrong rows silently.
3061///
3062/// So this does not track indices. It requires the key's name to occur in
3063/// **exactly one** leaf scan of the fact subtree, which makes the match
3064/// unambiguous by construction, and resolves the column against the target's
3065/// own schema with `Column::new_with_schema`. A self-join — q21 reads
3066/// `l_suppkey` from three separate `lineitem` scans — fails that test and is
3067/// declined rather than guessed at.
3068fn reduce_by_broadcast_dimension(
3069 plan: Arc<dyn ExecutionPlan>,
3070) -> SqlResult<Arc<dyn ExecutionPlan>> {
3071 // OFF by default until it has a measured cluster A/B of its own. The
3072 // logical version of this idea was cleared by an A/B over three queries,
3073 // shipped on, and cost q10 **18x** — caught only by the full 22-query
3074 // sweep. Being right about the architecture is not evidence about the
3075 // behaviour, and this rewrite has not been measured at SF100 yet.
3076 if !dimension_reduction_enabled() {
3077 return Ok(plan);
3078 }
3079 reduce_by_broadcast_dimension_inner(plan)
3080}
3081
3082/// The rewrite itself, past the gate — see `reduce_by_broadcast_dimension`.
3083fn reduce_by_broadcast_dimension_inner(
3084 plan: Arc<dyn ExecutionPlan>,
3085) -> SqlResult<Arc<dyn ExecutionPlan>> {
3086 use datafusion::logical_expr::JoinType;
3087 use datafusion::physical_plan::joins::{HashJoinExec, PartitionMode};
3088
3089 // Bottom-up, so a rewritten child is what the parent above sees.
3090 let children = plan.children();
3091 let plan = if children.is_empty() {
3092 plan
3093 } else {
3094 let mut rebuilt = Vec::with_capacity(children.len());
3095 let mut changed = false;
3096 for child in children {
3097 let new_child = reduce_by_broadcast_dimension_inner(Arc::clone(child))?;
3098 changed = changed || !Arc::ptr_eq(&new_child, child);
3099 rebuilt.push(new_child);
3100 }
3101 if changed {
3102 plan.with_new_children(rebuilt)
3103 .map_err(|e| SqlError::DataFusion {
3104 message: format!("dimension-reduction rewrite: {e}"),
3105 })?
3106 } else {
3107 plan
3108 }
3109 };
3110
3111 let Some(join) = plan.downcast_ref::<HashJoinExec>() else {
3112 return Ok(plan);
3113 };
3114 if *join.join_type() != JoinType::Inner
3115 || *join.partition_mode() != PartitionMode::CollectLeft
3116 || join.null_aware
3117 {
3118 return Ok(plan);
3119 }
3120 // The dimension is the build side, and it must be provably small — a
3121 // positive estimate under the same ceiling the broadcast decision uses.
3122 // `Absent` is an honest "I do not know" and declines, exactly as the
3123 // sibling rules treat it.
3124 if !is_broadcastable_dimension(join.left()) {
3125 return Ok(plan);
3126 }
3127
3128 let fact = join.right();
3129 for (dim_key, fact_key) in join.on() {
3130 let (Some(dim_col), Some(fact_col)) = (
3131 (dim_key.as_ref() as &dyn std::any::Any)
3132 .downcast_ref::<datafusion::physical_plan::expressions::Column>(),
3133 (fact_key.as_ref() as &dyn std::any::Any)
3134 .downcast_ref::<datafusion::physical_plan::expressions::Column>(),
3135 ) else {
3136 continue;
3137 };
3138 // Unambiguous by name across the fact subtree's leaves, or decline.
3139 if leaf_scans_naming(fact, fact_col.name()) != 1 {
3140 continue;
3141 }
3142 let Some(reduced_fact) =
3143 attach_reducer(fact, fact_col.name(), join.left(), dim_col.name())?
3144 else {
3145 continue;
3146 };
3147 let rebuilt = join
3148 .builder()
3149 .reset_state()
3150 .with_new_children(vec![Arc::clone(join.left()), reduced_fact])
3151 .and_then(|b| b.recompute_properties().build_exec())
3152 .map_err(|e| SqlError::DataFusion {
3153 message: format!("dimension-reduction rebuild: {e}"),
3154 })?;
3155 tracing::debug!(
3156 key = fact_col.name(),
3157 "reduced a fact stream by a broadcast dimension before its joins"
3158 );
3159 return Ok(rebuilt);
3160 }
3161 Ok(plan)
3162}
3163
3164/// Is this a relation small enough that the planner would broadcast it?
3165///
3166/// A positive estimate under [`broadcast_byte_ceiling`]. A degenerate zero is
3167/// refused for the same reason [`broadcast_build_estimate_is_empty`] refuses
3168/// it: zero is the estimator giving up, and reducing by a relation we believe
3169/// is empty would be reducing by nothing.
3170fn is_broadcastable_dimension(plan: &Arc<dyn ExecutionPlan>) -> bool {
3171 use datafusion::common::stats::Precision;
3172
3173 let estimate = crate::join_estimates::BuildSideEstimate::of(plan);
3174 if estimate.is_wholly_degenerate() {
3175 return false;
3176 }
3177 let Ok(stats) = plan.partition_statistics(None) else {
3178 return false;
3179 };
3180 let bytes = match stats.total_byte_size {
3181 Precision::Exact(b) | Precision::Inexact(b) => Some(b),
3182 Precision::Absent => estimate.bytes_implied_by_rows(&plan.schema()),
3183 };
3184 matches!(bytes, Some(b) if b > 0 && b <= broadcast_byte_ceiling())
3185}
3186
3187/// How many leaf scans beneath `plan` have a field of this name?
3188///
3189/// The soundness test for matching the key by name — see the guard note on
3190/// [`reduce_by_broadcast_dimension`]. More than one means the name is
3191/// ambiguous (a self-join) and the rewrite is refused.
3192fn leaf_scans_naming(plan: &Arc<dyn ExecutionPlan>, name: &str) -> usize {
3193 if plan.children().is_empty() {
3194 return usize::from(plan.schema().index_of(name).is_ok());
3195 }
3196 plan.children()
3197 .iter()
3198 .map(|child| leaf_scans_naming(child, name))
3199 .sum()
3200}
3201
3202/// Attach a `RightSemi` reducer at the deepest node that still carries `name`.
3203///
3204/// `RightSemi` with the dimension on the left and `CollectLeft` mode is the
3205/// shape the physical planner itself produces for this: it buffers the tiny
3206/// dimension and probes with the fact side, and its output schema is the
3207/// right side's — so the node being replaced keeps its exact schema.
3208fn attach_reducer(
3209 plan: &Arc<dyn ExecutionPlan>,
3210 name: &str,
3211 dimension: &Arc<dyn ExecutionPlan>,
3212 dimension_key: &str,
3213) -> SqlResult<Option<Arc<dyn ExecutionPlan>>> {
3214 use datafusion::logical_expr::JoinType;
3215 use datafusion::physical_expr::PhysicalExpr;
3216 use datafusion::physical_plan::expressions::Column;
3217 use datafusion::physical_plan::joins::{HashJoinExec, PartitionMode};
3218
3219 // Already reduced by this dimension: the rewrite is idempotent by refusing
3220 // to stack, not by hoping the traversal only runs once.
3221 if let Some(existing) = plan.downcast_ref::<HashJoinExec>()
3222 && *existing.join_type() == JoinType::RightSemi
3223 {
3224 return Ok(None);
3225 }
3226
3227 // Deepest first: reducing as early as possible is the entire point, so
3228 // recurse before considering this node.
3229 for (at, child) in plan.children().iter().enumerate() {
3230 if child.schema().index_of(name).is_err() {
3231 continue;
3232 }
3233 if let Some(new_child) = attach_reducer(child, name, dimension, dimension_key)? {
3234 let mut children: Vec<Arc<dyn ExecutionPlan>> =
3235 plan.children().into_iter().map(Arc::clone).collect();
3236 let Some(slot) = children.get_mut(at) else {
3237 return Ok(None);
3238 };
3239 *slot = new_child;
3240 return Arc::clone(plan)
3241 .with_new_children(children)
3242 .map(Some)
3243 .map_err(|e| SqlError::DataFusion {
3244 message: format!("dimension-reduction splice: {e}"),
3245 });
3246 }
3247 }
3248
3249 // No child could take it; land it here if this node actually has the column.
3250 let Ok(fact_index) = plan.schema().index_of(name) else {
3251 return Ok(None);
3252 };
3253 let Ok(dim_index) = dimension.schema().index_of(dimension_key) else {
3254 return Ok(None);
3255 };
3256 let on = vec![(
3257 Arc::new(Column::new(dimension_key, dim_index)) as Arc<dyn PhysicalExpr>,
3258 Arc::new(Column::new(name, fact_index)) as Arc<dyn PhysicalExpr>,
3259 )];
3260 let reducer = HashJoinExec::try_new(
3261 Arc::clone(dimension),
3262 Arc::clone(plan),
3263 on,
3264 None,
3265 &JoinType::RightSemi,
3266 None,
3267 PartitionMode::CollectLeft,
3268 datafusion::common::NullEquality::NullEqualsNothing,
3269 false,
3270 )
3271 .map_err(|e| SqlError::DataFusion {
3272 message: format!("dimension reducer: {e}"),
3273 })?;
3274 Ok(Some(Arc::new(reducer)))
3275}
3276
3277/// Convert broadcast joins that cannot be split — or that were chosen on an
3278/// estimate claiming their build side is empty, or on a row ceiling blind to
3279/// how wide those rows are — into hash-partitioned joins (see
3280/// [`is_unsplittable_broadcast_join`], [`is_degenerate_broadcast_join`] and
3281/// [`broadcast_build_is_too_wide`]).
3282///
3283/// Both sides gain a hash exchange on the join keys, which the stage cutter
3284/// then turns into ordinary map stages — so the join keeps running across the
3285/// cluster instead of being declined back to a single task.
3286///
3287/// The build side's `CoalescePartitionsExec` is dropped when present: it exists
3288/// only to satisfy `CollectLeft`'s `Distribution::SinglePartition` requirement,
3289/// and keeping it would funnel the whole build side through one partition
3290/// before re-splitting it.
3291pub fn redistribute_unsplittable_broadcast_joins(
3292 plan: Arc<dyn ExecutionPlan>,
3293) -> SqlResult<Arc<dyn ExecutionPlan>> {
3294 use datafusion::physical_plan::joins::{HashJoinExec, PartitionMode};
3295
3296 // Bottom-up: children are rewritten before the node that joins them, so a
3297 // converted child's new partitioning is what the parent sees.
3298 let children = plan.children();
3299 let plan = if children.is_empty() {
3300 plan
3301 } else {
3302 let mut new_children = Vec::with_capacity(children.len());
3303 let mut changed = false;
3304 for child in children {
3305 let rewritten = redistribute_unsplittable_broadcast_joins(Arc::clone(child))?;
3306 changed = changed || !Arc::ptr_eq(&rewritten, child);
3307 new_children.push(rewritten);
3308 }
3309 if changed {
3310 plan.with_new_children(new_children)
3311 .map_err(|e| SqlError::DataFusion {
3312 message: format!("broadcast-join redistribution rewrite: {e}"),
3313 })?
3314 } else {
3315 plan
3316 }
3317 };
3318
3319 let Some(join) = plan.downcast_ref::<HashJoinExec>() else {
3320 return Ok(plan);
3321 };
3322 let unsplittable = is_unsplittable_broadcast_join(join);
3323 if !unsplittable && !is_degenerate_broadcast_join(join) && !broadcast_build_is_too_wide(join) {
3324 return Ok(plan);
3325 }
3326
3327 let (left_keys, right_keys): (Vec<_>, Vec<_>) = join
3328 .on()
3329 .iter()
3330 .map(|(l, r)| (Arc::clone(l), Arc::clone(r)))
3331 .unzip();
3332
3333 let build_side = match join.left().downcast_ref::<CoalescePartitionsExec>() {
3334 Some(coalesce) => Arc::clone(coalesce.input()),
3335 None => Arc::clone(join.left()),
3336 };
3337 // The wider of the two sides, not simply the probe's count.
3338 //
3339 // Taking the probe side's alone is a rescue that does not rescue when the
3340 // probe is a small single-file table. TPC-H q21's last join is against
3341 // `nation` — 25 rows in one parquet file, so one partition — with a build
3342 // side of everything above `lineitem ⋈ supplier ⋈ orders`. Sizing from the
3343 // probe hash-partitioned that whole intermediate into **one** partition:
3344 // exactly as serial as the broadcast being replaced, plus two exchanges
3345 // and a stage boundary to pay for it.
3346 //
3347 // `max` can only ever raise the count, so no shape that works today loses
3348 // parallelism; both sides are repartitioned to it regardless, which is what
3349 // `PartitionMode::Partitioned` requires.
3350 let partitions = join
3351 .right()
3352 .output_partitioning()
3353 .partition_count()
3354 .max(build_side.output_partitioning().partition_count());
3355 let exchange = |input: Arc<dyn ExecutionPlan>,
3356 keys: Vec<Arc<dyn datafusion::physical_expr::PhysicalExpr>>|
3357 -> SqlResult<Arc<dyn ExecutionPlan>> {
3358 RepartitionExec::try_new(input, Partitioning::Hash(keys, partitions))
3359 .map(|r| Arc::new(r) as Arc<dyn ExecutionPlan>)
3360 .map_err(|e| SqlError::DataFusion {
3361 message: format!("broadcast-join redistribution exchange: {e}"),
3362 })
3363 };
3364
3365 let converted = join
3366 .builder()
3367 .with_new_children(vec![
3368 exchange(build_side, left_keys)?,
3369 exchange(Arc::clone(join.right()), right_keys)?,
3370 ])
3371 .and_then(|b| {
3372 b.with_partition_mode(PartitionMode::Partitioned)
3373 .recompute_properties()
3374 .reset_state()
3375 .build_exec()
3376 })
3377 .map_err(|e| SqlError::DataFusion {
3378 message: format!("broadcast-join redistribution rebuild: {e}"),
3379 })?;
3380 tracing::debug!(
3381 join_type = ?join.join_type(),
3382 partitions,
3383 reason = if unsplittable { "unsplittable" } else { "oversized" },
3384 "converted a broadcast join to a hash-partitioned join"
3385 );
3386 Ok(converted)
3387}
3388
3389/// Give a severed stage subtree back the [`ScalarSubqueryExec`] wrapper its
3390/// `ScalarSubqueryExpr` nodes need in order to decode and to resolve.
3391///
3392/// A pass-through node: it reports its input's partitioning and statistics
3393/// verbatim, so wrapping changes neither the stage's task count nor its
3394/// shuffle keys — only whether the fragment can be rebuilt on an executor.
3395fn wrap_in_scalar_subquery_exec(
3396 plan: Arc<dyn ExecutionPlan>,
3397 context: &StageSubqueryContext,
3398) -> Arc<dyn ExecutionPlan> {
3399 Arc::new(ScalarSubqueryExec::new(
3400 plan,
3401 context.links.clone(),
3402 context.results.clone(),
3403 ))
3404}
3405
3406/// Extract plain column names from hash-partitioning expressions.
3407fn hash_expr_column_names(
3408 exprs: &[Arc<dyn datafusion::physical_expr::PhysicalExpr>],
3409) -> Option<Vec<String>> {
3410 use datafusion::physical_expr::expressions::Column;
3411 let mut names = Vec::with_capacity(exprs.len());
3412 for expr in exprs {
3413 let column = (expr.as_ref() as &dyn std::any::Any).downcast_ref::<Column>()?;
3414 names.push(column.name().to_owned());
3415 }
3416 (!names.is_empty()).then_some(names)
3417}
3418
3419/// Detect nodes that break the task-per-partition execution model.
3420fn find_unsupported_stage_node(plan: &Arc<dyn ExecutionPlan>) -> Option<String> {
3421 if plan.is::<RepartitionExec>() {
3422 return Some(String::from("RepartitionExec inside stage subtree"));
3423 }
3424 // The safety net behind `redistribute_unsplittable_broadcast_joins`. If a
3425 // broadcast join that emits unmatched build rows ever reaches a stage
3426 // subtree unconverted, declining to stage is the only correct outcome:
3427 // shipping it returns the wrong ANSWER rather than an error, and a wrong
3428 // answer that looks like a clean pass is the worst failure this builder
3429 // can produce.
3430 if let Some(join) = plan.downcast_ref::<datafusion::physical_plan::joins::HashJoinExec>()
3431 && is_unsplittable_broadcast_join(join)
3432 {
3433 return Some(format!(
3434 "broadcast {:?} join inside a stage subtree: its unmatched build rows are \
3435 emitted only after the last probe partition, which a task executing one \
3436 partition can never observe",
3437 join.join_type()
3438 ));
3439 }
3440 // A scalar subquery is executed WHOLE by whichever task evaluates it —
3441 // `ScalarSubqueryExec` runs each through `execute_stream`, which coalesces
3442 // the plan to a single partition. An exchange inside one is therefore
3443 // ordinary single-node execution, not a violation of the task-per-partition
3444 // model, and the rule below must not reach into it: descending would reject
3445 // any query whose subquery happens to contain a hash exchange and quietly
3446 // run the whole thing as one task.
3447 if let Some(subquery_exec) = plan.downcast_ref::<ScalarSubqueryExec>() {
3448 return find_unsupported_stage_node(subquery_exec.input());
3449 }
3450 for child in plan.children() {
3451 if let Some(reason) = find_unsupported_stage_node(child) {
3452 return Some(reason);
3453 }
3454 }
3455 None
3456}
3457
3458// ── Cross-stage runtime filters ────────────────────────────────────────────
3459
3460/// How many times larger the probe side must be estimated before a filter is
3461/// worth its stage.
3462///
3463/// The filter costs one extra scan of the build side plus a broadcast of a few
3464/// MB to every probe task. Below this ratio that is not obviously repaid, and a
3465/// rule that fires on marginal cases is exactly how the semi-join rule and the
3466/// broadcast over-reach each cost more than they gained.
3467const RUNTIME_FILTER_MIN_RATIO: usize = 8;
3468
3469/// A join that can carry a cross-stage runtime filter, with everything the
3470/// rewrite needs already validated.
3471#[derive(Debug, Clone, Copy)]
3472struct RuntimeFilterCandidate {
3473 build_stage: usize,
3474 probe_stage: usize,
3475 build_key_index: usize,
3476 probe_key_index: usize,
3477 filter_bytes: usize,
3478}
3479
3480/// What a join child reads, when it reads one upstream stage with the column
3481/// layout that stage's root emits.
3482#[derive(Debug, Clone, Copy)]
3483struct JoinSideRead {
3484 stage: usize,
3485 rows: Option<usize>,
3486}
3487
3488/// Find the [`ShuffleReadExec`] under a join child, if the column indexes at the
3489/// join are the same indexes the upstream stage's root emits.
3490///
3491/// The whole rewrite hinges on that equality: the key index comes from the
3492/// join's `on` expressions, which are `Column`s into the join child's schema,
3493/// and it is applied at the *root of the upstream stage*. So this descends only
3494/// through single-child nodes that leave the field list untouched — a
3495/// projection that reorders or renames stops the walk rather than silently
3496/// shifting which column gets filtered.
3497fn join_side_read(plan: &Arc<dyn ExecutionPlan>) -> Option<JoinSideRead> {
3498 let mut current = Arc::clone(plan);
3499 loop {
3500 if let Some(read) = current.downcast_ref::<ShuffleReadExec>() {
3501 return Some(JoinSideRead {
3502 stage: read.upstream_stage_index(),
3503 rows: read.upstream_estimate().0,
3504 });
3505 }
3506 let next = {
3507 let children = current.children();
3508 let [child] = children.as_slice() else {
3509 return None;
3510 };
3511 if current.schema().fields() != child.schema().fields() {
3512 return None;
3513 }
3514 Arc::clone(child)
3515 };
3516 current = next;
3517 }
3518}
3519
3520/// Decide whether one join earns a runtime filter, and on which key.
3521///
3522/// Every guard here exists because a plan rule that fires too widely has
3523/// already cost this engine more than it gained, twice.
3524/// Why joins were not even considered as runtime-filter candidates.
3525///
3526/// The guards inside [`runtime_filter_candidate`] all return `None`, so a join
3527/// rejected there never reaches the injection loop and never appears in its
3528/// counters. Instrumenting only the injection loop would have answered "how
3529/// many candidates were rejected" while leaving "why were there no candidates"
3530/// exactly as invisible as before — which is the half-fix that makes a rule
3531/// look installed-but-idle.
3532#[derive(Debug, Default, Clone, Copy)]
3533struct RuntimeFilterRejects {
3534 /// Joins inspected.
3535 joins: usize,
3536 /// Not an INNER join, so dropping probe rows would change the answer.
3537 not_inner: usize,
3538 /// A side does not resolve to a `ShuffleReadExec` through schema-preserving
3539 /// parents — a projection between the join and the read stops the walk.
3540 side_not_a_shuffle_read: usize,
3541 /// Both sides read the same stage; DataFusion's own dynamic filter covers it.
3542 same_stage: usize,
3543 /// No row estimate on one of the sides.
3544 no_row_estimate: usize,
3545 /// The build side is not selective enough to be worth a stage.
3546 not_selective: usize,
3547 /// The bloom would be clamped at the size ceiling, degrading to
3548 /// "matches everything".
3549 filter_too_large: usize,
3550 /// No equijoin pair of a type the filter can encode on both sides.
3551 no_encodable_key: usize,
3552 /// A join node this pass cannot even look at, because it is not a
3553 /// `HashJoinExec`.
3554 ///
3555 /// This is not a rejection — it is the pass being structurally blind, and
3556 /// it is why every other counter here read zero on the query this feature
3557 /// was built for. `SpillableJoinSelection` runs *before* stage cutting and
3558 /// converts an oversized hash join into a sort-merge join; on TPC-H q21 at
3559 /// SF100 it converted all five (`hash_joins: 5, converted: 3`, plus two it
3560 /// declined that were already sort-merge). By the time this pass walks the
3561 /// plan there is no `HashJoinExec` left to inspect, so it logged
3562 /// `joins_inspected: 0` — indistinguishable, in a log, from a query with no
3563 /// joins at all.
3564 ///
3565 /// Counting them separately makes "the rule declined" and "the rule could
3566 /// not see it" different observations. Generalising the rule to sort-merge
3567 /// (and grace) joins is the actual fix: a runtime filter is a semantic
3568 /// operation on the join's keys and does not care which algorithm executes
3569 /// it, and `runtime_filter_candidate` only needs `join_type`, `left`,
3570 /// `right` and the equijoin keys, all of which those nodes also have.
3571 joins_of_unsupported_kind: usize,
3572}
3573
3574/// The four things a runtime filter needs from a join, independent of which
3575/// algorithm executes it.
3576///
3577/// A bloom filter is a statement about the join's *keys*: probe rows whose key
3578/// cannot appear on the build side cannot join, whichever way the rows are
3579/// matched. Hash, sort-merge and grace joins all answer the same four
3580/// questions, so the rule reads them through this view instead of being
3581/// hard-wired to one node type — which is what left it inspecting zero joins on
3582/// q21 (see `joins_of_unsupported_kind`).
3583struct JoinView<'a> {
3584 /// By value: `HashJoinExec` hands back a reference and
3585 /// `SortMergeJoinExec` a copy, and `JoinType` is `Copy`.
3586 join_type: datafusion::logical_expr::JoinType,
3587 left: &'a Arc<dyn ExecutionPlan>,
3588 right: &'a Arc<dyn ExecutionPlan>,
3589 /// DataFusion's own name for the equijoin-pair slice, so this reads the
3590 /// same shape both join nodes expose rather than restating it.
3591 on: datafusion::physical_plan::joins::utils::JoinOnRef<'a>,
3592}
3593
3594fn runtime_filter_candidate(
3595 join: &JoinView<'_>,
3596 rejects: &mut RuntimeFilterRejects,
3597) -> Option<RuntimeFilterCandidate> {
3598 use datafusion::logical_expr::JoinType;
3599 use datafusion::physical_expr::expressions::Column;
3600 use krishiv_shuffle::{FilterKeyType, MAX_FILTER_BYTES, plan_filter_bytes};
3601
3602 // Guard — INNER only. A bloom drops probe rows that cannot match, which is
3603 // invisible to an inner join and catastrophic to anything that preserves
3604 // unmatched probe rows: RightAnti emits exactly the rows this removes, and
3605 // Full/Right pad them with nulls. `RuntimeFilter::contains` also drops null
3606 // keys, which is correct only where a null key cannot produce output.
3607 rejects.joins += 1;
3608 if join.join_type != JoinType::Inner {
3609 rejects.not_inner += 1;
3610 return None;
3611 }
3612 // Guard 1 — different stages. A same-stage join already gets DataFusion's
3613 // own dynamic filter, so firing there duplicates work for nothing.
3614 let (Some(build), Some(probe)) = (join_side_read(join.left), join_side_read(join.right)) else {
3615 rejects.side_not_a_shuffle_read += 1;
3616 return None;
3617 };
3618 if build.stage == probe.stage {
3619 rejects.same_stage += 1;
3620 return None;
3621 }
3622
3623 // Guard 2 — selectivity, on estimates that exist. `Precision::Absent`
3624 // arrives here as `None` and means "no idea", never "small": guessing is
3625 // the `SpillableJoinSelection` lesson, and guessing wrong here adds a stage
3626 // and a broadcast to a query that gains nothing from either.
3627 let (Some(build_rows), Some(probe_rows)) = (build.rows, probe.rows) else {
3628 rejects.no_row_estimate += 1;
3629 return None;
3630 };
3631 if build_rows == 0 || probe_rows / RUNTIME_FILTER_MIN_RATIO < build_rows {
3632 rejects.not_selective += 1;
3633 return None;
3634 }
3635
3636 // Guard 3 — size cap. `plan_filter_bytes` clamps at the ceiling, and a
3637 // clamped filter is one whose false-positive rate has quietly degraded
3638 // towards "matches everything" — correct, but pure cost.
3639 let filter_bytes = plan_filter_bytes(build_rows as u64);
3640 if filter_bytes >= MAX_FILTER_BYTES {
3641 rejects.filter_too_large += 1;
3642 return None;
3643 }
3644
3645 // Guard 6 — read the join's own equijoin pairs rather than re-deriving
3646 // them. Only plain columns of a type the filter can encode canonically;
3647 // for a composite key the first usable column is enough, because a row that
3648 // matches on every key column necessarily matches on one of them.
3649 let found = join.on.iter().find_map(|(left, right)| {
3650 let build_column = (left.as_ref() as &dyn std::any::Any).downcast_ref::<Column>()?;
3651 let probe_column = (right.as_ref() as &dyn std::any::Any).downcast_ref::<Column>()?;
3652 let build_schema = join.left.schema();
3653 let probe_schema = join.right.schema();
3654 let build_type =
3655 FilterKeyType::for_data_type(build_schema.field(build_column.index()).data_type())?;
3656 let probe_type =
3657 FilterKeyType::for_data_type(probe_schema.field(probe_column.index()).data_type())?;
3658 // A disagreement fails open at runtime, but there is no reason to build
3659 // a filter that will be ignored.
3660 (build_type == probe_type).then_some(RuntimeFilterCandidate {
3661 build_stage: build.stage,
3662 probe_stage: probe.stage,
3663 build_key_index: build_column.index(),
3664 probe_key_index: probe_column.index(),
3665 filter_bytes,
3666 })
3667 });
3668 if found.is_none() {
3669 rejects.no_encodable_key += 1;
3670 }
3671 found
3672}
3673
3674fn collect_runtime_filter_candidates(
3675 plan: &Arc<dyn ExecutionPlan>,
3676 out: &mut Vec<RuntimeFilterCandidate>,
3677 rejects: &mut RuntimeFilterRejects,
3678) {
3679 // Both equijoin algorithms are read through the same view. Which one the
3680 // planner picked is not a property of the filter: `SpillableJoinSelection`
3681 // converts an oversized hash join to sort-merge *before* stage cutting, and
3682 // on q21 at SF100 it converted all five — so keying on `HashJoinExec` alone
3683 // meant the rule saw nothing at all on the query it was written for.
3684 if let Some(join) = plan.downcast_ref::<datafusion::physical_plan::joins::HashJoinExec>() {
3685 let view = JoinView {
3686 join_type: *join.join_type(),
3687 left: join.left(),
3688 right: join.right(),
3689 on: join.on(),
3690 };
3691 if let Some(candidate) = runtime_filter_candidate(&view, rejects) {
3692 out.push(candidate);
3693 }
3694 } else if let Some(join) =
3695 plan.downcast_ref::<datafusion::physical_plan::joins::SortMergeJoinExec>()
3696 {
3697 let view = JoinView {
3698 join_type: join.join_type(),
3699 left: join.left(),
3700 right: join.right(),
3701 on: join.on(),
3702 };
3703 if let Some(candidate) = runtime_filter_candidate(&view, rejects) {
3704 out.push(candidate);
3705 }
3706 } else if plan
3707 .downcast_ref::<datafusion::physical_plan::joins::NestedLoopJoinExec>()
3708 .is_some()
3709 || plan
3710 .downcast_ref::<crate::grace_hash_join::GraceHashJoinExec>()
3711 .is_some()
3712 {
3713 // Still unreachable, for different reasons, and both honest:
3714 //
3715 // * `NestedLoopJoinExec` has no equijoin pairs at all — it is the node
3716 // DataFusion picks when there is no equality to key on, so there is
3717 // no key to build a filter over. Not a gap; a category error.
3718 // * `GraceHashJoinExec` is ours and does carry equi keys, but it
3719 // partitions its build side into buckets on disk, and whether a probe
3720 // filter composes with that spill protocol is a question this pass
3721 // should not answer by assumption. Left counted until it is measured.
3722 rejects.joins_of_unsupported_kind += 1;
3723 }
3724 for child in plan.children() {
3725 collect_runtime_filter_candidates(child, out, rejects);
3726 }
3727}
3728
3729/// Does stage `from` depend, transitively, on stage `target`?
3730///
3731/// Guard 4. The filter stage inherits the build stage's upstreams, and the probe
3732/// stage gains a dependency on the filter stage — so if the build side already
3733/// depends on the probe side, that new edge closes a loop. The scheduler would
3734/// catch it (Kahn's algorithm, `validate_job`) but only by rejecting the whole
3735/// job, which turns an optimization into an outage.
3736fn stage_depends_on(drafts: &[StageDraft], from: usize, target: usize) -> bool {
3737 let mut seen = vec![false; drafts.len()];
3738 let mut stack = vec![from];
3739 while let Some(index) = stack.pop() {
3740 if index == target {
3741 return true;
3742 }
3743 match seen.get_mut(index) {
3744 Some(flag) if !*flag => *flag = true,
3745 _ => continue,
3746 }
3747 if let Some(draft) = drafts.get(index) {
3748 stack.extend(collect_upstream_stage_indexes(&draft.plan));
3749 }
3750 }
3751 false
3752}
3753
3754// ── Stage reuse (Spark's ReuseExchange) ────────────────────────────────────
3755
3756/// Env flag for cross-stage reuse of identical leaf stages. Default **off**.
3757pub const STAGE_REUSE_ENV: &str = "KRISHIV_STAGE_REUSE";
3758
3759/// Whether identical leaf stages are collapsed into one.
3760pub fn stage_reuse_enabled() -> bool {
3761 std::env::var(STAGE_REUSE_ENV)
3762 .map(|v| {
3763 let v = v.trim();
3764 v == "1" || v.eq_ignore_ascii_case("true") || v.eq_ignore_ascii_case("on")
3765 })
3766 .unwrap_or(false)
3767}
3768
3769/// Function names whose presence makes a subtree non-reusable.
3770///
3771/// Reuse replaces two evaluations with one, which is only sound when the
3772/// subtree is **deterministic**. `ExecutionPlan` exposes no expression
3773/// accessor, so the rendered plan text is the only place a volatile call is
3774/// visible — this matches on that text. Matching is deliberately over-eager: a
3775/// column merely *named* `random_score` also blocks reuse. A false positive
3776/// costs one missed optimization; a false negative would silently collapse two
3777/// evaluations that were supposed to differ.
3778const VOLATILE_MARKERS: &[&str] = &[
3779 "random(",
3780 "rand(",
3781 "uuid(",
3782 "now(",
3783 "current_timestamp",
3784 "current_date",
3785 "current_time",
3786 "nextval",
3787];
3788
3789/// Rewrite every `ShuffleReadExec` in `plan`, remapping its upstream stage
3790/// index through `remap`.
3791fn remap_shuffle_reads(
3792 plan: &Arc<dyn ExecutionPlan>,
3793 remap: &std::collections::HashMap<usize, usize>,
3794) -> Arc<dyn ExecutionPlan> {
3795 if let Some(read) = plan.downcast_ref::<ShuffleReadExec>() {
3796 let old = read.upstream_stage_index();
3797 if let Some(&new) = remap.get(&old)
3798 && new != old
3799 {
3800 return Arc::new(read.clone_with_upstream_stage_index(new));
3801 }
3802 return Arc::clone(plan);
3803 }
3804 let children = plan.children();
3805 if children.is_empty() {
3806 return Arc::clone(plan);
3807 }
3808 let new_children: Vec<_> = children
3809 .iter()
3810 .map(|child| remap_shuffle_reads(child, remap))
3811 .collect();
3812 let changed = new_children
3813 .iter()
3814 .zip(children.iter())
3815 .any(|(new, old)| !Arc::ptr_eq(new, old));
3816 if !changed {
3817 return Arc::clone(plan);
3818 }
3819 Arc::clone(plan)
3820 .with_new_children(new_children)
3821 .unwrap_or_else(|_| Arc::clone(plan))
3822}
3823
3824/// Collapse identical leaf stages into one, so a subtree computed twice is
3825/// computed once and both consumers read the same shuffle output.
3826///
3827/// This is Spark's `ReuseExchange`, and it fits our model exactly: the cutter
3828/// already materializes every stage boundary, so two stages that compute the
3829/// same thing are two writes of the same bytes.
3830///
3831/// # What it does NOT reach, measured
3832///
3833/// The three cases originally listed here (q18/q21 `lineitem`, q2 `partsupp`)
3834/// were identified as duplicated **scans**, and this rule was written as if
3835/// that made them duplicated **stages**. It does not. On the SF100 sweep of
3836/// `fast-6f586954` the rule fired **twice across all 22 queries**, one stage
3837/// each.
3838///
3839/// `tests/stage_reuse_duplicate_scan.rs` reproduces q18's shape over real
3840/// parquet and shows why:
3841///
3842/// ```text
3843/// stage A: AggregateExec(Partial, gby=l_orderkey, sum(l_quantity))
3844/// DataSourceExec lineitem[l_orderkey, l_quantity] DynamicFilter [ empty ]
3845/// stage B: DataSourceExec lineitem[l_orderkey, l_quantity] DynamicFilter [ empty ]
3846/// ```
3847///
3848/// Identical scan, identical projection, identical predicate — and a partial
3849/// aggregate fused onto one of them. They are not the same stage and never
3850/// will be. (The `DynamicFilter` is *identical* on both sides and is not the
3851/// blocker, which is what an earlier note here guessed.)
3852///
3853/// # And sharing the scan is probably the wrong trade here anyway
3854///
3855/// Both consumers want `lineitem` partitioned by `l_orderkey`, so one shuffle
3856/// could serve both — but only by lifting the partial aggregate above the
3857/// exchange, which puts ~600M raw rows on the wire in place of the partially
3858/// aggregated ~150M. This cluster's floor is the network (pod-to-pod ~11 MiB/s
3859/// VXLAN vs 150-286 MB/s node-local MinIO — `bench-storage-longhorn-bottleneck`,
3860/// `bench-storage-locality-fix`), so converting a cheap local re-read into an
3861/// expensive shuffle read is a loss, not a win.
3862///
3863/// The lever for q18 is not scanning `lineitem` once; it is not shipping 600M
3864/// rows at all (`cross-stage-runtime-filter-design`).
3865///
3866/// **Restricted to leaf stages** (no `ShuffleReadExec` inside, no severed
3867/// scalar-subquery context). Two reasons, both load-bearing: a leaf stage
3868/// contains no stage indexes, so collapsing it can never invalidate a
3869/// reference *inside* it; and a leaf stage is a scan + row-wise work, the case
3870/// where textual identity is most trustworthy. Non-leaf reuse (q17's
3871/// projection-subsumed scan) needs a different, wider rule.
3872///
3873/// Returns the number of stages removed.
3874fn dedupe_identical_stages(
3875 root: &mut Arc<dyn ExecutionPlan>,
3876 drafts: &mut Vec<StageDraft>,
3877) -> usize {
3878 if !stage_reuse_enabled() {
3879 return 0;
3880 }
3881 dedupe_identical_stages_unconditionally(root, drafts)
3882}
3883
3884/// [`dedupe_identical_stages`] without the flag check, so the rewrite and every
3885/// guard are testable without `set_var` racing across test threads.
3886fn dedupe_identical_stages_unconditionally(
3887 root: &mut Arc<dyn ExecutionPlan>,
3888 drafts: &mut Vec<StageDraft>,
3889) -> usize {
3890 use datafusion::physical_plan::displayable;
3891
3892 // The identity is the **encoded plan**, not the rendered plan text.
3893 //
3894 // Plan text is a display function, not a semantic identity: two
3895 // `DataSourceExec`s over different in-memory data render identically,
3896 // because the printer has nothing to show. Keying on text collapsed two
3897 // stages producing different rows — a wrong answer, caught by
3898 // `different_content_does_not_collapse`. The protobuf encoding is the
3899 // bytes we actually ship to executors, so byte-identical encodings are the
3900 // same computation by construction, and a plan that will not encode is
3901 // simply not eligible (fails closed).
3902 let codec = KrishivPhysicalCodec::coordinator();
3903
3904 // canonical key -> first draft index carrying it
3905 let mut first_seen: std::collections::HashMap<Vec<u8>, usize> =
3906 std::collections::HashMap::new();
3907 // duplicate index -> index it is replaced by
3908 let mut replaced_by: std::collections::HashMap<usize, usize> = std::collections::HashMap::new();
3909
3910 for (index, draft) in drafts.iter().enumerate() {
3911 if draft.subqueries.is_some() {
3912 continue;
3913 }
3914 if !collect_upstream_stage_indexes(&draft.plan).is_empty() {
3915 continue;
3916 }
3917 let Some(shuffle) = &draft.shuffle else {
3918 continue;
3919 };
3920 let text = displayable(draft.plan.as_ref()).indent(true).to_string();
3921 let lowered = text.to_ascii_lowercase();
3922 if VOLATILE_MARKERS.iter().any(|m| lowered.contains(m)) {
3923 continue;
3924 }
3925 // The shuffle contract is part of the identity: two stages computing
3926 // the same rows but partitioning them differently are NOT
3927 // interchangeable, because the consumer reads by partition index.
3928 //
3929 // `map_tasks` matters for a less obvious reason: a `ShuffleReadExec` is
3930 // constructed with the producer's task count, so repointing a reader at
3931 // a stage with a different task count would make it look for map
3932 // outputs that do not exist.
3933 let Ok(encoded) = encode_dfplan_bytes(Arc::clone(&draft.plan), &codec) else {
3934 // Not shippable, so not reusable. The main loop has its own
3935 // fallback for this; here it just means "skip".
3936 continue;
3937 };
3938 let mut key = encoded;
3939 key.extend_from_slice(
3940 format!(
3941 "|keys={:?}|parts={}|map_tasks={}|schema={:?}",
3942 shuffle.key_columns,
3943 shuffle.num_output_partitions,
3944 draft.plan.output_partitioning().partition_count(),
3945 draft.plan.schema()
3946 )
3947 .as_bytes(),
3948 );
3949 match first_seen.get(&key) {
3950 Some(&canonical) => {
3951 replaced_by.insert(index, canonical);
3952 }
3953 None => {
3954 first_seen.insert(key, index);
3955 }
3956 }
3957 }
3958
3959 if replaced_by.is_empty() {
3960 return 0;
3961 }
3962
3963 // Compact the draft list, building old -> new for the survivors.
3964 let mut remap: std::collections::HashMap<usize, usize> = std::collections::HashMap::new();
3965 let mut survivors: Vec<StageDraft> = Vec::with_capacity(drafts.len() - replaced_by.len());
3966 for (old_index, draft) in std::mem::take(drafts).into_iter().enumerate() {
3967 if replaced_by.contains_key(&old_index) {
3968 continue;
3969 }
3970 remap.insert(old_index, survivors.len());
3971 survivors.push(draft);
3972 }
3973 // Duplicates point at their canonical stage's NEW index. The canonical is
3974 // always a survivor (it is the first occurrence, and only later
3975 // occurrences are removed), so this lookup cannot fail.
3976 for (duplicate, canonical) in &replaced_by {
3977 if let Some(&new_canonical) = remap.get(canonical) {
3978 remap.insert(*duplicate, new_canonical);
3979 }
3980 }
3981
3982 let removed = replaced_by.len();
3983 for draft in &mut survivors {
3984 draft.plan = remap_shuffle_reads(&draft.plan, &remap);
3985 }
3986 *root = remap_shuffle_reads(root, &remap);
3987 *drafts = survivors;
3988
3989 tracing::info!(
3990 removed_stages = removed,
3991 "collapsed identical leaf stages (stage reuse)"
3992 );
3993 removed
3994}
3995
3996/// Insert cross-stage runtime filters: for each qualifying join, add a stage
3997/// that builds a bloom of the build-side key and make the probe stage filter
3998/// its rows through it before shuffling them.
3999///
4000/// Returns the number of filters injected. Off unless
4001/// [`crate::runtime_filter_exec::enabled`]; a failure to build any single
4002/// filter skips that filter and leaves the plan untouched, because a
4003/// throughput optimization must never be why a query fails.
4004fn inject_runtime_filters(root: &Arc<dyn ExecutionPlan>, drafts: &mut Vec<StageDraft>) -> usize {
4005 if !crate::runtime_filter_exec::enabled() {
4006 // Count and report anyway, then change nothing.
4007 //
4008 // The early return used to happen here, *before* the pass computed its
4009 // rejection breakdown — so the one diagnostic that would tell an
4010 // operator whether enabling the flag is worth trying was available only
4011 // after enabling it. Every run since the counters landed in `59243a94`
4012 // has therefore produced zero `runtime-filter: pass complete` lines, and
4013 // the note-to-self to "read that line before theorising" was unsatisfiable
4014 // by construction.
4015 //
4016 // A dry run costs one read-only walk of a plan that has just been built
4017 // and cut, and it makes "would this fire?" answerable from an ordinary
4018 // benchmark log.
4019 report_runtime_filter_candidates(root, drafts);
4020 return 0;
4021 }
4022 inject_runtime_filters_unconditionally(root, drafts)
4023}
4024
4025/// Walk the plan exactly as the injector would and log the same
4026/// `runtime-filter: pass complete` breakdown, without rewriting anything.
4027///
4028/// Deliberately shares [`collect_runtime_filter_candidates`] with the real pass:
4029/// a dry run that used its own traversal would answer a question nobody asked.
4030fn report_runtime_filter_candidates(root: &Arc<dyn ExecutionPlan>, drafts: &[StageDraft]) {
4031 let mut candidates = Vec::new();
4032 let mut rejects = RuntimeFilterRejects::default();
4033 collect_runtime_filter_candidates(root, &mut candidates, &mut rejects);
4034 for draft in drafts {
4035 collect_runtime_filter_candidates(&draft.plan, &mut candidates, &mut rejects);
4036 }
4037 tracing::info!(
4038 joins_inspected = rejects.joins,
4039 not_inner = rejects.not_inner,
4040 side_not_a_shuffle_read = rejects.side_not_a_shuffle_read,
4041 same_stage = rejects.same_stage,
4042 no_row_estimate = rejects.no_row_estimate,
4043 not_selective = rejects.not_selective,
4044 filter_too_large = rejects.filter_too_large,
4045 no_encodable_key = rejects.no_encodable_key,
4046 joins_of_unsupported_kind = rejects.joins_of_unsupported_kind,
4047 candidates = candidates.len(),
4048 injected = 0,
4049 enabled = false,
4050 "runtime-filter: pass complete"
4051 );
4052}
4053
4054/// [`inject_runtime_filters`] without the flag check.
4055///
4056/// Split out so the rewrite and every guard can be tested directly. Reading the
4057/// flag inside the tested function would make the whole rule depend on process
4058/// environment, and `set_var` across parallel test threads is a race, not a
4059/// fixture.
4060fn inject_runtime_filters_unconditionally(
4061 root: &Arc<dyn ExecutionPlan>,
4062 drafts: &mut Vec<StageDraft>,
4063) -> usize {
4064 use crate::runtime_filter_exec::{
4065 RuntimeFilterBuildExec, RuntimeFilterProbeExec, filter_schema,
4066 };
4067 use datafusion::physical_expr::expressions::Column;
4068 use datafusion::physical_plan::projection::ProjectionExec;
4069
4070 let mut candidates = Vec::new();
4071 let mut rejects = RuntimeFilterRejects::default();
4072 collect_runtime_filter_candidates(root, &mut candidates, &mut rejects);
4073 for draft in drafts.iter() {
4074 collect_runtime_filter_candidates(&draft.plan, &mut candidates, &mut rejects);
4075 }
4076
4077 let mut touched: Vec<usize> = Vec::new();
4078 let mut injected = 0usize;
4079 // Why each candidate was turned away.
4080 //
4081 // Every rejection below was previously either `debug!` (invisible at the
4082 // executors' and coordinator's `info` level) or a bare `continue`. So a run
4083 // that found no candidates and a run that rejected all of them produced
4084 // *identical* output: nothing. Turning the flag on for q18 changed the
4085 // wall time by 2.4% and left 0 log lines, and there was no way to tell
4086 // whether the rule had declined or was simply not installed.
4087 //
4088 // That is the exact failure that made `SpillableJoinSelection` cost hours
4089 // of live investigation before its "pass complete" line existed. One
4090 // counted summary, at info, is the whole fix.
4091 let candidate_count = candidates.len();
4092 let (mut already_touched, mut missing_stage, mut severed_subquery) = (0usize, 0usize, 0usize);
4093 let (mut would_cycle, mut no_key_field, mut build_failed, mut probe_failed) =
4094 (0usize, 0usize, 0usize, 0usize);
4095 for candidate in candidates {
4096 // One filter per stage, and never over a stage this pass has already
4097 // rewritten: stacking rewrites would have each filter stage clone the
4098 // previous one's probe node, which is correct but compounds cost for a
4099 // shrinking return.
4100 if touched.contains(&candidate.build_stage) || touched.contains(&candidate.probe_stage) {
4101 already_touched += 1;
4102 continue;
4103 }
4104 let (Some(build), Some(probe)) = (
4105 drafts.get(candidate.build_stage),
4106 drafts.get(candidate.probe_stage),
4107 ) else {
4108 missing_stage += 1;
4109 continue;
4110 };
4111 // A stage severed from a `ScalarSubqueryExec` is parameterised by a
4112 // subquery result; cloning its subtree without the wrapper produces a
4113 // fragment that cannot decode.
4114 if build.subqueries.is_some() || probe.subqueries.is_some() {
4115 severed_subquery += 1;
4116 continue;
4117 }
4118 if stage_depends_on(drafts, candidate.build_stage, candidate.probe_stage) {
4119 would_cycle += 1;
4120 continue;
4121 }
4122
4123 let source = Arc::clone(&build.plan);
4124 let probe_plan = Arc::clone(&probe.plan);
4125 let schema = source.schema();
4126 let Some(field) = schema.fields().get(candidate.build_key_index) else {
4127 no_key_field += 1;
4128 continue;
4129 };
4130 let name = field.name().clone();
4131 // Project to the key column alone before coalescing: the filter stage
4132 // needs one column, and carrying the rest through a single task is
4133 // memory spent to be thrown away.
4134 let projected = ProjectionExec::try_new(
4135 vec![(
4136 Arc::new(Column::new(&name, candidate.build_key_index)) as _,
4137 name.clone(),
4138 )],
4139 source,
4140 );
4141 let filter_plan = projected.and_then(|projected| {
4142 let coalesced = Arc::new(CoalescePartitionsExec::new(Arc::new(projected)));
4143 RuntimeFilterBuildExec::try_new(coalesced, 0, candidate.filter_bytes)
4144 });
4145 let filter_plan = match filter_plan {
4146 Ok(plan) => Arc::new(plan) as Arc<dyn ExecutionPlan>,
4147 Err(error) => {
4148 build_failed += 1;
4149 tracing::debug!(%error, "declined to build a runtime filter stage");
4150 continue;
4151 }
4152 };
4153
4154 let filter_stage = drafts.len();
4155 // A single map task (the coalesce above) writing one keyless partition:
4156 // exactly the gather shape the cutter already emits for ungrouped
4157 // aggregates, so the writer and reader need no special case.
4158 let read = ShuffleReadExec::new(filter_stage, 1, 1, filter_schema(), None);
4159 let rewritten =
4160 RuntimeFilterProbeExec::try_new(probe_plan, Arc::new(read), candidate.probe_key_index);
4161 let rewritten = match rewritten {
4162 Ok(plan) => Arc::new(plan) as Arc<dyn ExecutionPlan>,
4163 Err(error) => {
4164 probe_failed += 1;
4165 tracing::debug!(%error, "declined to apply a runtime filter to the probe stage");
4166 continue;
4167 }
4168 };
4169 let Some(probe_draft) = drafts.get_mut(candidate.probe_stage) else {
4170 missing_stage += 1;
4171 continue;
4172 };
4173 probe_draft.plan = rewritten;
4174 drafts.push(StageDraft {
4175 plan: filter_plan,
4176 shuffle: Some(StageShuffleOutput {
4177 key_columns: Vec::new(),
4178 num_output_partitions: 1,
4179 }),
4180 subqueries: None,
4181 });
4182 touched.push(candidate.build_stage);
4183 touched.push(candidate.probe_stage);
4184 injected += 1;
4185 tracing::info!(
4186 build_stage = candidate.build_stage,
4187 probe_stage = candidate.probe_stage,
4188 filter_stage,
4189 filter_bytes = candidate.filter_bytes,
4190 "injected a cross-stage runtime filter"
4191 );
4192 }
4193 // At info, unconditionally: "no candidates" and "rejected every candidate"
4194 // must never again be indistinguishable from "rule not installed".
4195 tracing::info!(
4196 joins_inspected = rejects.joins,
4197 not_inner = rejects.not_inner,
4198 side_not_a_shuffle_read = rejects.side_not_a_shuffle_read,
4199 same_stage = rejects.same_stage,
4200 no_row_estimate = rejects.no_row_estimate,
4201 not_selective = rejects.not_selective,
4202 filter_too_large = rejects.filter_too_large,
4203 no_encodable_key = rejects.no_encodable_key,
4204 joins_of_unsupported_kind = rejects.joins_of_unsupported_kind,
4205 candidates = candidate_count,
4206 injected,
4207 already_touched,
4208 missing_stage,
4209 severed_subquery,
4210 would_cycle,
4211 no_key_field,
4212 build_failed,
4213 probe_failed,
4214 "runtime-filter: pass complete"
4215 );
4216 injected
4217}
4218
4219fn collect_upstream_stage_indexes(plan: &Arc<dyn ExecutionPlan>) -> Vec<usize> {
4220 let mut indexes = Vec::new();
4221 collect_upstream_inner(plan, &mut indexes);
4222 indexes.sort_unstable();
4223 indexes.dedup();
4224 indexes
4225}
4226
4227fn collect_upstream_inner(plan: &Arc<dyn ExecutionPlan>, out: &mut Vec<usize>) {
4228 if let Some(read) = plan.downcast_ref::<ShuffleReadExec>() {
4229 out.push(read.upstream_stage_index());
4230 }
4231 for child in plan.children() {
4232 collect_upstream_inner(child, out);
4233 }
4234}
4235
4236#[cfg(test)]
4237mod tests {
4238
4239 /// Does ANY node of the plan carry a scalar subquery?
4240 ///
4241 /// `LogicalPlan::expressions()` returns only the expressions of the node it
4242 /// is called on — for `SELECT ... WHERE x > (subquery)` the root is a
4243 /// Projection and the subquery lives in the Filter beneath it. Checking the
4244 /// root alone silently proves nothing, which is what the precondition
4245 /// assertion in these tests exists to catch.
4246 fn plan_has_scalar_subquery(plan: &datafusion::logical_expr::LogicalPlan) -> bool {
4247 use datafusion::common::tree_node::{TreeNode, TreeNodeRecursion};
4248 use datafusion::logical_expr::Expr;
4249 let mut found = false;
4250 let _ = plan.apply(|node| {
4251 if node
4252 .expressions()
4253 .iter()
4254 .any(Expr::contains_scalar_subquery)
4255 {
4256 found = true;
4257 return Ok(TreeNodeRecursion::Stop);
4258 }
4259 Ok(TreeNodeRecursion::Continue)
4260 });
4261 found
4262 }
4263
4264 /// q22: the whole point is that a query carrying an uncorrelated scalar
4265 /// subquery becomes stageable. Before the fold, `ScalarSubqueryExpr` cannot
4266 /// round-trip through `dfplan` encoding, the verify step refuses the
4267 /// fragment, and the caller runs the query as ONE task while reporting a
4268 /// clean pass.
4269 ///
4270 /// Asserts the outcome (stages exist, and the plan no longer carries a
4271 /// scalar subquery) rather than the mechanism, so the test survives a
4272 /// change of folding strategy.
4273 #[tokio::test]
4274 async fn an_uncorrelated_scalar_subquery_is_folded_so_the_query_can_stage() {
4275 let ctx = planning_session_context(4);
4276 ctx.sql(
4277 "CREATE TABLE acct(id BIGINT, bal DOUBLE) AS VALUES (1, 10.0), (2, 30.0), (3, 50.0)",
4278 )
4279 .await
4280 .unwrap()
4281 .collect()
4282 .await
4283 .unwrap();
4284
4285 let sql = "SELECT id FROM acct WHERE bal > (SELECT avg(bal) FROM acct)";
4286 let before = ctx.sql(sql).await.unwrap();
4287 assert!(
4288 plan_has_scalar_subquery(before.logical_plan()),
4289 "precondition: the planned query must actually carry a scalar \
4290 subquery, or this test proves nothing"
4291 );
4292
4293 let after = inline_uncorrelated_scalar_subqueries(&ctx, before)
4294 .await
4295 .unwrap();
4296 assert!(
4297 !plan_has_scalar_subquery(after.logical_plan()),
4298 "the uncorrelated subquery must be folded to a constant"
4299 );
4300
4301 // And the fold must not change the answer: avg is 30.0, so only id=3.
4302 let rows = after.collect().await.unwrap();
4303 let total: usize = rows.iter().map(|b| b.num_rows()).sum();
4304 assert_eq!(
4305 total, 1,
4306 "folding a constant must not change the result set"
4307 );
4308 }
4309
4310 /// A CORRELATED subquery references the outer row, so it is not a constant
4311 /// and must be left exactly as it was. Getting this wrong would produce
4312 /// silently wrong answers, which is far worse than the single-task
4313 /// fallback this fix exists to remove.
4314 #[tokio::test]
4315 async fn a_correlated_scalar_subquery_is_left_alone() {
4316 let ctx = planning_session_context(4);
4317 ctx.sql("CREATE TABLE t(k BIGINT, v DOUBLE) AS VALUES (1, 10.0), (2, 30.0)")
4318 .await
4319 .unwrap()
4320 .collect()
4321 .await
4322 .unwrap();
4323 ctx.sql("CREATE TABLE u(k BIGINT, w DOUBLE) AS VALUES (1, 5.0), (2, 40.0)")
4324 .await
4325 .unwrap()
4326 .collect()
4327 .await
4328 .unwrap();
4329
4330 let sql = "SELECT k FROM t WHERE v > (SELECT max(w) FROM u WHERE u.k = t.k)";
4331 let Ok(before) = ctx.sql(sql).await else {
4332 // Some correlated shapes are decorrelated by the optimizer before
4333 // we ever see them; nothing to assert if this one is rejected.
4334 return;
4335 };
4336 let had_subquery = plan_has_scalar_subquery(before.logical_plan());
4337 let after = inline_uncorrelated_scalar_subqueries(&ctx, before)
4338 .await
4339 .unwrap();
4340 let still_has = plan_has_scalar_subquery(after.logical_plan());
4341 assert_eq!(
4342 had_subquery, still_has,
4343 "a correlated subquery depends on the outer row and must never be \
4344 folded to a constant"
4345 );
4346 }
4347 use datafusion::prelude::SessionConfig;
4348
4349 /// D3(2): the point of carrying the upstream estimate is that a
4350 /// shuffle-fed join side stops reporting `Absent`. This asserts the
4351 /// property the optimizer rules actually key on, not the field value —
4352 /// `SpillableJoinSelection` returns `Ok(None)` on `Absent` by design, so
4353 /// "absent" and "known" is the whole distinction that matters.
4354 #[test]
4355 fn a_shuffle_read_reports_its_upstream_estimate_instead_of_unknown() {
4356 use datafusion::common::stats::Precision;
4357 let schema = Arc::new(arrow::datatypes::Schema::new(vec![
4358 arrow::datatypes::Field::new("a", arrow::datatypes::DataType::Int64, false),
4359 ]));
4360
4361 let unknown = ShuffleReadExec::new(0, 4, 4, Arc::clone(&schema), None);
4362 assert_eq!(
4363 unknown.partition_statistics(None).unwrap().total_byte_size,
4364 Precision::Absent,
4365 "a read with no estimate must stay Absent — inventing a size is how \
4366 a spill decision gets made on a guess"
4367 );
4368
4369 let known = ShuffleReadExec::new(0, 4, 4, Arc::clone(&schema), None)
4370 .with_upstream_estimate(Some(1_000), Some(800_000));
4371 let whole = known.partition_statistics(None).unwrap();
4372 assert_eq!(whole.num_rows, Precision::Inexact(1_000));
4373 assert_eq!(
4374 whole.total_byte_size,
4375 Precision::Inexact(800_000),
4376 "the whole-plan question gets the whole stage's size"
4377 );
4378
4379 let one = known.partition_statistics(Some(0)).unwrap();
4380 assert_eq!(
4381 one.total_byte_size,
4382 Precision::Inexact(200_000),
4383 "a per-partition question gets the even-split share of 4 partitions"
4384 );
4385 }
4386
4387 /// The estimate has to survive the wire, or the executor runs a plan whose
4388 /// sizes disagree with the plan the coordinator optimized.
4389 #[test]
4390 fn the_upstream_estimate_survives_encode_decode() {
4391 let schema = Arc::new(arrow::datatypes::Schema::new(vec![
4392 arrow::datatypes::Field::new("a", arrow::datatypes::DataType::Int64, false),
4393 ]));
4394 let node: Arc<dyn ExecutionPlan> = Arc::new(
4395 ShuffleReadExec::new(3, 2, 4, schema, None)
4396 .with_upstream_estimate(Some(77), Some(4_096)),
4397 );
4398 let codec = KrishivPhysicalCodec::coordinator();
4399 let mut buf = Vec::new();
4400 codec.try_encode(Arc::clone(&node), &mut buf).unwrap();
4401
4402 let ctx = crate::SqlEngine::new_with_engine_memory(crate::EngineMemory::Unbounded);
4403 let task_ctx = ctx.session_context().task_ctx();
4404 let decoded = codec.try_decode(&buf, &[], &task_ctx).unwrap();
4405
4406 // Re-encode and compare bytes rather than downcasting: it asserts the
4407 // same property (the estimate made the trip intact) and it also catches
4408 // a field that decodes but is dropped on the way back out.
4409 let mut round_tripped = Vec::new();
4410 codec.try_encode(decoded, &mut round_tripped).unwrap();
4411 assert_eq!(
4412 String::from_utf8(round_tripped).unwrap(),
4413 String::from_utf8(buf).unwrap(),
4414 "the upstream estimate must survive encode -> decode -> encode"
4415 );
4416 }
4417 use super::*;
4418 use arrow::record_batch::RecordBatch;
4419 use datafusion::physical_plan::displayable;
4420 use datafusion_proto::physical_plan::DefaultPhysicalExtensionCodec;
4421 use std::collections::HashMap;
4422 use std::sync::Mutex;
4423
4424 /// The stage builder plans on a throwaway context, so an `s3://` table must
4425 /// resolve to an object store there. When it did not, `register_parquet`
4426 /// errored, the caller read that as "decline to stage", and the whole
4427 /// dataset was scanned by a single executor — correct results, silently
4428 /// zero distribution.
4429 ///
4430 /// The property under test is that the bucket resolves, and that an
4431 /// explicit registration takes precedence over the lazy fallback (explicit
4432 /// registration is what carries endpoint and credential configuration).
4433 /// This test previously opened by asserting a fresh context could *not*
4434 /// resolve the bucket; installing `LazyCloudObjectStoreRegistry` on the
4435 /// planning context made that precondition false, so the assertion, not
4436 /// the behavior, was wrong.
4437 ///
4438 /// The round-trip guard must reject bytes that do not decode — feeding it
4439 /// garbage proves it inspects them rather than returning Ok
4440 /// unconditionally.
4441 #[test]
4442 fn the_roundtrip_guard_rejects_bytes_that_do_not_decode() {
4443 let codec = DefaultPhysicalExtensionCodec {};
4444 let err = verify_dfplan_roundtrip(
4445 b"not a physical plan proto",
4446 &codec,
4447 &fragment_decode_session_context().task_ctx(),
4448 None,
4449 )
4450 .expect_err("undecodable bytes must be rejected");
4451 assert!(format!("{err}").contains("decode"), "got: {err}");
4452 }
4453
4454 /// The regression that got the first guard reverted: it verified against a
4455 /// bare context with no object-store registry, so every s3-scanning
4456 /// fragment failed the check and silently fell back to single-task (q1:
4457 /// 13 tasks -> 1 task, 156 s -> 595 s). The verify context must resolve
4458 /// object stores exactly like the executor's runtime — this asserts the
4459 /// capability delta that broke, without needing a network round trip
4460 /// (constructing a lazy store does not contact the endpoint).
4461 #[test]
4462 fn the_verify_context_resolves_object_stores_like_the_executor() {
4463 use datafusion::execution::object_store::ObjectStoreUrl;
4464 let url = ObjectStoreUrl::parse("s3://roundtrip-bucket").expect("url");
4465
4466 // A bare context cannot resolve the bucket — the first guard's bug.
4467 assert!(
4468 SessionContext::new()
4469 .runtime_env()
4470 .object_store(url.clone())
4471 .is_err(),
4472 "precondition: a bare context must NOT resolve s3, or this test proves nothing"
4473 );
4474
4475 // The context the guard actually uses must.
4476 planning_session_context(1)
4477 .task_ctx()
4478 .runtime_env()
4479 .object_store(url)
4480 .expect("the verify context must resolve s3 buckets like the executor runtime");
4481 }
4482
4483 /// Logical and physical optimizer rule names installed on a session.
4484 fn optimizer_rule_names(ctx: &SessionContext) -> (Vec<String>, Vec<String>) {
4485 let state = ctx.state();
4486 (
4487 state
4488 .optimizers()
4489 .iter()
4490 .map(|r| r.name().to_owned())
4491 .collect(),
4492 state
4493 .physical_optimizers()
4494 .iter()
4495 .map(|r| r.name().to_owned())
4496 .collect(),
4497 )
4498 }
4499
4500 /// E4 (review 2026-07-27): the staged planner must carry the same optimizer
4501 /// rules as the engine, or every rule the engine installs is dead on the
4502 /// distributed path.
4503 ///
4504 /// This is the whole of finding A6 in one assertion, and it currently
4505 /// FAILS: `planning_session_context` is a bare `SessionContext`, so it
4506 /// carries none of `CooperativeAmplifiers` (distributed cancel cannot
4507 /// preempt an amplifying operator without it), `SpillableJoinSelection`
4508 /// (q18's shipped fix), `SemiJoinReductionThroughAggregate` or
4509 /// `SemiJoinPushdownThroughInnerJoin` (q17's shipped fix — 88 % of a 252 s
4510 /// query). Two shipped performance fixes do not apply to the path being
4511 /// benchmarked, and nothing said so.
4512 ///
4513 /// Left executable-but-ignored deliberately: the fix is A6 (Batch 3, plan
4514 /// the staged query on `SqlEngine`'s own `SessionStateBuilder`), and this
4515 /// documents the gap in a form that turns green the moment it lands rather
4516 /// than in prose that can rot.
4517 #[test]
4518 fn the_staging_context_carries_the_engines_optimizer_rules() {
4519 let engine = crate::SqlEngine::new_with_engine_memory(crate::EngineMemory::Unbounded);
4520 let (engine_logical, engine_physical) = optimizer_rule_names(engine.session_context());
4521 let staging = planning_session_context(engine.target_parallelism().get());
4522 let (staging_logical, staging_physical) = optimizer_rule_names(&staging);
4523
4524 assert_eq!(
4525 engine_logical, staging_logical,
4526 "the staged planner must run the engine's logical optimizer rules; \
4527 missing here means SemiJoinReductionThroughAggregate / \
4528 SemiJoinPushdownThroughInnerJoin never fire distributed (D4)"
4529 );
4530 assert_eq!(
4531 engine_physical, staging_physical,
4532 "the staged planner must run the engine's physical optimizer rules; \
4533 missing here means SpillableJoinSelection (D3) and \
4534 CooperativeAmplifiers (distributed cancel) never fire"
4535 );
4536
4537 // The config half of A6: the four runtime-filter switches and the
4538 // lambda-capable dialect are what make `KRISHIV_RUNTIME_FILTERS` mean
4539 // anything distributed, and what let a Phase-60 lambda query stage at
4540 // all instead of silently degrading to one task.
4541 let engine_opts = engine.session_context().copied_config();
4542 let staging_opts = staging.copied_config();
4543 for option in [
4544 "datafusion.optimizer.enable_dynamic_filter_pushdown",
4545 "datafusion.optimizer.enable_join_dynamic_filter_pushdown",
4546 "datafusion.optimizer.enable_topk_dynamic_filter_pushdown",
4547 "datafusion.optimizer.enable_aggregate_dynamic_filter_pushdown",
4548 ] {
4549 assert_eq!(
4550 engine_opts
4551 .options()
4552 .entries()
4553 .iter()
4554 .find(|e| e.key == option)
4555 .map(|e| e.value.clone()),
4556 staging_opts
4557 .options()
4558 .entries()
4559 .iter()
4560 .find(|e| e.key == option)
4561 .map(|e| e.value.clone()),
4562 "{option} must match the engine's setting on the staged planner"
4563 );
4564 }
4565 assert_eq!(
4566 engine_opts.options().sql_parser.dialect,
4567 staging_opts.options().sql_parser.dialect,
4568 "the staged planner must parse in the engine's dialect"
4569 );
4570 assert_eq!(
4571 engine_opts.options().execution.batch_size,
4572 staging_opts.options().execution.batch_size,
4573 "the staged planner must use the engine's batch size"
4574 );
4575 }
4576
4577 /// A5: the guard rehearses the decode against the wrong session.
4578 ///
4579 /// The executor decodes a fragment on `task_sql_engine`, a real
4580 /// `SqlEngine` carrying Krishiv's registered UDFs. The guard decoded on
4581 /// `planning_session_context`, a bare `SessionContext` that carries none
4582 /// of them — so a fragment referencing `get_json_object` (a Phase-60 front
4583 /// door function, always available on the engine) fails the guard, the
4584 /// caller reads that as "decline to stage", and the query silently runs as
4585 /// a single task on one executor.
4586 ///
4587 /// The plan is built on the engine and decoded through the guard, which is
4588 /// exactly the asymmetry: encode-side capability the verify side lacks.
4589 #[tokio::test]
4590 async fn the_roundtrip_guard_accepts_a_fragment_using_an_engine_udf() {
4591 let engine = crate::SqlEngine::new_with_engine_memory(crate::EngineMemory::Unbounded);
4592 let ctx = engine.session_context();
4593 ctx.sql("CREATE TABLE docs AS VALUES ('{\"a\":1}'), ('{\"a\":2}')")
4594 .await
4595 .unwrap()
4596 .collect()
4597 .await
4598 .unwrap();
4599 // The argument must be a column: a literal one is const-folded away and
4600 // the encoded plan then carries no UDF reference at all.
4601 let plan = ctx
4602 .sql("SELECT get_json_object(column1, '$.a') AS a FROM docs")
4603 .await
4604 .unwrap()
4605 .create_physical_plan()
4606 .await
4607 .unwrap();
4608 let codec = DefaultPhysicalExtensionCodec {};
4609 let bytes = encode_dfplan_bytes(plan, &codec).expect("encode");
4610
4611 // Precondition: the bare planning context genuinely cannot decode it,
4612 // or this test proves nothing about which context the guard uses.
4613 let bare = planning_session_context(1).task_ctx();
4614 assert!(
4615 datafusion_proto::bytes::physical_plan_from_bytes_with_extension_codec(
4616 &bytes, &bare, &codec
4617 )
4618 .is_err(),
4619 "precondition: a bare planning context must NOT resolve engine UDFs"
4620 );
4621
4622 verify_dfplan_roundtrip(
4623 &bytes,
4624 &codec,
4625 &fragment_decode_session_context().task_ctx(),
4626 None,
4627 )
4628 .expect(
4629 "the guard must decode on the engine the executor uses; failing here \
4630 silently degrades the query to a single task",
4631 );
4632 }
4633
4634 /// And it must not reject ordinary plans, or every query silently loses
4635 /// distribution — the worse failure of the two.
4636 #[tokio::test]
4637 async fn the_roundtrip_guard_accepts_an_ordinary_plan() {
4638 let ctx = SessionContext::new();
4639 ctx.sql("CREATE TABLE t AS VALUES (1, 'a'), (2, 'b')")
4640 .await
4641 .unwrap()
4642 .collect()
4643 .await
4644 .unwrap();
4645 let plan = ctx
4646 .sql("SELECT column1 FROM t WHERE column1 > 1")
4647 .await
4648 .unwrap()
4649 .create_physical_plan()
4650 .await
4651 .unwrap();
4652 let codec = DefaultPhysicalExtensionCodec {};
4653 let bytes = encode_dfplan_bytes(plan, &codec).expect("encode");
4654 verify_dfplan_roundtrip(
4655 &bytes,
4656 &codec,
4657 &fragment_decode_session_context().task_ctx(),
4658 None,
4659 )
4660 .expect("ordinary plans must pass");
4661 }
4662
4663 #[tokio::test]
4664 async fn s3_paths_resolve_on_the_planning_context_and_explicit_registration_wins() {
4665 // No environment setup: `build_s3_object_store` defaults the region and
4666 // constructing a store does not contact the endpoint, so this stays a
4667 // pure unit test rather than one that mutates process-wide env.
4668 use datafusion::execution::object_store::ObjectStoreUrl;
4669 let ctx = planning_session_context(4);
4670 let url = ObjectStoreUrl::parse("s3://tpch-bucket").expect("bucket url");
4671
4672 let lazily_built = ctx
4673 .runtime_env()
4674 .object_store(url.clone())
4675 .expect("the planning context must resolve an s3 bucket on demand");
4676
4677 register_object_store_for_path(&ctx, "s3://tpch-bucket/tpch/sf100/lineitem/")
4678 .expect("registering an s3 path must succeed");
4679
4680 let explicit = ctx
4681 .runtime_env()
4682 .object_store(url)
4683 .expect("after registration the planning context must resolve the bucket");
4684 assert!(
4685 !Arc::ptr_eq(&lazily_built, &explicit),
4686 "explicit registration must replace the lazily-constructed store, \
4687 or configured endpoints and credentials would be ignored"
4688 );
4689 }
4690
4691 /// Local paths must not be routed through the S3 builder — it reads
4692 /// credentials from the environment and would fail on a machine that has
4693 /// none, turning every ordinary filesystem-backed staged job into a
4694 /// single-task job.
4695 #[tokio::test]
4696 async fn local_paths_are_left_alone_by_object_store_registration() {
4697 let ctx = planning_session_context(4);
4698 register_object_store_for_path(&ctx, "/home/krishiv-bench-data/tpch/sf1/lineitem.parquet")
4699 .expect("a local path must be a no-op, not an error");
4700 register_object_store_for_path(&ctx, "relative/dir")
4701 .expect("a relative local path must be a no-op, not an error");
4702 }
4703
4704 /// Write a 4-file parquet dataset (1000 rows total) and return the
4705 /// directory path (registered as a multi-file table so scans genuinely
4706 /// have multiple partitions, like real distributed inputs).
4707 async fn write_test_parquet(dir: &std::path::Path) -> std::path::PathBuf {
4708 use arrow::array::{Int64Array, StringArray};
4709 use arrow::datatypes::{DataType, Field, Schema};
4710
4711 let schema = Arc::new(Schema::new(vec![
4712 Field::new("id", DataType::Int64, false),
4713 Field::new("category", DataType::Utf8, false),
4714 Field::new("amount", DataType::Int64, false),
4715 ]));
4716 let table_dir = dir.join("t");
4717 std::fs::create_dir_all(&table_dir).expect("table dir");
4718 for file_index in 0..4i64 {
4719 let ids: Vec<i64> = (0..250).map(|i| file_index * 250 + i).collect();
4720 let batch = RecordBatch::try_new(
4721 schema.clone(),
4722 vec![
4723 Arc::new(Int64Array::from(ids.clone())),
4724 Arc::new(StringArray::from(
4725 ids.iter()
4726 .map(|i| match i % 3 {
4727 0 => "red",
4728 1 => "green",
4729 _ => "blue",
4730 })
4731 .collect::<Vec<_>>(),
4732 )),
4733 Arc::new(Int64Array::from(
4734 ids.iter().map(|i| i * 3).collect::<Vec<_>>(),
4735 )),
4736 ],
4737 )
4738 .expect("test batch");
4739 let path = table_dir.join(format!("part-{file_index}.parquet"));
4740 let file = std::fs::File::create(&path).expect("create parquet");
4741 let mut writer =
4742 datafusion::parquet::arrow::ArrowWriter::try_new(file, schema.clone(), None)
4743 .expect("writer init");
4744 writer.write(&batch).expect("write batch");
4745 writer.close().expect("close writer");
4746 }
4747 table_dir
4748 }
4749
4750 /// A declared primary key must actually shrink the GROUP BY.
4751 ///
4752 /// This is the whole point of `ParquetTableSpec::with_primary_key`:
4753 /// DataFusion's `optimize_projections` already calls
4754 /// `get_required_group_by_exprs_indices` to reduce a GROUP BY to the
4755 /// minimal functionally-equivalent subset, but it can only do so when the
4756 /// table declares a key. Without the declaration the rule is live and
4757 /// inert.
4758 ///
4759 /// Measured stakes (TPC-H q10, SF100, 2026-07-31): grouping by seven
4760 /// customer columns instead of the one key costs **14.8x** end to end —
4761 /// 1784.6 s versus 120.9 s — because the six determined columns ride
4762 /// through every join and shuffle.
4763 #[tokio::test]
4764 async fn a_declared_primary_key_shrinks_the_group_by() {
4765 let tmp = tempfile::tempdir().expect("tempdir");
4766 let table_dir = write_test_parquet(tmp.path()).await;
4767 let path = table_dir.to_string_lossy().to_string();
4768 // `category` and `amount` are determined by `id`, so grouping by all
4769 // three is equivalent to grouping by `id`.
4770 //
4771 // The two dependent columns are deliberately NOT selected. DataFusion's
4772 // rule keeps `(what the parent requires) ∪ (minimal FD subset)`, so a
4773 // column the output still needs stays in the GROUP BY no matter what
4774 // the key says. Selecting them would test nothing.
4775 let sql = "SELECT id, count(*) AS n FROM t GROUP BY id, category, amount";
4776
4777 let plan_text = |spec: ParquetTableSpec| async move {
4778 let ctx = planning_session_context(4);
4779 register_parquet_table(&ctx, &spec)
4780 .await
4781 .expect("register table");
4782 let df = ctx.sql(sql).await.expect("plan sql");
4783 // The OPTIMIZED plan: `optimize_projections` is where
4784 // `get_required_group_by_exprs_indices` runs, so the unoptimized
4785 // plan always shows the full GROUP BY and proves nothing.
4786 let optimized = df.into_optimized_plan().expect("optimize");
4787 format!("{}", optimized.display_indent())
4788 };
4789
4790 let without = plan_text(ParquetTableSpec::new("t", &path)).await;
4791 let with = plan_text(ParquetTableSpec::new("t", &path).with_primary_key(["id"])).await;
4792
4793 // With the key declared, the aggregate groups by `id` alone.
4794 let group_line = |text: &str| {
4795 text.lines()
4796 .find(|line| line.contains("Aggregate:"))
4797 .unwrap_or("<no Aggregate>")
4798 .to_owned()
4799 };
4800 let (g_without, g_with) = (group_line(&without), group_line(&with));
4801 assert_ne!(
4802 g_without, g_with,
4803 "declaring a primary key changed nothing about the aggregate; \
4804 the constraint is not reaching DataFusion's functional-dependency \
4805 machinery.\n without: {g_without}\n with: {g_with}"
4806 );
4807 assert!(
4808 g_with.len() < g_without.len(),
4809 "the declared key should SHRINK the grouping list, not grow it.\n\
4810 without: {g_without}\n with: {g_with}"
4811 );
4812 }
4813
4814 /// A key naming a column the table does not have is an error, not a
4815 /// silently ignored declaration — a typo would otherwise present as "the
4816 /// optimization mysteriously never applies".
4817 #[tokio::test]
4818 async fn an_unknown_primary_key_column_is_rejected() {
4819 let tmp = tempfile::tempdir().expect("tempdir");
4820 let table_dir = write_test_parquet(tmp.path()).await;
4821 let ctx = planning_session_context(4);
4822 let spec = ParquetTableSpec::new("t", table_dir.to_string_lossy().as_ref())
4823 .with_primary_key(["nonexistent_column"]);
4824 let error = register_parquet_table(&ctx, &spec)
4825 .await
4826 .expect_err("an unknown key column must be rejected");
4827 let message = error.to_string();
4828 assert!(
4829 message.contains("nonexistent_column") && message.contains("not in table"),
4830 "the error must name the offending column and the table: {message}"
4831 );
4832 }
4833
4834 /// ADR-0003 risk gate: a scan→filter→hash-aggregate plan round-trips
4835 /// An ungrouped aggregate must split into stages.
4836 ///
4837 /// `SELECT sum(x) FROM t` plans as Final(gather(Partial(scan))) — there is
4838 /// no hash exchange anywhere, because there are no grouping keys to hash
4839 /// on. A cutter that only recognised `RepartitionExec` therefore declined
4840 /// the entire query class and ran it as one task: TPC-H q6 at SF100 took
4841 /// 518 s on a 3-node cluster with two nodes idle. The work is
4842 /// embarrassingly parallel — partial aggregates per file group, combined
4843 /// once — so declining was a pure loss.
4844 ///
4845 /// Asserting on stage COUNT is what makes this a regression test: a plan
4846 /// that merely round-trips proves nothing about distribution.
4847 #[test]
4848 fn target_partitions_scale_with_the_cluster_not_a_constant() {
4849 // The defect: this was 4 regardless of the cluster, so a large cluster
4850 // sat mostly idle and a small one queued work behind itself.
4851 let two_slots = ClusterCapacity { total_slots: 2 };
4852 let thirty_two = ClusterCapacity { total_slots: 32 };
4853 let small = derive_stage_target_partitions(None, Some(two_slots), 8);
4854 let large = derive_stage_target_partitions(None, Some(thirty_two), 8);
4855 assert!(
4856 large > small,
4857 "a 16x larger cluster planned {large} vs {small} partitions"
4858 );
4859 assert_eq!(large, 32 * TASKS_PER_SLOT);
4860 }
4861
4862 #[test]
4863 fn multiple_waves_per_slot_leave_room_to_absorb_stragglers() {
4864 // One task per slot makes a stage as slow as its slowest task. More
4865 // tasks than slots lets a fast slot take a second while a slow one is
4866 // still on its first.
4867 let cluster = ClusterCapacity { total_slots: 8 };
4868 assert!(
4869 derive_stage_target_partitions(None, Some(cluster), 8) > cluster.total_slots,
4870 "a stage should plan more tasks than slots, not exactly one wave"
4871 );
4872 }
4873
4874 #[test]
4875 fn an_explicit_setting_overrides_the_derivation() {
4876 let cluster = ClusterCapacity { total_slots: 64 };
4877 assert_eq!(derive_stage_target_partitions(Some(6), Some(cluster), 8), 6);
4878 // ...but a value that would defeat stage splitting entirely does not:
4879 // below 2 partitions there is no exchange to cut.
4880 assert!(derive_stage_target_partitions(Some(1), Some(cluster), 8) >= MIN_STAGE_PARTITIONS);
4881 assert!(derive_stage_target_partitions(Some(0), Some(cluster), 8) >= MIN_STAGE_PARTITIONS);
4882 }
4883
4884 #[test]
4885 fn no_cluster_view_falls_back_to_the_local_machine() {
4886 // The embedded runtime and any caller without a coordinator.
4887 assert_eq!(
4888 derive_stage_target_partitions(None, None, 6),
4889 6 * TASKS_PER_SLOT
4890 );
4891 }
4892
4893 #[test]
4894 fn partition_counts_stay_inside_the_shuffle_fragment_budget() {
4895 // Shuffle fragments grow as partitions², so an enormous cluster must
4896 // not translate into an unbounded fragment count.
4897 let huge = ClusterCapacity {
4898 total_slots: usize::MAX,
4899 };
4900 assert_eq!(
4901 derive_stage_target_partitions(None, Some(huge), 8),
4902 MAX_STAGE_PARTITIONS
4903 );
4904 // A single-slot cluster still gets a splittable plan.
4905 let one = ClusterCapacity { total_slots: 1 };
4906 assert!(derive_stage_target_partitions(None, Some(one), 1) >= MIN_STAGE_PARTITIONS);
4907 }
4908
4909 #[tokio::test]
4910 async fn ungrouped_aggregate_splits_into_map_and_reduce_stages() {
4911 let tmp = tempfile::tempdir().expect("tempdir");
4912 let path = write_test_parquet(tmp.path()).await;
4913 let tables = vec![(
4914 String::from("t"),
4915 path.to_str().expect("utf8 path").to_owned(),
4916 )];
4917
4918 let staged = build_stages_for_parquet_query(
4919 "SELECT SUM(amount) AS total, COUNT(*) AS n FROM t WHERE id >= 100",
4920 &tables,
4921 Some(ClusterCapacity { total_slots: 4 }),
4922 )
4923 .await
4924 .expect("planning must not error")
4925 .expect("an ungrouped aggregate must be stage-split, not declined");
4926
4927 assert!(
4928 staged.stages.len() >= 2,
4929 "expected a map stage and a reduce stage, got {} stage(s) — \
4930 the gather was not cut, so the whole scan runs in one task",
4931 staged.stages.len()
4932 );
4933
4934 // The map stage gathers to exactly one reduce partition, and carries no
4935 // hash key: every row goes to partition 0, which is what a gather means.
4936 let map = &staged.stages[0];
4937 let shuffle = map
4938 .shuffle
4939 .as_ref()
4940 .expect("the map stage must write a shuffle output");
4941 assert_eq!(
4942 shuffle.num_output_partitions, 1,
4943 "a gather must produce exactly one reduce partition"
4944 );
4945 assert!(
4946 shuffle.key_columns.is_empty(),
4947 "a gather has no partitioning key; got {:?}",
4948 shuffle.key_columns
4949 );
4950 }
4951
4952 /// A grouped aggregate keeps cutting at the hash exchange, with real hash
4953 /// keys — the gather cut must not have swallowed that path.
4954 #[tokio::test]
4955 async fn grouped_aggregate_still_cuts_at_the_hash_exchange() {
4956 let tmp = tempfile::tempdir().expect("tempdir");
4957 let path = write_test_parquet(tmp.path()).await;
4958 let tables = vec![(
4959 String::from("t"),
4960 path.to_str().expect("utf8 path").to_owned(),
4961 )];
4962
4963 let staged = build_stages_for_parquet_query(
4964 "SELECT category, SUM(amount) AS total FROM t GROUP BY category",
4965 &tables,
4966 Some(ClusterCapacity { total_slots: 4 }),
4967 )
4968 .await
4969 .expect("planning must not error")
4970 .expect("a grouped aggregate must be stage-split");
4971
4972 let map = &staged.stages[0];
4973 let shuffle = map.shuffle.as_ref().expect("map stage writes a shuffle");
4974 assert_eq!(
4975 shuffle.key_columns,
4976 vec![String::from("category")],
4977 "a grouped aggregate must shuffle on its grouping key"
4978 );
4979 }
4980
4981 /// through datafusion-proto on the pinned DataFusion and executes
4982 /// identically from a fresh context.
4983 #[tokio::test]
4984 async fn aggregate_plan_round_trips_through_proto() {
4985 let tmp = tempfile::tempdir().expect("tempdir");
4986 let path = write_test_parquet(tmp.path()).await;
4987
4988 let ctx = SessionContext::new();
4989 ctx.register_parquet(
4990 "t",
4991 path.to_str().expect("utf8 path"),
4992 datafusion::prelude::ParquetReadOptions::default(),
4993 )
4994 .await
4995 .expect("register parquet");
4996 let df = ctx
4997 .sql("SELECT category, COUNT(*) AS n, SUM(amount) AS total FROM t WHERE id >= 100 GROUP BY category")
4998 .await
4999 .expect("sql");
5000 let plan = df.create_physical_plan().await.expect("physical plan");
5001 let original_display = displayable(plan.as_ref()).indent(true).to_string();
5002
5003 let codec = DefaultPhysicalExtensionCodec {};
5004 let bytes = encode_dfplan_bytes(Arc::clone(&plan), &codec).expect("encode");
5005 let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
5006 let body = dfplan_task_body(&b64, 0);
5007 assert!(is_dfplan_body(&body));
5008
5009 // Decode on a FRESH context with no tables registered — the executor
5010 // side never re-registers coordinator tables.
5011 let exec_ctx = SessionContext::new();
5012 let (spec, decoded) =
5013 decode_dfplan_task(&body, &exec_ctx.task_ctx(), &codec).expect("decode");
5014 assert_eq!(spec, DfplanTaskSpec::single(0));
5015 assert_eq!(
5016 original_display,
5017 displayable(decoded.as_ref()).indent(true).to_string(),
5018 "decoded plan display must match original"
5019 );
5020
5021 let task_ctx = exec_ctx.task_ctx();
5022 let mut results = Vec::new();
5023 for partition in 0..decoded.output_partitioning().partition_count() {
5024 let stream = decoded
5025 .execute(partition, Arc::clone(&task_ctx))
5026 .expect("execute decoded partition");
5027 let batches: Vec<_> = futures::TryStreamExt::try_collect(stream)
5028 .await
5029 .expect("collect decoded stream");
5030 results.extend(batches);
5031 }
5032 let total_rows: usize = results.iter().map(|b| b.num_rows()).sum();
5033 assert_eq!(total_rows, 3, "three category groups expected");
5034 }
5035
5036 #[test]
5037 fn non_dfplan_body_is_rejected() {
5038 let err = parse_dfplan_body("sql: SELECT 1").unwrap_err();
5039 assert!(err.to_string().contains("not a dfplan:v1: fragment"));
5040 }
5041
5042 /// The shuffle seam names the producer when the two sides disagree.
5043 ///
5044 /// Without this check the mismatch flows on and dies in whichever
5045 /// downstream operator first builds a batch against the plan's schema —
5046 /// q17's bare `expected Decimal128(15, 2) but found Decimal128(30, 15)`,
5047 /// which identifies no stage, no partition and no map task.
5048 #[test]
5049 fn a_shuffle_batch_that_contradicts_the_declared_schema_is_named_not_passed_on() {
5050 use arrow::array::{Int64Array, StringViewArray};
5051 use arrow::datatypes::{DataType, Field, Schema};
5052
5053 // q19's exact disagreement: the plan declares the revenue decimal, the
5054 // batch carries a `Utf8View` string column (Parquet reads produce view
5055 // types by default in DataFusion 54).
5056 let declared: SchemaRef = Arc::new(Schema::new(vec![Field::new(
5057 "revenue",
5058 DataType::Decimal128(15, 2),
5059 false,
5060 )]));
5061 let batch = RecordBatch::try_new(
5062 Arc::new(Schema::new(vec![Field::new(
5063 "p_brand",
5064 DataType::Utf8View,
5065 false,
5066 )])),
5067 vec![Arc::new(StringViewArray::from(vec!["Brand#23"]))],
5068 )
5069 .expect("utf8view batch");
5070
5071 let error = check_shuffle_batch_schema(&declared, batch, 3, 7, 5)
5072 .expect_err("a contradicting batch must not be passed on");
5073 let text = error.to_string();
5074 for expected in ["stage 3", "map 7", "partition 5", "revenue", "p_brand"] {
5075 assert!(
5076 text.contains(expected),
5077 "error must name {expected}, got: {text}"
5078 );
5079 }
5080
5081 // Arity disagreement is reported too, and separately.
5082 let two_col = RecordBatch::try_new(
5083 Arc::new(Schema::new(vec![
5084 Field::new("a", DataType::Int64, false),
5085 Field::new("b", DataType::Int64, false),
5086 ])),
5087 vec![
5088 Arc::new(Int64Array::from(vec![1])),
5089 Arc::new(Int64Array::from(vec![2])),
5090 ],
5091 )
5092 .expect("two column batch");
5093 let error = check_shuffle_batch_schema(&declared, two_col, 0, 0, 0)
5094 .expect_err("column-count disagreement must not be passed on");
5095 assert!(
5096 error
5097 .to_string()
5098 .contains("2 columns but the plan declares 1"),
5099 "got: {error}"
5100 );
5101 }
5102
5103 /// The check must not reject a batch that merely carries different field
5104 /// metadata or nullability — those differ harmlessly across a Parquet read
5105 /// and an IPC round trip, and rejecting them would fail correct queries.
5106 #[test]
5107 fn matching_column_types_pass_even_when_metadata_and_nullability_differ() {
5108 use arrow::array::Int64Array;
5109 use arrow::datatypes::{DataType, Field, Schema};
5110
5111 let declared: SchemaRef = Arc::new(Schema::new(vec![
5112 Field::new("n", DataType::Int64, false).with_metadata(
5113 [(String::from("origin"), String::from("coordinator"))]
5114 .into_iter()
5115 .collect(),
5116 ),
5117 ]));
5118 let batch = RecordBatch::try_new(
5119 Arc::new(Schema::new(vec![Field::new("n", DataType::Int64, true)])),
5120 vec![Arc::new(Int64Array::from(vec![1, 2, 3]))],
5121 )
5122 .expect("batch");
5123
5124 check_shuffle_batch_schema(&declared, batch, 0, 0, 0)
5125 .expect("metadata and nullability differences must not fail the query");
5126 }
5127
5128 /// In-memory [`ShufflePartitionReader`] + writer used to execute a
5129 /// stage plan end-to-end in tests (the executor's store stands in).
5130 #[derive(Debug, Default)]
5131 struct TestShuffleStore {
5132 partitions: Mutex<HashMap<(usize, usize, usize), Vec<RecordBatch>>>,
5133 }
5134
5135 impl TestShuffleStore {
5136 fn write(&self, stage: usize, map_task: usize, partition: usize, batch: RecordBatch) {
5137 self.partitions
5138 .lock()
5139 .expect("store lock")
5140 .entry((stage, map_task, partition))
5141 .or_default()
5142 .push(batch);
5143 }
5144 }
5145
5146 impl ShufflePartitionReader for Arc<TestShuffleStore> {
5147 fn open_partition(
5148 &self,
5149 upstream_stage_index: usize,
5150 map_task_index: usize,
5151 partition: usize,
5152 ) -> futures::future::BoxFuture<'static, Result<ShuffleFragmentStream, String>> {
5153 let batches = self
5154 .partitions
5155 .lock()
5156 .expect("store lock")
5157 .get(&(upstream_stage_index, map_task_index, partition))
5158 .cloned()
5159 .unwrap_or_default();
5160 Box::pin(async move {
5161 Ok(Box::pin(futures::stream::iter(batches.into_iter().map(Ok)))
5162 as ShuffleFragmentStream)
5163 })
5164 }
5165 }
5166
5167 /// A reader that models the shuffle server's `serve_permits`: a permit is
5168 /// taken before the fragment is served and released only when the response
5169 /// stream is fully consumed.
5170 ///
5171 /// `reverse_open_order` makes the *last* map task acquire first and the
5172 /// first acquire last. Without it the deadlock is not reproducible, because
5173 /// `buffered` polls the futures in order, so map task 0 wins the permit race
5174 /// by accident and the whole read drains sequentially. The production race is
5175 /// decided by network timing, not poll order, so the ordering must be forced
5176 /// to test the invariant rather than the scheduler's luck.
5177 #[derive(Debug)]
5178 struct ServeLimitedReader {
5179 inner: Arc<TestShuffleStore>,
5180 permits: Arc<tokio::sync::Semaphore>,
5181 reverse_open_order: bool,
5182 map_tasks: usize,
5183 }
5184
5185 impl ShufflePartitionReader for ServeLimitedReader {
5186 fn open_partition(
5187 &self,
5188 stage: usize,
5189 map_task: usize,
5190 partition: usize,
5191 ) -> futures::future::BoxFuture<'static, Result<ShuffleFragmentStream, String>> {
5192 let batches = self
5193 .inner
5194 .partitions
5195 .lock()
5196 .expect("store lock")
5197 .get(&(stage, map_task, partition))
5198 .cloned()
5199 .unwrap_or_default();
5200 let permits = Arc::clone(&self.permits);
5201 let delay = if self.reverse_open_order {
5202 // Later map tasks reach the semaphore first.
5203 20 * (self.map_tasks.saturating_sub(map_task)) as u64
5204 } else {
5205 0
5206 };
5207 Box::pin(async move {
5208 if delay > 0 {
5209 tokio::time::sleep(std::time::Duration::from_millis(delay)).await;
5210 }
5211 let permit = permits
5212 .acquire_owned()
5213 .await
5214 .map_err(|_| String::from("serve semaphore closed"))?;
5215 // The permit rides along with the stream, exactly as
5216 // `PermitHoldingStream` does on the server.
5217 let held = futures::stream::iter(batches.into_iter().map(Ok)).chain(
5218 futures::stream::unfold(Some(permit), |permit| async move {
5219 // Releasing the permit only when the stream is fully
5220 // consumed is the whole point: that is what the server's
5221 // `PermitHoldingStream` does.
5222 drop(permit?);
5223 None
5224 }),
5225 );
5226 Ok(Box::pin(held) as ShuffleFragmentStream)
5227 })
5228 }
5229 }
5230
5231 /// A reduce read must complete when the producer serves fewer concurrent
5232 /// responses than the reduce side has fragments to read.
5233 ///
5234 /// This pins the second deadlock found on 2026-07-30, and it is a *cluster*
5235 /// hang rather than a slow query: `ShuffleFlightService::do_get` holds a
5236 /// `serve_permits` permit for its response stream's lifetime, so a client
5237 /// that opens `n` fragments ahead and drains them in order holds `n` server
5238 /// permits while consuming one. With every executor acting as both client and
5239 /// server the waits form cycles across nodes, nothing times out, and the job
5240 /// sits at 0% CPU forever. Measured live: TPC-H q2 wedged at 132/181 tasks
5241 /// with a prefetch of 8, and ran in 104.3 s on the identical image with a
5242 /// prefetch of 1.
5243 ///
5244 /// Four fragments against one serve permit is the smallest case that
5245 /// reproduces it. If `DEFAULT_SHUFFLE_FETCH_BUFFER` is ever raised without
5246 /// first changing `do_get` to bound resident bytes instead of open
5247 /// responses, this test hangs and the timeout fails it.
5248 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
5249 async fn a_reduce_read_completes_even_when_the_producer_serves_one_at_a_time() {
5250 use arrow::array::Int64Array;
5251 use arrow::datatypes::{DataType, Field, Schema};
5252 let store = Arc::new(TestShuffleStore::default());
5253 let schema = Arc::new(Schema::new(vec![Field::new("v", DataType::Int64, false)]));
5254 let map_tasks = 4usize;
5255 for map_task in 0..map_tasks {
5256 let batch = RecordBatch::try_new(
5257 Arc::clone(&schema),
5258 vec![Arc::new(Int64Array::from(vec![map_task as i64; 3]))],
5259 )
5260 .expect("batch");
5261 store.write(0, map_task, 0, batch);
5262 }
5263
5264 let reader: Arc<dyn ShufflePartitionReader> = Arc::new(ServeLimitedReader {
5265 inner: Arc::clone(&store),
5266 // One fewer permit than there are fragments, and the LAST map task
5267 // reaches the semaphore first. With a prefetch of `map_tasks` the
5268 // later fragments take every permit, map task 0 waits for one, and
5269 // nothing releases because `buffered` cannot yield fragment 1 before
5270 // fragment 0 has opened.
5271 permits: Arc::new(tokio::sync::Semaphore::new(map_tasks - 1)),
5272 reverse_open_order: true,
5273 map_tasks,
5274 });
5275 let read = ShuffleReadExec::new(0, map_tasks, 1, Arc::clone(&schema), Some(reader));
5276 let ctx = SessionContext::new();
5277 let stream = read.execute(0, ctx.task_ctx()).expect("execute");
5278
5279 let batches = tokio::time::timeout(
5280 std::time::Duration::from_secs(20),
5281 futures::TryStreamExt::try_collect::<Vec<_>>(stream),
5282 )
5283 .await
5284 .expect(
5285 "the reduce read deadlocked: it is holding more producer response \
5286 streams open than the producer will serve, and only downstream \
5287 consumption releases them",
5288 )
5289 .expect("read");
5290 let rows: usize = batches.iter().map(|b| b.num_rows()).sum();
5291 assert_eq!(rows, map_tasks * 3, "every fragment's rows must arrive");
5292 }
5293
5294 /// Consistent test-side hash partitioner (any consistent hash is
5295 /// correct; the executor uses krishiv-shuffle's seeded partitioner).
5296 fn partition_batch_by_key(
5297 batch: &RecordBatch,
5298 key_column: &str,
5299 num_partitions: usize,
5300 ) -> Vec<RecordBatch> {
5301 use std::hash::{Hash as _, Hasher as _};
5302 let key_idx = batch.schema().index_of(key_column).expect("key column");
5303 let column = batch.column(key_idx);
5304 let mut selections: Vec<Vec<u32>> = vec![Vec::new(); num_partitions];
5305 for row in 0..batch.num_rows() {
5306 let value = arrow::util::display::array_value_to_string(column, row).expect("value");
5307 let mut hasher = std::collections::hash_map::DefaultHasher::new();
5308 value.hash(&mut hasher);
5309 let bucket = (hasher.finish() as usize) % num_partitions;
5310 selections[bucket].push(row as u32);
5311 }
5312 selections
5313 .into_iter()
5314 .map(|rows| {
5315 let indices = arrow::array::UInt32Array::from(rows);
5316 arrow::compute::take_record_batch(batch, &indices).expect("take")
5317 })
5318 .collect()
5319 }
5320
5321 /// End-to-end stage execution: build stages for a GROUP BY, execute the
5322 /// map tasks (hash-partition into the test store), execute the result
5323 /// stage through ShuffleReadExec, and compare with direct execution.
5324 #[tokio::test]
5325 async fn staged_group_by_matches_direct_execution() {
5326 let tmp = tempfile::tempdir().expect("tempdir");
5327 let path = write_test_parquet(tmp.path()).await;
5328
5329 let plan_ctx = planning_session_context(4);
5330 plan_ctx
5331 .register_parquet(
5332 "t",
5333 path.to_str().expect("utf8 path"),
5334 datafusion::prelude::ParquetReadOptions::default(),
5335 )
5336 .await
5337 .expect("register parquet");
5338 let query = "SELECT category, COUNT(*) AS n, SUM(amount) AS total FROM t GROUP BY category ORDER BY category";
5339 let df = plan_ctx.sql(query).await.expect("sql");
5340 let plan = df.create_physical_plan().await.expect("physical plan");
5341
5342 let staged = build_distributed_stages(plan)
5343 .expect("build stages")
5344 .expect("plan must be splittable");
5345 assert_eq!(staged.stages.len(), 2, "one map stage + one result stage");
5346 let map_stage = &staged.stages[0];
5347 let result_stage = &staged.stages[1];
5348 let shuffle = map_stage.shuffle.as_ref().expect("map stage shuffles");
5349 assert_eq!(shuffle.key_columns, vec!["category".to_owned()]);
5350 assert!(
5351 map_stage.task_count() > 1,
5352 "multi-file scan must yield a multi-task map stage, got {}",
5353 map_stage.task_count()
5354 );
5355 assert!(result_stage.shuffle.is_none());
5356 assert_eq!(result_stage.upstream_stage_indexes, vec![0]);
5357
5358 // Execute map tasks: each runs its partition of the decoded subtree
5359 // and hash-partitions the output into the test store.
5360 let store = Arc::new(TestShuffleStore::default());
5361 let exec_ctx = SessionContext::new();
5362 let exec_codec = KrishivPhysicalCodec::executor(Arc::new(Arc::clone(&store)));
5363 for (task_index, body) in map_stage.task_bodies.iter().enumerate() {
5364 let (spec, plan) =
5365 decode_dfplan_task(body, &exec_ctx.task_ctx(), &exec_codec).expect("decode map");
5366 assert_eq!(spec, DfplanTaskSpec::single(task_index));
5367 let stream = plan
5368 .execute(task_index, exec_ctx.task_ctx())
5369 .expect("execute map partition");
5370 let batches: Vec<_> = futures::TryStreamExt::try_collect(stream)
5371 .await
5372 .expect("collect map output");
5373 for batch in batches {
5374 if batch.num_rows() == 0 {
5375 continue;
5376 }
5377 for (bucket, part) in partition_batch_by_key(
5378 &batch,
5379 &shuffle.key_columns[0],
5380 shuffle.num_output_partitions,
5381 )
5382 .into_iter()
5383 .enumerate()
5384 {
5385 if part.num_rows() > 0 {
5386 store.write(0, task_index, bucket, part);
5387 }
5388 }
5389 }
5390 }
5391
5392 // Execute the result stage through ShuffleReadExec.
5393 let mut staged_results = Vec::new();
5394 for (task_index, body) in result_stage.task_bodies.iter().enumerate() {
5395 let (spec, plan) =
5396 decode_dfplan_task(body, &exec_ctx.task_ctx(), &exec_codec).expect("decode result");
5397 assert_eq!(spec, DfplanTaskSpec::single(task_index));
5398 let stream = plan
5399 .execute(task_index, exec_ctx.task_ctx())
5400 .expect("execute result partition");
5401 let batches: Vec<_> = futures::TryStreamExt::try_collect(stream)
5402 .await
5403 .expect("collect result output");
5404 staged_results.extend(batches);
5405 }
5406
5407 let direct = plan_ctx
5408 .sql(query)
5409 .await
5410 .expect("direct sql")
5411 .collect()
5412 .await
5413 .expect("direct collect");
5414
5415 let render = |batches: &[RecordBatch]| {
5416 let mut rows: Vec<String> = batches
5417 .iter()
5418 .flat_map(|b| {
5419 (0..b.num_rows()).map(move |r| {
5420 (0..b.num_columns())
5421 .map(|c| {
5422 arrow::util::display::array_value_to_string(b.column(c), r)
5423 .expect("cell")
5424 })
5425 .collect::<Vec<_>>()
5426 .join("|")
5427 })
5428 })
5429 .collect();
5430 rows.sort();
5431 rows
5432 };
5433 assert_eq!(
5434 render(&staged_results),
5435 render(&direct),
5436 "staged execution must match direct execution"
5437 );
5438 }
5439
5440 /// A plain scan (no exchange) is not worth splitting: builder says None.
5441 #[tokio::test]
5442 async fn scan_only_plan_declines_with_a_stated_reason() {
5443 // A projection-and-filter plan has no exchange, so there is nothing to
5444 // cut and it correctly runs as one task. What changed is how that is
5445 // reported: declining used to be a bare `Ok(None)`, indistinguishable
5446 // at the call site from every other reason to fall back, which is how
5447 // a genuine planning bug hid behind "the planner declined" for a whole
5448 // benchmarking session. The reason is now a value, and this asserts it
5449 // says which plan property was missing.
5450 let tmp = tempfile::tempdir().expect("tempdir");
5451 let path = write_test_parquet(tmp.path()).await;
5452 let plan_ctx = planning_session_context(4);
5453 plan_ctx
5454 .register_parquet(
5455 "t",
5456 path.to_str().expect("utf8 path"),
5457 datafusion::prelude::ParquetReadOptions::default(),
5458 )
5459 .await
5460 .expect("register parquet");
5461 let df = plan_ctx
5462 .sql("SELECT id, amount FROM t WHERE id < 10")
5463 .await
5464 .expect("sql");
5465 let plan = df.create_physical_plan().await.expect("physical plan");
5466 let reason = build_distributed_stages(plan)
5467 .expect_err("a scan-only plan has no exchange and must decline")
5468 .to_string();
5469 assert!(
5470 reason.contains("no exchange"),
5471 "the decline must name the missing plan property, got: {reason}"
5472 );
5473 }
5474
5475 /// Hash-join splits into two map stages + a result stage, and staged
5476 /// execution matches direct execution.
5477 #[tokio::test]
5478 async fn staged_join_matches_direct_execution() {
5479 let tmp = tempfile::tempdir().expect("tempdir");
5480 let path = write_test_parquet(tmp.path()).await;
5481
5482 // Force a partitioned (repartition-both-sides) hash join: the test
5483 // table is tiny, and DF would otherwise broadcast it below the
5484 // single-partition thresholds — which the builder correctly declines
5485 // to split (`scan_only_plan_is_not_split` covers that shape).
5486 let mut config = SessionConfig::new().with_target_partitions(4);
5487 config
5488 .options_mut()
5489 .optimizer
5490 .enable_round_robin_repartition = false;
5491 config
5492 .options_mut()
5493 .optimizer
5494 .hash_join_single_partition_threshold = 0;
5495 config
5496 .options_mut()
5497 .optimizer
5498 .hash_join_single_partition_threshold_rows = 0;
5499 let plan_ctx = SessionContext::new_with_config(config);
5500 for name in ["a", "b"] {
5501 plan_ctx
5502 .register_parquet(
5503 name,
5504 path.to_str().expect("utf8 path"),
5505 datafusion::prelude::ParquetReadOptions::default(),
5506 )
5507 .await
5508 .expect("register parquet");
5509 }
5510 let query = "SELECT a.category, COUNT(*) AS n, SUM(b.amount) AS total \
5511 FROM a JOIN b ON a.id = b.id GROUP BY a.category";
5512 let df = plan_ctx.sql(query).await.expect("sql");
5513 let plan = df.create_physical_plan().await.expect("physical plan");
5514 let staged = build_distributed_stages(plan)
5515 .expect("build stages")
5516 .expect("partitioned join must split into stages");
5517 assert!(
5518 staged.stages.len() >= 3,
5519 "expected two join-side map stages + result, got {}",
5520 staged.stages.len()
5521 );
5522
5523 let store = Arc::new(TestShuffleStore::default());
5524 let exec_ctx = SessionContext::new();
5525 let exec_codec = KrishivPhysicalCodec::executor(Arc::new(Arc::clone(&store)));
5526
5527 // Execute stages in order (map stages precede the result stage).
5528 let mut staged_results = Vec::new();
5529 for (stage_index, stage) in staged.stages.iter().enumerate() {
5530 for (task_index, body) in stage.task_bodies.iter().enumerate() {
5531 let (spec, plan) = decode_dfplan_task(body, &exec_ctx.task_ctx(), &exec_codec)
5532 .expect("decode stage task");
5533 assert_eq!(spec, DfplanTaskSpec::single(task_index));
5534 let stream = plan
5535 .execute(task_index, exec_ctx.task_ctx())
5536 .expect("execute stage partition");
5537 let batches: Vec<_> = futures::TryStreamExt::try_collect(stream)
5538 .await
5539 .expect("collect stage output");
5540 match &stage.shuffle {
5541 Some(shuffle) => {
5542 for batch in batches {
5543 if batch.num_rows() == 0 {
5544 continue;
5545 }
5546 for (bucket, part) in partition_batch_by_key(
5547 &batch,
5548 &shuffle.key_columns[0],
5549 shuffle.num_output_partitions,
5550 )
5551 .into_iter()
5552 .enumerate()
5553 {
5554 if part.num_rows() > 0 {
5555 store.write(stage_index, task_index, bucket, part);
5556 }
5557 }
5558 }
5559 }
5560 None => staged_results.extend(batches),
5561 }
5562 }
5563 }
5564
5565 let direct = plan_ctx
5566 .sql(query)
5567 .await
5568 .expect("direct sql")
5569 .collect()
5570 .await
5571 .expect("direct collect");
5572
5573 let render = |batches: &[RecordBatch]| {
5574 let mut rows: Vec<String> = batches
5575 .iter()
5576 .flat_map(|b| {
5577 (0..b.num_rows()).map(move |r| {
5578 (0..b.num_columns())
5579 .map(|c| {
5580 arrow::util::display::array_value_to_string(b.column(c), r)
5581 .expect("cell")
5582 })
5583 .collect::<Vec<_>>()
5584 .join("|")
5585 })
5586 })
5587 .collect();
5588 rows.sort();
5589 rows
5590 };
5591 assert_eq!(
5592 render(&staged_results),
5593 render(&direct),
5594 "staged join must match direct execution"
5595 );
5596 }
5597
5598 // ── Phase 54: partition-spec grammar ─────────────────────────────────
5599
5600 #[test]
5601 fn partition_spec_grammar_round_trips() {
5602 let multi = DfplanTaskSpec {
5603 partitions: vec![1, 4, 7],
5604 map_range: None,
5605 };
5606 let body = dfplan_task_body_for_spec("QUJD", &multi);
5607 assert_eq!(body, "dfplan:v1:1,4,7:QUJD");
5608 assert_eq!(dfplan_body_partition_spec(&body).expect("parse"), multi);
5609
5610 let split = DfplanTaskSpec {
5611 partitions: vec![5],
5612 map_range: Some(DfplanMapRange {
5613 upstream_stage_index: 0,
5614 start: 2,
5615 end: 4,
5616 }),
5617 };
5618 let body = dfplan_task_body_for_spec("QUJD", &split);
5619 assert_eq!(body, "dfplan:v1:5/s0m2-4:QUJD");
5620 assert_eq!(dfplan_body_partition_spec(&body).expect("parse"), split);
5621
5622 // Legacy single-partition form parses as a single spec.
5623 assert_eq!(
5624 dfplan_body_partition_spec("dfplan:v1:3:QUJD").expect("parse"),
5625 DfplanTaskSpec::single(3)
5626 );
5627 }
5628
5629 #[test]
5630 fn partition_spec_rewrite_preserves_payload() {
5631 let original = dfplan_task_body("cGF5bG9hZA==", 2);
5632 let rewritten = dfplan_body_with_spec(
5633 &original,
5634 &DfplanTaskSpec {
5635 partitions: vec![0, 2],
5636 map_range: None,
5637 },
5638 )
5639 .expect("rewrite");
5640 assert_eq!(rewritten, "dfplan:v1:0,2:cGF5bG9hZA==");
5641 }
5642
5643 #[test]
5644 fn partition_spec_rejects_malformed_segments() {
5645 assert!(dfplan_body_partition_spec("dfplan:v1::QUJD").is_err());
5646 assert!(dfplan_body_partition_spec("dfplan:v1:x:QUJD").is_err());
5647 assert!(dfplan_body_partition_spec("dfplan:v1:1/s0m4-4:QUJD").is_err());
5648 assert!(dfplan_body_partition_spec("dfplan:v1:1/m0-2:QUJD").is_err());
5649 }
5650
5651 /// Coalescing correctness: a Result-stage task executing SEVERAL root
5652 /// partitions produces exactly the union the one-task-per-partition
5653 /// layout produces (the exit-gate mechanism for AQE coalescing).
5654 #[tokio::test]
5655 async fn coalesced_result_stage_matches_direct_execution() {
5656 let tmp = tempfile::tempdir().expect("tempdir");
5657 let path = write_test_parquet(tmp.path()).await;
5658 let plan_ctx = planning_session_context(4);
5659 plan_ctx
5660 .register_parquet(
5661 "t",
5662 path.to_str().expect("utf8 path"),
5663 datafusion::prelude::ParquetReadOptions::default(),
5664 )
5665 .await
5666 .expect("register parquet");
5667 let query = "SELECT category, COUNT(*) AS n, SUM(amount) AS total FROM t GROUP BY category";
5668 let df = plan_ctx.sql(query).await.expect("sql");
5669 let plan = df.create_physical_plan().await.expect("physical plan");
5670 let staged = build_distributed_stages(plan)
5671 .expect("build stages")
5672 .expect("splittable");
5673 let map_stage = staged.stages.first().expect("map stage");
5674 let result_stage = staged.stages.get(1).expect("result stage");
5675 let shuffle = map_stage.shuffle.as_ref().expect("map shuffles");
5676
5677 // Run the map stage into the test store (as in the staged tests).
5678 let store = Arc::new(TestShuffleStore::default());
5679 let exec_ctx = SessionContext::new();
5680 for (task_index, body) in map_stage.task_bodies.iter().enumerate() {
5681 let reader: Arc<dyn ShufflePartitionReader> = Arc::new(Arc::clone(&store));
5682 let (_, mut stream) =
5683 execute_dfplan_body(body, &exec_ctx, Some(reader)).expect("map exec");
5684 while let Some(batch) = futures::StreamExt::next(&mut stream).await {
5685 let batch = batch.expect("map batch");
5686 if batch.num_rows() == 0 {
5687 continue;
5688 }
5689 for (bucket, part) in partition_batch_by_key(
5690 &batch,
5691 &shuffle.key_columns[0],
5692 shuffle.num_output_partitions,
5693 )
5694 .into_iter()
5695 .enumerate()
5696 {
5697 if part.num_rows() > 0 {
5698 store.write(0, task_index, bucket, part);
5699 }
5700 }
5701 }
5702 }
5703
5704 // ONE coalesced task executing every result partition.
5705 let all_partitions: Vec<usize> = (0..result_stage.task_count()).collect();
5706 let coalesced_body = dfplan_body_with_spec(
5707 result_stage.task_bodies.first().expect("result body"),
5708 &DfplanTaskSpec {
5709 partitions: all_partitions,
5710 map_range: None,
5711 },
5712 )
5713 .expect("coalesce rewrite");
5714 let reader: Arc<dyn ShufflePartitionReader> = Arc::new(Arc::clone(&store));
5715 let (_, stream) =
5716 execute_dfplan_body(&coalesced_body, &exec_ctx, Some(reader)).expect("coalesced exec");
5717 let coalesced: Vec<RecordBatch> = futures::TryStreamExt::try_collect(stream)
5718 .await
5719 .expect("coalesced results");
5720
5721 // Per-partition baseline through the ORIGINAL bodies.
5722 let mut baseline = Vec::new();
5723 for body in &result_stage.task_bodies {
5724 let reader: Arc<dyn ShufflePartitionReader> = Arc::new(Arc::clone(&store));
5725 let (_, stream) =
5726 execute_dfplan_body(body, &exec_ctx, Some(reader)).expect("baseline exec");
5727 let batches: Vec<RecordBatch> = futures::TryStreamExt::try_collect(stream)
5728 .await
5729 .expect("baseline results");
5730 baseline.extend(batches);
5731 }
5732
5733 let render = |batches: &[RecordBatch]| {
5734 let mut rows: Vec<String> = batches
5735 .iter()
5736 .flat_map(|b| {
5737 (0..b.num_rows()).map(move |r| {
5738 (0..b.num_columns())
5739 .map(|c| {
5740 arrow::util::display::array_value_to_string(b.column(c), r)
5741 .expect("cell")
5742 })
5743 .collect::<Vec<_>>()
5744 .join("|")
5745 })
5746 })
5747 .collect();
5748 rows.sort();
5749 rows
5750 };
5751 assert_eq!(
5752 render(&coalesced),
5753 render(&baseline),
5754 "coalesced task must produce the same union as per-partition tasks"
5755 );
5756 assert!(!coalesced.is_empty(), "group-by must produce rows");
5757 }
5758
5759 /// Skew-split correctness: splitting a Result-stage partition of a pure
5760 /// inner join into map-task ranges yields the same union as the unsplit
5761 /// task (the exit-gate mechanism for AQE skew handling), and the
5762 /// split-safety gate admits the join while rejecting an aggregation.
5763 #[tokio::test]
5764 async fn skew_split_result_tasks_match_unsplit_execution() {
5765 let tmp = tempfile::tempdir().expect("tempdir");
5766 let path = write_test_parquet(tmp.path()).await;
5767
5768 let mut config = SessionConfig::new().with_target_partitions(4);
5769 config
5770 .options_mut()
5771 .optimizer
5772 .enable_round_robin_repartition = false;
5773 config
5774 .options_mut()
5775 .optimizer
5776 .hash_join_single_partition_threshold = 0;
5777 config
5778 .options_mut()
5779 .optimizer
5780 .hash_join_single_partition_threshold_rows = 0;
5781 let plan_ctx = SessionContext::new_with_config(config);
5782 for name in ["a", "b"] {
5783 plan_ctx
5784 .register_parquet(
5785 name,
5786 path.to_str().expect("utf8 path"),
5787 datafusion::prelude::ParquetReadOptions::default(),
5788 )
5789 .await
5790 .expect("register parquet");
5791 }
5792 // Pure inner join — no blocking operator above the shuffle reads.
5793 let query = "SELECT a.id, a.category, b.amount FROM a JOIN b ON a.id = b.id";
5794 let df = plan_ctx.sql(query).await.expect("sql");
5795 let plan = df.create_physical_plan().await.expect("physical plan");
5796 let staged = build_distributed_stages(plan)
5797 .expect("build stages")
5798 .expect("partitioned join must split");
5799 let result_stage = staged.stages.last().expect("result stage");
5800 assert!(result_stage.shuffle.is_none());
5801 let result_body = result_stage.task_bodies.first().expect("result body");
5802 assert!(
5803 dfplan_body_is_split_safe(result_body),
5804 "pure inner join result stage must be split-safe"
5805 );
5806
5807 // Execute all map stages into the store.
5808 let store = Arc::new(TestShuffleStore::default());
5809 let exec_ctx = SessionContext::new();
5810 let mut probe_map_tasks = 0usize;
5811 for (stage_index, stage) in staged.stages.iter().enumerate() {
5812 let Some(shuffle) = &stage.shuffle else {
5813 continue;
5814 };
5815 if stage_index == 0 {
5816 probe_map_tasks = stage.task_count();
5817 }
5818 for (task_index, body) in stage.task_bodies.iter().enumerate() {
5819 let reader: Arc<dyn ShufflePartitionReader> = Arc::new(Arc::clone(&store));
5820 let (_, stream) =
5821 execute_dfplan_body(body, &exec_ctx, Some(reader)).expect("map exec");
5822 let batches: Vec<RecordBatch> = futures::TryStreamExt::try_collect(stream)
5823 .await
5824 .expect("map results");
5825 for batch in batches {
5826 if batch.num_rows() == 0 {
5827 continue;
5828 }
5829 for (bucket, part) in partition_batch_by_key(
5830 &batch,
5831 &shuffle.key_columns[0],
5832 shuffle.num_output_partitions,
5833 )
5834 .into_iter()
5835 .enumerate()
5836 {
5837 if part.num_rows() > 0 {
5838 store.write(stage_index, task_index, bucket, part);
5839 }
5840 }
5841 }
5842 }
5843 }
5844 assert!(
5845 probe_map_tasks >= 2,
5846 "need >=2 map tasks to split, got {probe_map_tasks}"
5847 );
5848
5849 let collect_body = |body: String| {
5850 let store = Arc::clone(&store);
5851 let exec_ctx = exec_ctx.clone();
5852 async move {
5853 let reader: Arc<dyn ShufflePartitionReader> = Arc::new(store);
5854 let (_, stream) =
5855 execute_dfplan_body(&body, &exec_ctx, Some(reader)).expect("exec");
5856 let batches: Vec<RecordBatch> = futures::TryStreamExt::try_collect(stream)
5857 .await
5858 .expect("results");
5859 batches
5860 }
5861 };
5862
5863 let render = |batches: &[RecordBatch]| {
5864 let mut rows: Vec<String> = batches
5865 .iter()
5866 .flat_map(|b| {
5867 (0..b.num_rows()).map(move |r| {
5868 (0..b.num_columns())
5869 .map(|c| {
5870 arrow::util::display::array_value_to_string(b.column(c), r)
5871 .expect("cell")
5872 })
5873 .collect::<Vec<_>>()
5874 .join("|")
5875 })
5876 })
5877 .collect();
5878 rows.sort();
5879 rows
5880 };
5881
5882 // Every result partition: unsplit baseline vs two map-range splits
5883 // of upstream stage 0 (the probe side in builder order).
5884 for (partition, body) in result_stage.task_bodies.iter().enumerate() {
5885 let baseline = collect_body(body.clone()).await;
5886 let mid = probe_map_tasks / 2;
5887 let mut split_union = Vec::new();
5888 for (start, end) in [(0, mid), (mid, probe_map_tasks)] {
5889 let split_body = dfplan_body_with_spec(
5890 body,
5891 &DfplanTaskSpec {
5892 partitions: vec![partition],
5893 map_range: Some(DfplanMapRange {
5894 upstream_stage_index: 0,
5895 start,
5896 end,
5897 }),
5898 },
5899 )
5900 .expect("split rewrite");
5901 split_union.extend(collect_body(split_body).await);
5902 }
5903 assert_eq!(
5904 render(&split_union),
5905 render(&baseline),
5906 "partition {partition}: split union must equal unsplit output"
5907 );
5908 }
5909
5910 // The safety gate must reject a plan with a blocking aggregation.
5911 let agg_ctx = planning_session_context(4);
5912 agg_ctx
5913 .register_parquet(
5914 "t",
5915 path.to_str().expect("utf8 path"),
5916 datafusion::prelude::ParquetReadOptions::default(),
5917 )
5918 .await
5919 .expect("register parquet");
5920 let agg_plan = agg_ctx
5921 .sql("SELECT category, COUNT(*) FROM t GROUP BY category")
5922 .await
5923 .expect("sql")
5924 .create_physical_plan()
5925 .await
5926 .expect("plan");
5927 let agg_staged = build_distributed_stages(agg_plan)
5928 .expect("build stages")
5929 .expect("splittable");
5930 let agg_body = agg_staged
5931 .stages
5932 .last()
5933 .expect("result stage")
5934 .task_bodies
5935 .first()
5936 .expect("body");
5937 assert!(
5938 !dfplan_body_is_split_safe(agg_body),
5939 "final aggregation must NOT be split-safe"
5940 );
5941 }
5942}
5943
5944#[cfg(test)]
5945#[allow(clippy::unwrap_used, clippy::expect_used)]
5946mod roundtrip_schema_guard_tests {
5947 use super::*;
5948 use arrow::datatypes::{DataType, Field, Schema};
5949
5950 /// A schema deliberately unlike anything the encoded plan produces.
5951 fn alien_schema() -> Schema {
5952 Schema::new(vec![Field::new(
5953 "not_a_real_column",
5954 DataType::Boolean,
5955 true,
5956 )])
5957 }
5958
5959 #[tokio::test]
5960 async fn the_guard_rejects_a_decode_whose_schema_differs() {
5961 // The property the guard was missing. It only ever checked that decode
5962 // *succeeded*, so a fragment could decode into a plan producing
5963 // different column types and ship anyway — `ShuffleReadExec` labels its
5964 // stream with the coordinator's schema, `RecordBatchStreamAdapter` does
5965 // not validate, and the disagreement surfaced much later inside an
5966 // executor as a bare Arrow error (q17: Decimal128(15,2) declared,
5967 // Decimal128(30,15) produced).
5968 let ctx = fragment_decode_session_context();
5969 ctx.sql("CREATE TABLE t(a INT) AS VALUES (1), (2)")
5970 .await
5971 .unwrap()
5972 .collect()
5973 .await
5974 .unwrap();
5975 let plan = ctx
5976 .sql("SELECT a FROM t")
5977 .await
5978 .unwrap()
5979 .create_physical_plan()
5980 .await
5981 .unwrap();
5982 let codec = KrishivPhysicalCodec::coordinator();
5983 let bytes = encode_dfplan_bytes(Arc::clone(&plan), &codec).unwrap();
5984 let task_ctx = ctx.task_ctx();
5985
5986 // Its own plan passes.
5987 verify_dfplan_roundtrip(&bytes, &codec, &task_ctx, Some(&plan))
5988 .expect("a plan must round-trip against itself");
5989
5990 // A different plan is refused, and the message names the mismatch so
5991 // the fallback is explainable rather than mysterious.
5992 let alien: Arc<dyn ExecutionPlan> = Arc::new(
5993 datafusion::physical_plan::empty::EmptyExec::new(Arc::new(alien_schema())),
5994 );
5995 let err = verify_dfplan_roundtrip(&bytes, &codec, &task_ctx, Some(&alien))
5996 .expect_err("a schema disagreement must be refused");
5997 let msg = err.to_string();
5998 assert!(
5999 msg.contains("decoded plan differs"),
6000 "unexpected message: {msg}"
6001 );
6002 }
6003
6004 /// The root-only check was not enough, and q17 is the proof.
6005 ///
6006 /// A decode can re-resolve an interior aggregate to a different type while
6007 /// a projection above it casts back, so the *root* schemas agree and the
6008 /// guard passes a fragment whose interior will not run. Comparing the tree
6009 /// is what makes the guard mean "the executor can rebuild this plan"
6010 /// rather than "the executor can rebuild this plan's last node".
6011 #[tokio::test]
6012 async fn the_guard_compares_the_whole_tree_not_just_the_root() {
6013 use arrow::datatypes::{DataType, Field, Schema};
6014 use datafusion::physical_plan::empty::EmptyExec;
6015
6016 let same_root = Arc::new(Schema::new(vec![Field::new("a", DataType::Int32, true)]));
6017 // Two plans with identical root schemas and different interiors.
6018 let original: Arc<dyn ExecutionPlan> =
6019 Arc::new(datafusion::physical_plan::limit::GlobalLimitExec::new(
6020 Arc::new(EmptyExec::new(Arc::clone(&same_root))),
6021 0,
6022 None,
6023 ));
6024 let decoded: Arc<dyn ExecutionPlan> =
6025 Arc::new(datafusion::physical_plan::limit::GlobalLimitExec::new(
6026 Arc::new(EmptyExec::new(Arc::new(Schema::new(vec![Field::new(
6027 "a",
6028 DataType::Int64,
6029 true,
6030 )])))),
6031 0,
6032 None,
6033 ));
6034 // Roots agree only if the interiors do for CoalesceBatchesExec, so
6035 // assert on the child directly: the walk must reach it and name it.
6036 let difference = first_schema_difference(&original, &decoded, "root")
6037 .expect("an interior disagreement must be reported");
6038 assert!(
6039 difference.contains("root"),
6040 "the difference must name where it is: {difference}"
6041 );
6042
6043 // Identical trees agree.
6044 assert!(first_schema_difference(&original, &original, "root").is_none());
6045 }
6046
6047 #[tokio::test]
6048 async fn passing_no_expected_schema_keeps_the_old_decode_only_behaviour() {
6049 // Callers that only care whether the bytes decode (the existing
6050 // regression tests) must keep working unchanged.
6051 let ctx = fragment_decode_session_context();
6052 ctx.sql("CREATE TABLE t2(a INT) AS VALUES (1)")
6053 .await
6054 .unwrap()
6055 .collect()
6056 .await
6057 .unwrap();
6058 let plan = ctx
6059 .sql("SELECT a FROM t2")
6060 .await
6061 .unwrap()
6062 .create_physical_plan()
6063 .await
6064 .unwrap();
6065 let codec = KrishivPhysicalCodec::coordinator();
6066 let bytes = encode_dfplan_bytes(plan, &codec).unwrap();
6067 verify_dfplan_roundtrip(&bytes, &codec, &ctx.task_ctx(), None)
6068 .expect("decode-only checking must still pass");
6069 }
6070}
6071
6072/// Staged TPC-H over a miniature fixture: the whole cut-encode-ship-execute
6073/// path, in process.
6074///
6075/// The SF100 cluster is the only place several of this module's defects have
6076/// ever appeared, and a cluster cycle costs an hour. These tests run the same
6077/// path — the same planner, the same stage cut, the same fragment bodies, the
6078/// same `ShuffleReadExec` — over a few hundred rows, so a schema disagreement
6079/// between what a stage *declares* and what it *produces* fails in seconds on
6080/// a laptop instead of in an overnight sweep.
6081#[cfg(test)]
6082#[allow(clippy::unwrap_used, clippy::expect_used)]
6083mod staged_tpch_tests {
6084 use super::*;
6085 use arrow::record_batch::RecordBatch;
6086 use datafusion::prelude::{ParquetReadOptions, SessionContext};
6087 use std::collections::HashMap;
6088 use std::sync::{Arc, Mutex};
6089
6090 /// q17 and q19 verbatim from the benchmark corpus (`krishiv-bench`), which
6091 /// is the point: a paraphrase would not reproduce the plan shape.
6092 const Q17: &str = "SELECT sum(l_extendedprice) / 7.0 AS avg_yearly FROM lineitem, part \
6093 WHERE p_partkey = l_partkey AND p_brand = 'Brand#23' AND p_container = 'MED BOX' \
6094 AND l_quantity < (SELECT 0.2 * avg(l_quantity) FROM lineitem \
6095 WHERE l_partkey = p_partkey)";
6096
6097 const Q19: &str = "SELECT sum(l_extendedprice * (1 - l_discount)) AS revenue \
6098 FROM lineitem, part \
6099 WHERE (p_partkey = l_partkey AND p_brand = 'Brand#12' \
6100 AND p_container IN ('SM CASE', 'SM BOX', 'SM PACK', 'SM PKG') \
6101 AND l_quantity >= 1 AND l_quantity <= 11 AND p_size BETWEEN 1 AND 5 \
6102 AND l_shipmode IN ('AIR', 'AIR REG') AND l_shipinstruct = 'DELIVER IN PERSON') \
6103 OR (p_partkey = l_partkey AND p_brand = 'Brand#23' \
6104 AND p_container IN ('MED BAG', 'MED BOX', 'MED PKG', 'MED PACK') \
6105 AND l_quantity >= 10 AND l_quantity <= 20 AND p_size BETWEEN 1 AND 10 \
6106 AND l_shipmode IN ('AIR', 'AIR REG') AND l_shipinstruct = 'DELIVER IN PERSON') \
6107 OR (p_partkey = l_partkey AND p_brand = 'Brand#34' \
6108 AND p_container IN ('LG CASE', 'LG BOX', 'LG PACK', 'LG PKG') \
6109 AND l_quantity >= 20 AND l_quantity <= 30 AND p_size BETWEEN 1 AND 15 \
6110 AND l_shipmode IN ('AIR', 'AIR REG') AND l_shipinstruct = 'DELIVER IN PERSON')";
6111
6112 #[derive(Debug, Default)]
6113 struct StageStore {
6114 partitions: Mutex<HashMap<(usize, usize, usize), Vec<RecordBatch>>>,
6115 }
6116
6117 impl ShufflePartitionReader for Arc<StageStore> {
6118 fn open_partition(
6119 &self,
6120 upstream_stage_index: usize,
6121 map_task_index: usize,
6122 partition: usize,
6123 ) -> futures::future::BoxFuture<'static, Result<ShuffleFragmentStream, String>> {
6124 let batches = self
6125 .partitions
6126 .lock()
6127 .expect("store lock")
6128 .get(&(upstream_stage_index, map_task_index, partition))
6129 .cloned()
6130 .unwrap_or_default();
6131 Box::pin(async move {
6132 Ok(Box::pin(futures::stream::iter(batches.into_iter().map(Ok)))
6133 as ShuffleFragmentStream)
6134 })
6135 }
6136 }
6137
6138 fn write_parquet(path: &std::path::Path, batch: &RecordBatch) {
6139 let file = std::fs::File::create(path).expect("create parquet");
6140 let mut writer =
6141 datafusion::parquet::arrow::ArrowWriter::try_new(file, batch.schema(), None)
6142 .expect("writer init");
6143 writer.write(batch).expect("write batch");
6144 writer.close().expect("close writer");
6145 }
6146
6147 /// Miniature `lineitem` and `part`, two files each so map stages get more
6148 /// than one task. Column types match the TPC-H DDL — the `Decimal128(15,2)`
6149 /// money columns especially, since the defect under test is a decimal
6150 /// precision disagreement.
6151 fn write_tpch_fixture(dir: &std::path::Path) -> (std::path::PathBuf, std::path::PathBuf) {
6152 use arrow::array::{Decimal128Array, Int32Array, Int64Array, StringArray};
6153 use arrow::datatypes::{DataType, Field, Schema};
6154
6155 let lineitem_schema = Arc::new(Schema::new(vec![
6156 Field::new("l_partkey", DataType::Int64, false),
6157 Field::new("l_quantity", DataType::Decimal128(15, 2), false),
6158 Field::new("l_extendedprice", DataType::Decimal128(15, 2), false),
6159 Field::new("l_discount", DataType::Decimal128(15, 2), false),
6160 Field::new("l_shipmode", DataType::Utf8, false),
6161 Field::new("l_shipinstruct", DataType::Utf8, false),
6162 ]));
6163 let part_schema = Arc::new(Schema::new(vec![
6164 Field::new("p_partkey", DataType::Int64, false),
6165 Field::new("p_brand", DataType::Utf8, false),
6166 Field::new("p_container", DataType::Utf8, false),
6167 Field::new("p_size", DataType::Int32, false),
6168 ]));
6169 let money = |values: Vec<i128>| -> Arc<dyn arrow::array::Array> {
6170 Arc::new(
6171 Decimal128Array::from(values)
6172 .with_precision_and_scale(15, 2)
6173 .expect("decimal(15,2)"),
6174 )
6175 };
6176
6177 let lineitem_dir = dir.join("lineitem");
6178 std::fs::create_dir_all(&lineitem_dir).expect("lineitem dir");
6179 for file_index in 0..2i64 {
6180 let keys: Vec<i64> = (0..200).map(|i| (file_index * 200 + i) % 60).collect();
6181 let batch = RecordBatch::try_new(
6182 Arc::clone(&lineitem_schema),
6183 vec![
6184 Arc::new(Int64Array::from(keys.clone())),
6185 money(keys.iter().map(|k| i128::from(k % 30 + 1) * 100).collect()),
6186 money(keys.iter().map(|k| i128::from(k + 1) * 1_000).collect()),
6187 money(keys.iter().map(|k| i128::from(k % 10)).collect()),
6188 Arc::new(StringArray::from(
6189 keys.iter()
6190 .map(|k| if k % 2 == 0 { "AIR" } else { "RAIL" })
6191 .collect::<Vec<_>>(),
6192 )),
6193 Arc::new(StringArray::from(
6194 keys.iter()
6195 .map(|k| {
6196 if k % 3 == 0 {
6197 "DELIVER IN PERSON"
6198 } else {
6199 "TAKE BACK RETURN"
6200 }
6201 })
6202 .collect::<Vec<_>>(),
6203 )),
6204 ],
6205 )
6206 .expect("lineitem batch");
6207 write_parquet(
6208 &lineitem_dir.join(format!("l-{file_index}.parquet")),
6209 &batch,
6210 );
6211 }
6212
6213 let part_dir = dir.join("part");
6214 std::fs::create_dir_all(&part_dir).expect("part dir");
6215 for file_index in 0..2i64 {
6216 let keys: Vec<i64> = (0..30).map(|i| file_index * 30 + i).collect();
6217 let batch = RecordBatch::try_new(
6218 Arc::clone(&part_schema),
6219 vec![
6220 Arc::new(Int64Array::from(keys.clone())),
6221 Arc::new(StringArray::from(
6222 keys.iter()
6223 .map(|k| match k % 3 {
6224 0 => "Brand#12",
6225 1 => "Brand#23",
6226 _ => "Brand#34",
6227 })
6228 .collect::<Vec<_>>(),
6229 )),
6230 Arc::new(StringArray::from(
6231 keys.iter()
6232 .map(|k| match k % 4 {
6233 0 => "SM BOX",
6234 1 => "MED BOX",
6235 2 => "LG BOX",
6236 _ => "JUMBO BOX",
6237 })
6238 .collect::<Vec<_>>(),
6239 )),
6240 Arc::new(Int32Array::from(
6241 keys.iter().map(|k| (k % 15 + 1) as i32).collect::<Vec<_>>(),
6242 )),
6243 ],
6244 )
6245 .expect("part batch");
6246 write_parquet(&part_dir.join(format!("p-{file_index}.parquet")), &batch);
6247 }
6248 (lineitem_dir, part_dir)
6249 }
6250
6251 /// q22 verbatim: a correlated NOT EXISTS plus a scalar-subquery threshold,
6252 /// over `customer`/`orders`.
6253 const Q22: &str = "SELECT cntrycode, count(*) AS numcust, sum(c_acctbal) AS totacctbal FROM ( \
6254 SELECT substr(c_phone, 1, 2) AS cntrycode, c_acctbal FROM customer \
6255 WHERE substr(c_phone, 1, 2) IN ('13','31','23','29','30','18','17') \
6256 AND c_acctbal > (SELECT avg(c_acctbal) FROM customer \
6257 WHERE c_acctbal > 0.00 \
6258 AND substr(c_phone, 1, 2) IN ('13','31','23','29','30','18','17')) \
6259 AND NOT EXISTS (SELECT * FROM orders WHERE o_custkey = c_custkey)) AS custsale \
6260 GROUP BY cntrycode ORDER BY cntrycode";
6261
6262 /// Miniature `customer` and `orders`, two files each.
6263 fn write_q22_fixture(dir: &std::path::Path) -> (std::path::PathBuf, std::path::PathBuf) {
6264 use arrow::array::{Decimal128Array, Int64Array, StringArray};
6265 use arrow::datatypes::{DataType, Field, Schema};
6266
6267 let customer_schema = Arc::new(Schema::new(vec![
6268 Field::new("c_custkey", DataType::Int64, false),
6269 Field::new("c_phone", DataType::Utf8, false),
6270 Field::new("c_acctbal", DataType::Decimal128(15, 2), false),
6271 ]));
6272 let orders_schema = Arc::new(Schema::new(vec![
6273 Field::new("o_orderkey", DataType::Int64, false),
6274 Field::new("o_custkey", DataType::Int64, false),
6275 ]));
6276 let codes = ["13", "31", "23", "29", "30", "18", "17", "44"];
6277
6278 let customer_dir = dir.join("customer");
6279 std::fs::create_dir_all(&customer_dir).expect("customer dir");
6280 for file_index in 0..2i64 {
6281 let keys: Vec<i64> = (0..120).map(|i| file_index * 120 + i).collect();
6282 let batch = RecordBatch::try_new(
6283 Arc::clone(&customer_schema),
6284 vec![
6285 Arc::new(Int64Array::from(keys.clone())),
6286 Arc::new(StringArray::from(
6287 keys.iter()
6288 .map(|k| format!("{}-555-0100", codes[(*k as usize) % codes.len()]))
6289 .collect::<Vec<_>>(),
6290 )),
6291 Arc::new(
6292 Decimal128Array::from(
6293 keys.iter()
6294 .map(|k| i128::from(k % 900) * 100)
6295 .collect::<Vec<_>>(),
6296 )
6297 .with_precision_and_scale(15, 2)
6298 .expect("decimal(15,2)"),
6299 ),
6300 ],
6301 )
6302 .expect("customer batch");
6303 write_parquet(
6304 &customer_dir.join(format!("c-{file_index}.parquet")),
6305 &batch,
6306 );
6307 }
6308
6309 let orders_dir = dir.join("orders");
6310 std::fs::create_dir_all(&orders_dir).expect("orders dir");
6311 for file_index in 0..2i64 {
6312 let keys: Vec<i64> = (0..80).map(|i| file_index * 80 + i).collect();
6313 let batch = RecordBatch::try_new(
6314 Arc::clone(&orders_schema),
6315 vec![
6316 Arc::new(Int64Array::from(keys.clone())),
6317 // Only some customers have orders, so NOT EXISTS keeps rows.
6318 Arc::new(Int64Array::from(
6319 keys.iter().map(|k| k * 3 % 240).collect::<Vec<_>>(),
6320 )),
6321 ],
6322 )
6323 .expect("orders batch");
6324 write_parquet(&orders_dir.join(format!("o-{file_index}.parquet")), &batch);
6325 }
6326 (customer_dir, orders_dir)
6327 }
6328
6329 async fn q22_context(dir: &std::path::Path) -> SessionContext {
6330 q22_context_with_broadcast(dir, None).await
6331 }
6332
6333 async fn q22_context_with_broadcast(
6334 dir: &std::path::Path,
6335 broadcast_bytes: Option<usize>,
6336 ) -> SessionContext {
6337 let (customer, orders) = write_q22_fixture(dir);
6338 let ctx = planning_session_context_with_options(4, None, broadcast_bytes);
6339 for (name, path) in [("customer", customer), ("orders", orders)] {
6340 ctx.register_parquet(
6341 name,
6342 path.to_str().expect("utf8 path"),
6343 ParquetReadOptions::default(),
6344 )
6345 .await
6346 .expect("register parquet");
6347 }
6348 ctx
6349 }
6350
6351 /// The q22 defect and its repair, both pinned in one test.
6352 ///
6353 /// `c_acctbal > (SELECT avg(c_acctbal) …)` leaves a `ScalarSubqueryExpr` in
6354 /// a filter below the exchange, while the `ScalarSubqueryExec` that
6355 /// populates it — which DataFusion puts at the very ROOT of the plan —
6356 /// stays behind in the result stage. The map fragment then encodes happily
6357 /// and refuses to decode, and the builder reads that as "decline to stage",
6358 /// running all of q22 as ONE task.
6359 ///
6360 /// Asserting BOTH halves is the point. Without the first assertion the test
6361 /// would keep passing if the severing ever stopped happening, and the
6362 /// repair would be a no-op nobody noticed.
6363 #[tokio::test]
6364 async fn a_severed_scalar_subquery_stage_does_not_decode_until_the_wrapper_is_restored() {
6365 let tmp = tempfile::tempdir().expect("tempdir");
6366 let ctx = q22_context(tmp.path()).await;
6367 let plan = ctx
6368 .sql(Q22)
6369 .await
6370 .expect("sql")
6371 .create_physical_plan()
6372 .await
6373 .expect("physical plan");
6374
6375 let mut drafts: Vec<StageDraft> = Vec::new();
6376 let root = cut_exchanges(Arc::clone(&plan), &mut drafts)
6377 .unwrap_or_else(|Unsupported(reason)| panic!("q22 stage split: {reason}"));
6378 drafts.push(StageDraft {
6379 plan: root,
6380 shuffle: None,
6381 subqueries: None,
6382 });
6383 assert!(
6384 drafts.iter().any(|d| d.subqueries.is_some()),
6385 "q22 must cut at least one stage out from beneath the ScalarSubqueryExec, \
6386 or there is nothing for the repair to act on"
6387 );
6388
6389 let codec = KrishivPhysicalCodec::coordinator();
6390 let decode_ctx = fragment_decode_session_context().task_ctx();
6391 let mut saw_severed_stage = false;
6392 for draft in &drafts {
6393 let Some(context) = &draft.subqueries else {
6394 continue;
6395 };
6396 let bytes =
6397 encode_dfplan_bytes(Arc::clone(&draft.plan), &codec).expect("q22 stage encodes");
6398 let Err(error) =
6399 verify_dfplan_roundtrip(&bytes, &codec, &decode_ctx, Some(&draft.plan))
6400 else {
6401 // This stage carried no `ScalarSubqueryExpr`; nothing severed.
6402 continue;
6403 };
6404 assert!(
6405 error
6406 .to_string()
6407 .contains("ScalarSubqueryExpr can only be deserialized"),
6408 "expected the severed-wrapper decode failure, got: {error}"
6409 );
6410 saw_severed_stage = true;
6411
6412 let repaired = wrap_in_scalar_subquery_exec(Arc::clone(&draft.plan), context);
6413 let bytes =
6414 encode_dfplan_bytes(Arc::clone(&repaired), &codec).expect("repaired stage encodes");
6415 verify_dfplan_roundtrip(&bytes, &codec, &decode_ctx, Some(&repaired))
6416 .expect("restoring the wrapper must make the fragment decodable");
6417 }
6418 assert!(
6419 saw_severed_stage,
6420 "precondition: a q22 stage must actually fail to decode bare, or this \
6421 test proves nothing about the repair"
6422 );
6423 }
6424
6425 /// Bar 2 for q22: it must genuinely use the cluster, not merely return the
6426 /// right answer on one executor. A staged plan that produced one task per
6427 /// stage would satisfy `Some(_)` and still be a single-task query.
6428 #[tokio::test]
6429 async fn q22_distributes_instead_of_running_as_a_single_task() {
6430 let tmp = tempfile::tempdir().expect("tempdir");
6431 let ctx = q22_context(tmp.path()).await;
6432 let plan = ctx
6433 .sql(Q22)
6434 .await
6435 .expect("sql")
6436 .create_physical_plan()
6437 .await
6438 .expect("physical plan");
6439
6440 let staged = build_distributed_stages(plan)
6441 .expect("build stages")
6442 .expect("q22 must stage: a severed scalar-subquery wrapper is repaired, not declined");
6443 assert!(
6444 staged.stages.len() >= 2,
6445 "expected a map stage and a result stage, got {}",
6446 staged.stages.len()
6447 );
6448 assert!(
6449 staged.stages.iter().any(|s| s.task_count() > 1),
6450 "some stage must run more than one task, or 'distributed' means nothing: {:?}",
6451 staged
6452 .stages
6453 .iter()
6454 .map(DistributedStage::task_count)
6455 .collect::<Vec<_>>()
6456 );
6457 }
6458
6459 /// The silent wrong-answer bug q22 exposed, pinned deterministically.
6460 ///
6461 /// `PartitionMode::CollectLeft` emits its unmatched BUILD rows only after
6462 /// the last probe partition reports in. A distributed task executes ONE
6463 /// partition, so that rendezvous never happens and those rows are dropped —
6464 /// no error, no schema mismatch, just a wrong answer. q22's `NOT EXISTS`
6465 /// returned zero rows per task.
6466 ///
6467 /// Built by hand rather than planned from SQL, deliberately: DataFusion
6468 /// usually SWAPS the inputs so the smaller side builds, turning `LeftAnti`
6469 /// into `RightAnti` — which streams from the probe side and is perfectly
6470 /// safe to split. That swap is why this shape is rare, why it survived
6471 /// every sweep unnoticed, and why a test that just runs a `NOT EXISTS`
6472 /// query proves nothing: it would silently exercise the safe plan. The
6473 /// end-to-end proof over a real severed plan is
6474 /// `staged_q22_matches_direct_execution`.
6475 #[tokio::test]
6476 async fn an_unsplittable_broadcast_join_is_detected_and_converted() {
6477 use datafusion::logical_expr::JoinType;
6478 use datafusion::physical_plan::joins::{HashJoinExec, PartitionMode};
6479
6480 let tmp = tempfile::tempdir().expect("tempdir");
6481 let ctx = q22_context(tmp.path()).await;
6482 let scan = |sql: &'static str| {
6483 let ctx = ctx.clone();
6484 async move {
6485 ctx.sql(sql)
6486 .await
6487 .expect("sql")
6488 .create_physical_plan()
6489 .await
6490 .expect("physical plan")
6491 }
6492 };
6493 let build = scan("SELECT c_custkey FROM customer").await;
6494 let probe = scan("SELECT o_custkey FROM orders").await;
6495 assert!(
6496 probe.output_partitioning().partition_count() > 1,
6497 "precondition: the probe side must have several partitions, or there \
6498 is no rendezvous to miss"
6499 );
6500
6501 let on = vec![(
6502 datafusion::physical_plan::expressions::col("c_custkey", &build.schema())
6503 .expect("build key"),
6504 datafusion::physical_plan::expressions::col("o_custkey", &probe.schema())
6505 .expect("probe key"),
6506 )];
6507 let unsafe_join: Arc<dyn ExecutionPlan> = Arc::new(
6508 HashJoinExec::try_new(
6509 Arc::new(CoalescePartitionsExec::new(build)),
6510 probe,
6511 on,
6512 None,
6513 &JoinType::LeftAnti,
6514 None,
6515 PartitionMode::CollectLeft,
6516 datafusion::common::NullEquality::NullEqualsNothing,
6517 false,
6518 )
6519 .expect("hand-built broadcast anti-join"),
6520 );
6521
6522 let join_ref = unsafe_join
6523 .downcast_ref::<HashJoinExec>()
6524 .expect("hash join");
6525 assert!(
6526 is_unsplittable_broadcast_join(join_ref),
6527 "a CollectLeft LeftAnti join over a multi-partition probe must be \
6528 recognised as unsplittable"
6529 );
6530 assert!(
6531 find_unsupported_stage_node(&unsafe_join).is_some(),
6532 "and the stage guard must refuse it, so it can never ship unconverted"
6533 );
6534
6535 let converted = redistribute_unsplittable_broadcast_joins(Arc::clone(&unsafe_join))
6536 .expect("conversion must succeed");
6537 let converted_join = converted
6538 .downcast_ref::<HashJoinExec>()
6539 .expect("still a hash join");
6540 assert_eq!(
6541 *converted_join.partition_mode(),
6542 PartitionMode::Partitioned,
6543 "conversion must switch to the mode whose probe counter is per-task"
6544 );
6545 assert!(
6546 !is_unsplittable_broadcast_join(converted_join),
6547 "the converted join must no longer be unsplittable"
6548 );
6549 assert_eq!(
6550 *converted_join.join_type(),
6551 JoinType::LeftAnti,
6552 "conversion must not change the join's meaning"
6553 );
6554 assert_eq!(
6555 converted.schema(),
6556 unsafe_join.schema(),
6557 "conversion must preserve the join's output schema"
6558 );
6559 }
6560
6561 /// Hand-build a `CollectLeft` join over `build`, coalesced as the planner
6562 /// would coalesce it, probing a multi-partition `orders` scan.
6563 ///
6564 /// Shared by the broadcast-policy tests below so they differ only in the
6565 /// build side, which is the variable under test.
6566 async fn collect_left_over(
6567 ctx: &SessionContext,
6568 build_sql: &str,
6569 build_key: &str,
6570 null_aware: bool,
6571 join_type: datafusion::logical_expr::JoinType,
6572 ) -> Arc<dyn ExecutionPlan> {
6573 use datafusion::physical_plan::joins::{HashJoinExec, PartitionMode};
6574
6575 let plan = |sql: String| {
6576 let ctx = ctx.clone();
6577 async move {
6578 ctx.sql(&sql)
6579 .await
6580 .expect("sql")
6581 .create_physical_plan()
6582 .await
6583 .expect("physical plan")
6584 }
6585 };
6586 let build = plan(build_sql.to_owned()).await;
6587 let probe = plan(String::from("SELECT o_custkey FROM orders")).await;
6588 let on = vec![(
6589 datafusion::physical_plan::expressions::col(build_key, &build.schema())
6590 .expect("build key"),
6591 datafusion::physical_plan::expressions::col("o_custkey", &probe.schema())
6592 .expect("probe key"),
6593 )];
6594 Arc::new(
6595 HashJoinExec::try_new(
6596 Arc::new(CoalescePartitionsExec::new(build)),
6597 probe,
6598 on,
6599 None,
6600 &join_type,
6601 None,
6602 PartitionMode::CollectLeft,
6603 datafusion::common::NullEquality::NullEqualsNothing,
6604 null_aware,
6605 )
6606 .expect("hand-built broadcast join"),
6607 )
6608 }
6609
6610 /// The q21 defect: an estimate of ZERO must not read as "small enough to
6611 /// broadcast".
6612 ///
6613 /// At SF100 DataFusion estimates q21's `LeftAnti` self-join at
6614 /// `593462145 - 593462145 = 0` rows, and three `CollectLeft` joins stacked
6615 /// above it each broadcast on the strength of that — gathering tens of
6616 /// millions of rows to ONE partition three times over. `0 < ceiling` is the
6617 /// most convincing "broadcast me" a build side can produce, which is
6618 /// exactly backwards.
6619 #[tokio::test]
6620 async fn a_zero_estimate_is_not_proof_that_a_build_side_is_small() {
6621 use datafusion::physical_plan::joins::{HashJoinExec, PartitionMode};
6622
6623 let tmp = tempfile::tempdir().expect("tempdir");
6624 let ctx = q22_context(tmp.path()).await;
6625 // `WHERE false` gives the planner an exact zero — the same input the
6626 // anti-join's arithmetic produces, without needing SF100 to reach it.
6627 let join = collect_left_over(
6628 &ctx,
6629 "SELECT c_custkey FROM customer WHERE 1 = 0",
6630 "c_custkey",
6631 false,
6632 datafusion::logical_expr::JoinType::Inner,
6633 )
6634 .await;
6635 let join_ref = join.downcast_ref::<HashJoinExec>().expect("hash join");
6636
6637 assert!(
6638 broadcast_build_estimate_is_empty(join_ref),
6639 "a zero estimate is the estimator giving up, not a measurement"
6640 );
6641 assert!(
6642 is_degenerate_broadcast_join(join_ref),
6643 "so the join must be recognised as one that should not broadcast"
6644 );
6645 // The correctness gate must stay untouched: an Inner join drops no
6646 // unmatched build rows, so refusing to STAGE it would turn a merely
6647 // slow plan into one that declines to distribute at all.
6648 assert!(
6649 !is_unsplittable_broadcast_join(join_ref),
6650 "this is a throughput problem, not a correctness one"
6651 );
6652 assert!(
6653 find_unsupported_stage_node(&join).is_none(),
6654 "and the stage guard must not refuse it"
6655 );
6656
6657 let converted =
6658 redistribute_unsplittable_broadcast_joins(Arc::clone(&join)).expect("conversion");
6659 let converted_join = converted
6660 .downcast_ref::<HashJoinExec>()
6661 .expect("still a hash join");
6662 assert_eq!(
6663 *converted_join.partition_mode(),
6664 PartitionMode::Partitioned,
6665 "the build side must be hash-partitioned instead of gathered"
6666 );
6667 assert_eq!(
6668 converted.schema(),
6669 join.schema(),
6670 "conversion must preserve the join's output schema"
6671 );
6672 }
6673
6674 /// The q7 shape: the reducer must land on the scan that owns the key.
6675 ///
6676 /// Calls the rewrite directly rather than going through `stage_dump`,
6677 /// which prints the plan *before* staging and initialises no tracing
6678 /// subscriber — so neither its output nor its logs can show whether this
6679 /// fired. Reading "0 occurrences" off a binary that cannot print them is
6680 /// how this rule was almost declared inert.
6681 #[tokio::test]
6682 async fn the_reducer_lands_on_the_scan_that_owns_the_key() {
6683 use datafusion::physical_plan::joins::{HashJoinExec, PartitionMode};
6684
6685 let tmp = tempfile::tempdir().expect("tempdir");
6686 let ctx = q22_context(tmp.path()).await;
6687 let plan = |sql: String| {
6688 let ctx = ctx.clone();
6689 async move {
6690 ctx.sql(&sql)
6691 .await
6692 .expect("sql")
6693 .create_physical_plan()
6694 .await
6695 .expect("plan")
6696 }
6697 };
6698 // `customer` carries the key (`c_phone`); `orders` is the rest of the
6699 // fact stream. The q7 shape: the key enters at the deepest join and the
6700 // dimension that filters it sits at the top.
6701 let customer = plan(String::from("SELECT c_custkey, c_phone FROM customer")).await;
6702 let orders = plan(String::from("SELECT o_custkey FROM orders")).await;
6703 let on_fact = vec![(
6704 datafusion::physical_plan::expressions::col("c_custkey", &customer.schema())
6705 .expect("c_custkey"),
6706 datafusion::physical_plan::expressions::col("o_custkey", &orders.schema())
6707 .expect("o_custkey"),
6708 )];
6709 let fact: Arc<dyn ExecutionPlan> = Arc::new(
6710 HashJoinExec::try_new(
6711 Arc::new(CoalescePartitionsExec::new(customer)),
6712 orders,
6713 on_fact,
6714 None,
6715 &datafusion::logical_expr::JoinType::Inner,
6716 None,
6717 PartitionMode::CollectLeft,
6718 datafusion::common::NullEquality::NullEqualsNothing,
6719 false,
6720 )
6721 .expect("fact join"),
6722 );
6723
6724 // A small, filtered dimension keyed on the column `customer` supplies.
6725 let dimension = plan(String::from(
6726 "SELECT c_phone FROM customer WHERE c_custkey = 1",
6727 ))
6728 .await;
6729 let on_top = vec![(
6730 datafusion::physical_plan::expressions::col("c_phone", &dimension.schema())
6731 .expect("dim key"),
6732 datafusion::physical_plan::expressions::col("c_phone", &fact.schema())
6733 .expect("fact key"),
6734 )];
6735 let top: Arc<dyn ExecutionPlan> = Arc::new(
6736 HashJoinExec::try_new(
6737 Arc::clone(&dimension),
6738 Arc::clone(&fact),
6739 on_top,
6740 None,
6741 &datafusion::logical_expr::JoinType::Inner,
6742 None,
6743 PartitionMode::CollectLeft,
6744 datafusion::common::NullEquality::NullEqualsNothing,
6745 false,
6746 )
6747 .expect("top join"),
6748 );
6749
6750 let before = datafusion::physical_plan::displayable(top.as_ref())
6751 .indent(true)
6752 .to_string();
6753 assert_eq!(
6754 before.matches("RightSemi").count(),
6755 0,
6756 "precondition: nothing reduced yet"
6757 );
6758
6759 // The rule is opt-in, so drive the rewrite directly — the env cannot be
6760 // set from a test under `forbid(unsafe_code)`.
6761 let after_plan = reduce_by_broadcast_dimension_for_test(Arc::clone(&top))
6762 .expect("rewrite must not fail");
6763 let after = datafusion::physical_plan::displayable(after_plan.as_ref())
6764 .indent(true)
6765 .to_string();
6766
6767 assert_eq!(
6768 after.matches("RightSemi").count(),
6769 1,
6770 "exactly one reducer, attached once:\n{after}"
6771 );
6772 // It has to sit BELOW the fact join, not above it — a reducer that
6773 // lands at the top removes the same rows far too late to matter.
6774 let semi = after
6775 .lines()
6776 .position(|l| l.contains("RightSemi"))
6777 .expect("reducer");
6778 let fact_join = after
6779 .lines()
6780 .position(|l| l.contains("on=[(c_custkey"))
6781 .expect("fact join");
6782 assert!(
6783 semi > fact_join,
6784 "the reducer must be below the fact join, not above it:\n{after}"
6785 );
6786 assert_eq!(
6787 after_plan.schema(),
6788 top.schema(),
6789 "the rewrite must preserve the plan's schema exactly"
6790 );
6791 }
6792
6793 /// A rescue sized from a **single-partition probe** is not a rescue.
6794 ///
6795 /// TPC-H q21's last join is against `nation`: 25 rows in one parquet file,
6796 /// so one output partition. Sizing the replacement exchange from the probe
6797 /// alone hash-partitioned the entire `lineitem ⋈ supplier ⋈ orders`
6798 /// intermediate into ONE partition — as serial as the broadcast it replaced,
6799 /// with two exchanges and a stage boundary added for nothing. The count has
6800 /// to be the wider of the two sides.
6801 ///
6802 /// Asserting the partition count, not just the mode: the mode flipped to
6803 /// `Partitioned` in the broken version too, which is why the existing
6804 /// conversion tests could not see this.
6805 #[tokio::test]
6806 async fn a_rescue_never_narrows_to_the_probe_sides_partition_count() {
6807 use datafusion::physical_plan::joins::{HashJoinExec, PartitionMode};
6808
6809 let tmp = tempfile::tempdir().expect("tempdir");
6810 let ctx = q22_context(tmp.path()).await;
6811 let scan = ctx
6812 .sql("SELECT c_custkey FROM customer WHERE 1 = 0")
6813 .await
6814 .expect("sql")
6815 .create_physical_plan()
6816 .await
6817 .expect("physical plan");
6818 // Explicitly hash-partitioned, because that is what the build side of
6819 // this join is in the real plan: the output of the shuffle-connected
6820 // stage below it. The fixture's own scan is one file group.
6821 let build_key =
6822 datafusion::physical_plan::expressions::col("c_custkey", &scan.schema()).expect("col");
6823 let build: Arc<dyn ExecutionPlan> = Arc::new(
6824 RepartitionExec::try_new(scan, Partitioning::Hash(vec![build_key], 4))
6825 .expect("hash exchange"),
6826 );
6827 // One partition, like `nation` — a whole table small enough to land in
6828 // a single file group.
6829 let probe = ctx
6830 .sql("SELECT o_custkey FROM orders LIMIT 5")
6831 .await
6832 .expect("sql")
6833 .create_physical_plan()
6834 .await
6835 .expect("physical plan");
6836 let build_partitions = build.output_partitioning().partition_count();
6837 assert_eq!(
6838 probe.output_partitioning().partition_count(),
6839 1,
6840 "precondition: the probe must be single-partition or this tests nothing"
6841 );
6842 assert!(
6843 build_partitions > 1,
6844 "precondition: the build side must have parallelism to lose, got {build_partitions}"
6845 );
6846
6847 let on = vec![(
6848 datafusion::physical_plan::expressions::col("c_custkey", &build.schema())
6849 .expect("build key"),
6850 datafusion::physical_plan::expressions::col("o_custkey", &probe.schema())
6851 .expect("probe key"),
6852 )];
6853 let join: Arc<dyn ExecutionPlan> = Arc::new(
6854 HashJoinExec::try_new(
6855 Arc::new(CoalescePartitionsExec::new(build)),
6856 probe,
6857 on,
6858 None,
6859 &datafusion::logical_expr::JoinType::Inner,
6860 None,
6861 PartitionMode::CollectLeft,
6862 datafusion::common::NullEquality::NullEqualsNothing,
6863 false,
6864 )
6865 .expect("hand-built broadcast join"),
6866 );
6867 assert!(
6868 is_degenerate_broadcast_join(join.downcast_ref::<HashJoinExec>().expect("hash join")),
6869 "precondition: the zero estimate must make this a rescue candidate"
6870 );
6871
6872 let converted =
6873 redistribute_unsplittable_broadcast_joins(Arc::clone(&join)).expect("conversion");
6874 let converted_join = converted
6875 .downcast_ref::<HashJoinExec>()
6876 .expect("still a hash join");
6877 assert_eq!(*converted_join.partition_mode(), PartitionMode::Partitioned);
6878 assert_eq!(
6879 converted_join.left().output_partitioning().partition_count(),
6880 build_partitions,
6881 "the rescue must keep the build side's parallelism, not collapse to the probe's 1"
6882 );
6883 assert_eq!(
6884 converted_join
6885 .right()
6886 .output_partitioning()
6887 .partition_count(),
6888 build_partitions,
6889 "and both sides must agree, as PartitionMode::Partitioned requires"
6890 );
6891 assert_eq!(
6892 converted.schema(),
6893 join.schema(),
6894 "conversion must preserve the join's output schema"
6895 );
6896 }
6897
6898 /// The opposite regression, and the reason this rule is not simply "never
6899 /// broadcast".
6900 ///
6901 /// Broadcasting a genuinely small dimension side is what keeps q8/q9 from
6902 /// hash-partitioning the raw 600M-row `lineitem` scan across a ~11 MiB/s
6903 /// pod network. A scan of a small table has EXACT parquet statistics, so it
6904 /// is provably small and must survive this rule untouched.
6905 #[tokio::test]
6906 async fn a_provably_small_build_side_is_still_broadcast() {
6907 use datafusion::physical_plan::joins::{HashJoinExec, PartitionMode};
6908
6909 let tmp = tempfile::tempdir().expect("tempdir");
6910 let ctx = q22_context(tmp.path()).await;
6911 let join = collect_left_over(
6912 &ctx,
6913 "SELECT c_custkey FROM customer",
6914 "c_custkey",
6915 false,
6916 datafusion::logical_expr::JoinType::Inner,
6917 )
6918 .await;
6919 let join_ref = join.downcast_ref::<HashJoinExec>().expect("hash join");
6920
6921 assert!(
6922 !broadcast_build_estimate_is_empty(join_ref),
6923 "a non-empty parquet scan must report a positive estimate"
6924 );
6925 assert!(
6926 !is_degenerate_broadcast_join(join_ref),
6927 "so it must keep its broadcast"
6928 );
6929 let after =
6930 redistribute_unsplittable_broadcast_joins(Arc::clone(&join)).expect("conversion");
6931 assert_eq!(
6932 *after
6933 .downcast_ref::<HashJoinExec>()
6934 .expect("still a hash join")
6935 .partition_mode(),
6936 PartitionMode::CollectLeft,
6937 "the rule must leave a legitimately small broadcast alone"
6938 );
6939 }
6940
6941 /// The regression this rule caused once, pinned so it cannot come back.
6942 ///
6943 /// An earlier version demanded a positive estimate *below a ceiling*. That
6944 /// converted q8's and q9's `CollectLeft` build sides — estimated at
6945 /// `rows=~4000000, bytes=absent`, above the 1M row ceiling but entirely
6946 /// plausible — and on the cluster **q8 went 92 s -> 375 s and q9 226 s ->
6947 /// 576 s**, because the alternative to broadcasting a few million rows is
6948 /// hash-partitioning the 600M-row `lineitem` scan across an ~11 MiB/s pod
6949 /// network.
6950 ///
6951 /// The ceiling is DataFusion's call, made with the same numbers this rule
6952 /// can see. A large but positive estimate must therefore be left alone; only
6953 /// "the planner thinks this is empty" is overridden.
6954 #[tokio::test]
6955 async fn a_large_but_positive_estimate_keeps_its_broadcast() {
6956 use datafusion::physical_plan::joins::{HashJoinExec, PartitionMode};
6957
6958 let tmp = tempfile::tempdir().expect("tempdir");
6959 let ctx = q22_context(tmp.path()).await;
6960 // A cross join squares the row estimate, which is how a build side
6961 // reaches a number far above any ceiling while staying honest — the
6962 // shape of q8/q9's estimate, reachable without SF100.
6963 let join = collect_left_over(
6964 &ctx,
6965 "SELECT a.c_custkey FROM customer a CROSS JOIN customer b",
6966 "c_custkey",
6967 false,
6968 datafusion::logical_expr::JoinType::Inner,
6969 )
6970 .await;
6971 let join_ref = join.downcast_ref::<HashJoinExec>().expect("hash join");
6972
6973 let stats = join_ref
6974 .left()
6975 .partition_statistics(None)
6976 .expect("statistics");
6977 assert!(
6978 matches!(
6979 stats.num_rows,
6980 datafusion::common::stats::Precision::Exact(n)
6981 | datafusion::common::stats::Precision::Inexact(n) if n > 0
6982 ),
6983 "precondition: the build side must estimate a positive row count, \
6984 got {:?}",
6985 stats.num_rows
6986 );
6987 assert!(
6988 !broadcast_build_estimate_is_empty(join_ref),
6989 "a positive estimate is a measurement, however large"
6990 );
6991 assert!(
6992 !is_degenerate_broadcast_join(join_ref),
6993 "and must not be converted — overriding DataFusion's ceiling cost \
6994 q8 4x and q9 2.5x"
6995 );
6996 let after =
6997 redistribute_unsplittable_broadcast_joins(Arc::clone(&join)).expect("conversion");
6998 assert_eq!(
6999 *after
7000 .downcast_ref::<HashJoinExec>()
7001 .expect("still a hash join")
7002 .partition_mode(),
7003 PartitionMode::CollectLeft,
7004 "the plan must come back untouched"
7005 );
7006 }
7007
7008 /// A null-aware anti join is only correct as `CollectLeft`.
7009 ///
7010 /// It tracks probe-side state across the whole build side, and DataFusion
7011 /// rejects any other partition mode for it at construction — so however
7012 /// badly estimated its build side is, converting it would trade a slow
7013 /// query for one that does not run.
7014 #[tokio::test]
7015 async fn a_null_aware_anti_join_is_never_converted() {
7016 use datafusion::physical_plan::joins::HashJoinExec;
7017
7018 let tmp = tempfile::tempdir().expect("tempdir");
7019 let ctx = q22_context(tmp.path()).await;
7020 let join = collect_left_over(
7021 &ctx,
7022 "SELECT c_custkey FROM customer WHERE 1 = 0",
7023 "c_custkey",
7024 true,
7025 datafusion::logical_expr::JoinType::LeftAnti,
7026 )
7027 .await;
7028 let join_ref = join.downcast_ref::<HashJoinExec>().expect("hash join");
7029
7030 assert!(
7031 broadcast_build_estimate_is_empty(join_ref),
7032 "precondition: its build-side estimate is degenerate, so only the \
7033 null-aware check can be what spares it"
7034 );
7035 assert!(
7036 !is_degenerate_broadcast_join(join_ref),
7037 "a null-aware anti join must never be converted for throughput"
7038 );
7039 }
7040
7041 /// And the repaired plan must still compute q22's actual answer. The
7042 /// wrapper is re-evaluated per stage, so every task resolves the subquery
7043 /// independently — this is what proves they all resolve it to the same
7044 /// value the single-node plan uses.
7045 #[tokio::test]
7046 async fn staged_q22_matches_direct_execution() {
7047 let tmp = tempfile::tempdir().expect("tempdir");
7048 let ctx = q22_context(tmp.path()).await;
7049 let expected = render(&direct(&ctx, Q22).await);
7050 let actual = run_staged(&ctx, Q22)
7051 .await
7052 .unwrap_or_else(|e| panic!("q22: staged execution failed: {e}"));
7053 assert_eq!(
7054 render(&actual),
7055 expected,
7056 "q22: staged result differs from single-node execution"
7057 );
7058 assert!(!expected.is_empty(), "the q22 fixture must produce rows");
7059 }
7060
7061 /// `broadcast_bytes: Some(0)` reproduces the cluster's join shape: neither
7062 /// side is small enough to collect, so both hash-shuffle and the reduce
7063 /// stage gets **two** `ShuffleReadExec` leaves over two upstream stages.
7064 /// Every other test here runs the broadcast shape, because a fixture that
7065 /// fits in a process is always under the 32 MiB ceiling.
7066 async fn tpch_context_with_broadcast(
7067 dir: &std::path::Path,
7068 join_threshold: Option<u64>,
7069 broadcast_bytes: Option<usize>,
7070 ) -> SessionContext {
7071 let (lineitem, part) = write_tpch_fixture(dir);
7072 let ctx = planning_session_context_with_options(4, join_threshold, broadcast_bytes);
7073 for (name, path) in [("lineitem", lineitem), ("part", part)] {
7074 ctx.register_parquet(
7075 name,
7076 path.to_str().expect("utf8 path"),
7077 ParquetReadOptions::default(),
7078 )
7079 .await
7080 .expect("register parquet");
7081 }
7082 ctx
7083 }
7084
7085 /// Consistent test-side routing; any consistent hash is correct here.
7086 fn route(batch: &RecordBatch, key_column: &str, num_partitions: usize) -> Vec<RecordBatch> {
7087 use std::hash::{Hash as _, Hasher as _};
7088 let key_idx = batch.schema().index_of(key_column).expect("key column");
7089 let column = batch.column(key_idx);
7090 let mut selections: Vec<Vec<u32>> = vec![Vec::new(); num_partitions];
7091 for row in 0..batch.num_rows() {
7092 let value = arrow::util::display::array_value_to_string(column, row).expect("value");
7093 let mut hasher = std::collections::hash_map::DefaultHasher::new();
7094 value.hash(&mut hasher);
7095 let bucket = (hasher.finish() as usize) % num_partitions;
7096 selections[bucket].push(row as u32);
7097 }
7098 selections
7099 .into_iter()
7100 .map(|rows| {
7101 let indices = arrow::array::UInt32Array::from(rows);
7102 arrow::compute::take_record_batch(batch, &indices).expect("take")
7103 })
7104 .collect()
7105 }
7106
7107 /// Run every stage in dependency order, exactly as the cluster does.
7108 async fn run_staged(ctx: &SessionContext, sql: &str) -> Result<Vec<RecordBatch>, String> {
7109 let df = ctx.sql(sql).await.map_err(|e| e.to_string())?;
7110 let plan = df.create_physical_plan().await.map_err(|e| e.to_string())?;
7111 let staged = build_distributed_stages(plan)
7112 .map_err(|e| e.to_string())?
7113 .ok_or_else(|| String::from("declined to stage"))?;
7114
7115 let store = Arc::new(StageStore::default());
7116 let exec_ctx = fragment_decode_session_context();
7117 let mut result = Vec::new();
7118 for (stage_index, stage) in staged.stages.iter().enumerate() {
7119 for (task_index, body) in stage.task_bodies.iter().enumerate() {
7120 let reader: Arc<dyn ShufflePartitionReader> = Arc::new(Arc::clone(&store));
7121 let (declared, mut stream) = execute_dfplan_body(body, &exec_ctx, Some(reader))
7122 .map_err(|e| format!("stage {stage_index} task {task_index} start: {e}"))?;
7123 while let Some(batch) = futures::StreamExt::next(&mut stream).await {
7124 let batch =
7125 batch.map_err(|e| format!("stage {stage_index} task {task_index}: {e}"))?;
7126 // The invariant the cluster depends on and this harness
7127 // would otherwise hide: the store here hands the *same*
7128 // batches back, so a stage whose declared schema disagrees
7129 // with its produced batches sails through in process and
7130 // only dies on the wire, where the reduce side concatenates
7131 // real IPC data against the declared schema and Arrow says
7132 // "column types must match schema types".
7133 if batch.schema() != declared {
7134 return Err(format!(
7135 "stage {stage_index} task {task_index} declares {declared:?} but \
7136 produced {:?}; ShuffleReadExec labels the reduce side with the \
7137 declared schema, so this disagreement becomes a reduce-side Arrow \
7138 error on a real cluster",
7139 batch.schema()
7140 ));
7141 }
7142 if batch.num_rows() == 0 {
7143 continue;
7144 }
7145 match &stage.shuffle {
7146 None => result.push(batch),
7147 Some(shuffle) => match shuffle.key_columns.first() {
7148 Some(key) => {
7149 for (bucket, part) in
7150 route(&batch, key, shuffle.num_output_partitions)
7151 .into_iter()
7152 .enumerate()
7153 {
7154 if part.num_rows() > 0 {
7155 store
7156 .partitions
7157 .lock()
7158 .expect("store lock")
7159 .entry((stage_index, task_index, bucket))
7160 .or_default()
7161 .push(part);
7162 }
7163 }
7164 }
7165 // A keyless shuffle is a gather: everything to 0.
7166 None => store
7167 .partitions
7168 .lock()
7169 .expect("store lock")
7170 .entry((stage_index, task_index, 0))
7171 .or_default()
7172 .push(batch),
7173 },
7174 }
7175 }
7176 }
7177 }
7178 Ok(result)
7179 }
7180
7181 fn render(batches: &[RecordBatch]) -> Vec<String> {
7182 let mut rows: Vec<String> = batches
7183 .iter()
7184 .flat_map(|b| {
7185 (0..b.num_rows()).map(move |r| {
7186 (0..b.num_columns())
7187 .map(|c| {
7188 arrow::util::display::array_value_to_string(b.column(c), r)
7189 .expect("cell")
7190 })
7191 .collect::<Vec<_>>()
7192 .join("|")
7193 })
7194 })
7195 .collect();
7196 rows.sort();
7197 rows
7198 }
7199
7200 async fn direct(ctx: &SessionContext, sql: &str) -> Vec<RecordBatch> {
7201 ctx.sql(sql)
7202 .await
7203 .expect("sql")
7204 .collect()
7205 .await
7206 .expect("direct execution")
7207 }
7208
7209 /// The staged answer must equal the single-node answer, with and without
7210 /// the spillable-join conversion active.
7211 ///
7212 /// `Some(0)` forces every join whose build size is known to convert to
7213 /// sort-merge — the state a memory-capped executor is in, and the state
7214 /// this build box never reaches on its own. The rule claims to preserve
7215 /// the join's output schema exactly (`schema_check()` returns true), so
7216 /// converting *more* joins than production would must still be correct;
7217 /// if it is not, the claim is false.
7218 async fn staged_matches_direct(sql: &str, join_threshold: Option<u64>, label: &str) {
7219 staged_matches_direct_with_broadcast(sql, join_threshold, None, label).await;
7220 }
7221
7222 async fn staged_matches_direct_with_broadcast(
7223 sql: &str,
7224 join_threshold: Option<u64>,
7225 broadcast_bytes: Option<usize>,
7226 label: &str,
7227 ) {
7228 let tmp = tempfile::tempdir().expect("tempdir");
7229 let ctx = tpch_context_with_broadcast(tmp.path(), join_threshold, broadcast_bytes).await;
7230 let expected = render(&direct(&ctx, sql).await);
7231 let actual = run_staged(&ctx, sql)
7232 .await
7233 .unwrap_or_else(|e| panic!("{label}: staged execution failed: {e}"));
7234 assert_eq!(
7235 render(&actual),
7236 expected,
7237 "{label}: staged result differs from single-node execution"
7238 );
7239 }
7240
7241 #[tokio::test]
7242 async fn staged_q17_matches_direct_execution() {
7243 staged_matches_direct(Q17, None, "q17/unconverted").await;
7244 }
7245
7246 #[tokio::test]
7247 async fn staged_q17_matches_direct_execution_with_converted_joins() {
7248 staged_matches_direct(Q17, Some(0), "q17/converted").await;
7249 }
7250
7251 #[tokio::test]
7252 async fn staged_q19_matches_direct_execution() {
7253 staged_matches_direct(Q19, None, "q19/unconverted").await;
7254 }
7255
7256 #[tokio::test]
7257 async fn staged_q19_matches_direct_execution_with_converted_joins() {
7258 staged_matches_direct(Q19, Some(0), "q19/converted").await;
7259 }
7260
7261 /// The shape the cluster actually runs: no broadcast, so both join sides
7262 /// hash-shuffle and the reduce stage reads two upstream stages.
7263 ///
7264 /// q17 and q19 pass every broadcast-shaped test above and still fail at
7265 /// SF100 with a bare Arrow type error, so the defect lives in what the
7266 /// broadcast shape never builds.
7267 #[tokio::test]
7268 async fn staged_q17_matches_direct_execution_without_broadcast() {
7269 staged_matches_direct_with_broadcast(Q17, None, Some(0), "q17/no-broadcast").await;
7270 }
7271
7272 #[tokio::test]
7273 async fn staged_q19_matches_direct_execution_without_broadcast() {
7274 staged_matches_direct_with_broadcast(Q19, None, Some(0), "q19/no-broadcast").await;
7275 }
7276
7277 /// The cell the matrix was missing — and the only one the cluster is in.
7278 ///
7279 /// The conversion tests above all run the *broadcast* shape, and the
7280 /// no-broadcast tests all run *unconverted* joins. SF100 does both at once:
7281 /// no build side is under the 32 MiB ceiling, so both sides hash-shuffle,
7282 /// **and** the build sides are far over the spill threshold, so
7283 /// `SpillableJoinSelection` rewrites them to sort-merge. Two settings that
7284 /// are each covered alone and never together.
7285 ///
7286 /// That combination is what `reapply_projection` runs in: a projected join
7287 /// whose converted form is a `SortMergeJoinExec` (which has no projection of
7288 /// its own) sitting under a shuffle, where the reduce side concatenates real
7289 /// IPC data against the declared schema.
7290 #[tokio::test]
7291 async fn staged_q17_matches_direct_execution_converted_and_without_broadcast() {
7292 staged_matches_direct_with_broadcast(Q17, Some(0), Some(0), "q17/converted+no-broadcast")
7293 .await;
7294 }
7295
7296 #[tokio::test]
7297 async fn staged_q19_matches_direct_execution_converted_and_without_broadcast() {
7298 staged_matches_direct_with_broadcast(Q19, Some(0), Some(0), "q19/converted+no-broadcast")
7299 .await;
7300 }
7301
7302 #[tokio::test]
7303 async fn staged_q22_matches_direct_execution_without_broadcast() {
7304 let tmp = tempfile::tempdir().expect("tempdir");
7305 let ctx = q22_context_with_broadcast(tmp.path(), Some(0)).await;
7306 let expected = render(&direct(&ctx, Q22).await);
7307 let actual = run_staged(&ctx, Q22)
7308 .await
7309 .unwrap_or_else(|e| panic!("q22/no-broadcast: staged execution failed: {e}"));
7310 assert_eq!(
7311 render(&actual),
7312 expected,
7313 "q22/no-broadcast: staged result differs from single-node execution"
7314 );
7315 assert!(!expected.is_empty(), "the q22 fixture must produce rows");
7316 }
7317
7318 /// `avg` over a decimal, cut so the Final aggregate lands in a different
7319 /// stage from its Partial — q17's shape, reduced to the one operator.
7320 ///
7321 /// `datafusion-proto` carries no output type for an aggregate: the decoder
7322 /// resolves the UDAF by name and `AggregateExprBuilder::build()` re-derives
7323 /// the return type from the resolved function and its *input* types. A
7324 /// Final aggregate's inputs are the Partial's **state** columns, not the
7325 /// original column, so if the rebuild reads them as ordinary inputs it
7326 /// produces a wider decimal than the coordinator planned — which is exactly
7327 /// what q17 reports from SF100:
7328 ///
7329 /// expected Decimal128(15, 2) but found Decimal128(30, 15)
7330 ///
7331 /// Both the ungrouped (gather-cut) and grouped (hash-exchange-cut) forms
7332 /// are covered: they take different arms of `cut_exchanges`.
7333 #[tokio::test]
7334 async fn a_final_avg_over_a_decimal_survives_the_fragment_round_trip() {
7335 for sql in [
7336 "SELECT avg(l_quantity) FROM lineitem",
7337 "SELECT l_partkey, avg(l_quantity) FROM lineitem GROUP BY l_partkey",
7338 "SELECT sum(l_extendedprice) / 7.0 FROM lineitem",
7339 ] {
7340 let tmp = tempfile::tempdir().expect("tempdir");
7341 let ctx = tpch_context_with_broadcast(tmp.path(), None, None).await;
7342 let expected = render(&direct(&ctx, sql).await);
7343 let actual = run_staged(&ctx, sql)
7344 .await
7345 .unwrap_or_else(|e| panic!("{sql}: staged execution failed: {e}"));
7346 assert_eq!(
7347 render(&actual),
7348 expected,
7349 "{sql}: staged result differs from single-node execution"
7350 );
7351 }
7352 }
7353
7354 /// A reduce stage really does read two distinct upstream stages once
7355 /// broadcasting is off — the precondition the three tests above depend on.
7356 /// Without this, a planner change that quietly restored a broadcast join
7357 /// would turn them into duplicates of the tests they were written to
7358 /// complement, and nothing would say so.
7359 #[tokio::test]
7360 async fn without_broadcast_a_reduce_stage_reads_two_upstream_stages() {
7361 let tmp = tempfile::tempdir().expect("tempdir");
7362 let ctx = tpch_context_with_broadcast(tmp.path(), None, Some(0)).await;
7363 let plan = ctx
7364 .sql(Q19)
7365 .await
7366 .expect("sql")
7367 .create_physical_plan()
7368 .await
7369 .expect("physical plan");
7370 let staged = build_distributed_stages(plan)
7371 .expect("staging must not error")
7372 .expect("q19 must stage");
7373 let widest = staged
7374 .stages
7375 .iter()
7376 .map(|stage| stage.upstream_stage_indexes.len())
7377 .max()
7378 .unwrap_or(0);
7379 assert!(
7380 widest >= 2,
7381 "expected a stage reading 2+ upstream stages, widest was {widest}; \
7382 the no-broadcast tests are not exercising the cluster's join shape"
7383 );
7384 }
7385}
7386
7387#[cfg(test)]
7388#[allow(clippy::unwrap_used, clippy::expect_used)]
7389mod codec_completeness_tests {
7390 /// Every custom `ExecutionPlan` in this crate must be either encodable by
7391 /// [`KrishivPhysicalCodec`] or explicitly declared execution-local.
7392 ///
7393 /// # The failure this prevents
7394 ///
7395 /// `datafusion-proto` cannot encode a node the extension codec does not
7396 /// know. The scheduler's response to a stage plan it cannot encode is not an
7397 /// error — it is to abandon staging and run the whole query as a **single
7398 /// task**:
7399 ///
7400 /// ```text
7401 /// stage plan cannot be encoded and decoded; running this query as a
7402 /// SINGLE TASK ... Unsupported plan and extension codec failed
7403 /// ```
7404 ///
7405 /// So adding an operator without a codec entry does not break loudly. It
7406 /// quietly un-distributes every query the operator touches while continuing
7407 /// to report success. `GraceHashJoinExec` did exactly that to TPC-H q10,
7408 /// q17, q19 and q21 — hours of cluster time reading as passes.
7409 ///
7410 /// A source scan rather than a type-level check because Rust cannot
7411 /// enumerate trait impls at runtime; this mirrors
7412 /// `krishiv_common::env_registry`'s
7413 /// `every_flag_read_in_source_is_declared`, which exists for the same
7414 /// reason.
7415 #[test]
7416 fn every_custom_execution_plan_is_encodable_or_declared_local() {
7417 // Nodes that may appear in a plan the coordinator encodes. Adding one
7418 // here without a `try_encode`/`try_decode` arm re-opens the bug.
7419 // `runtime_filters::the_injected_stages_round_trip_through_the_codec` is
7420 // what makes listing the two filter nodes here a fact rather than a
7421 // promise: it encodes and decodes a plan containing both.
7422 const ENCODABLE: &[&str] = &[
7423 "RuntimeFilterBuildExec",
7424 "RuntimeFilterProbeExec",
7425 "ShuffleReadExec",
7426 ];
7427 // Nodes that are constructed only AFTER decode and never serialized.
7428 // `GraceHashJoinExec` is chosen per-executor from live memory pressure
7429 // (`apply_local_spill_strategy`); `OnceStreamExec` wraps an already-open
7430 // spill-file stream, which has no meaning on another machine.
7431 const EXECUTION_LOCAL: &[&str] = &["GraceHashJoinExec", "OnceStreamExec"];
7432
7433 let mut found = Vec::new();
7434 let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
7435 let mut stack = vec![dir];
7436 while let Some(path) = stack.pop() {
7437 for entry in std::fs::read_dir(&path).expect("read src") {
7438 let entry = entry.expect("dir entry").path();
7439 if entry.is_dir() {
7440 stack.push(entry);
7441 continue;
7442 }
7443 if entry.extension().is_none_or(|e| e != "rs") {
7444 continue;
7445 }
7446 let text = std::fs::read_to_string(&entry).expect("read file");
7447 for line in text.lines() {
7448 if let Some(rest) = line.trim().strip_prefix("impl ExecutionPlan for ") {
7449 let name = rest
7450 .trim_end_matches(" {")
7451 .split(['<', ' '])
7452 .next()
7453 .unwrap_or(rest)
7454 .to_string();
7455 found.push(name);
7456 }
7457 }
7458 }
7459 }
7460 found.sort();
7461 found.dedup();
7462 assert!(
7463 !found.is_empty(),
7464 "the scan found no ExecutionPlan impls at all"
7465 );
7466
7467 let undeclared: Vec<&String> = found
7468 .iter()
7469 .filter(|n| !ENCODABLE.contains(&n.as_str()) && !EXECUTION_LOCAL.contains(&n.as_str()))
7470 .collect();
7471 assert!(
7472 undeclared.is_empty(),
7473 "custom ExecutionPlan(s) {undeclared:?} are neither encodable nor declared \
7474 execution-local. If such a node can reach a stage plan, the coordinator will \
7475 silently run the query as a SINGLE TASK. Add a codec arm, or confine it to \
7476 post-decode and list it in EXECUTION_LOCAL."
7477 );
7478 }
7479 /// An AQE rewrite rebuilds every reduce task body through
7480 /// `dfplan_body_with_spec`. If that drops the Python-UDF directive prefix,
7481 /// the rebuilt task ships a plan referencing a UDF the executor was never
7482 /// told to reconstruct, and it dies with "PhysicalExtensionCodec is not
7483 /// provided for scalar function <name>" — while its sibling map tasks,
7484 /// whose bodies are never rebuilt, run fine.
7485 #[test]
7486 fn rebuilding_a_body_keeps_the_python_udf_directive() {
7487 use super::{
7488 DfplanTaskSpec, dfplan_body_partition_spec, dfplan_body_with_spec, is_dfplan_body,
7489 };
7490 let directive = "/* krishiv-register-python-udf:addk:int64:int64:QUJD */";
7491 let body = format!("{directive}\ndfplan:v1:0:QUJD");
7492 let spec = DfplanTaskSpec {
7493 partitions: vec![3, 4],
7494 map_range: None,
7495 };
7496 let rebuilt = dfplan_body_with_spec(&body, &spec).expect("rebuild");
7497 assert!(
7498 rebuilt.starts_with(directive),
7499 "the rebuilt body must still carry the UDF directive: {rebuilt}"
7500 );
7501 assert!(
7502 is_dfplan_body(&rebuilt),
7503 "and must still parse as a dfplan body: {rebuilt}"
7504 );
7505 assert_eq!(
7506 dfplan_body_partition_spec(&rebuilt)
7507 .expect("spec")
7508 .partitions,
7509 vec![3, 4],
7510 "the new partition spec must be the one asked for"
7511 );
7512 }
7513
7514 // ── Stage reuse (ReuseExchange) ────────────────────────────────────────
7515
7516 mod stage_reuse {
7517 use super::super::*;
7518
7519 fn scan_schema() -> SchemaRef {
7520 Arc::new(arrow::datatypes::Schema::new(vec![
7521 arrow::datatypes::Field::new("k", arrow::datatypes::DataType::Int64, false),
7522 arrow::datatypes::Field::new("v", arrow::datatypes::DataType::Int64, false),
7523 ]))
7524 }
7525
7526 /// A leaf stage: an in-memory scan, no shuffle read inside it.
7527 fn leaf_draft(rows: usize, key: &str, parts: usize) -> StageDraft {
7528 use datafusion::catalog::memory::MemorySourceConfig;
7529 use datafusion::datasource::source::DataSourceExec;
7530 let schema = scan_schema();
7531 let batches: Vec<Vec<arrow::record_batch::RecordBatch>> = vec![vec![
7532 arrow::record_batch::RecordBatch::try_new(
7533 Arc::clone(&schema),
7534 vec![
7535 Arc::new(arrow::array::Int64Array::from(
7536 (0..rows as i64).collect::<Vec<_>>(),
7537 )),
7538 Arc::new(arrow::array::Int64Array::from(
7539 (0..rows as i64).collect::<Vec<_>>(),
7540 )),
7541 ],
7542 )
7543 .expect("batch"),
7544 ]];
7545 let source = MemorySourceConfig::try_new(&batches, Arc::clone(&schema), None)
7546 .expect("memory source");
7547 StageDraft {
7548 plan: Arc::new(DataSourceExec::new(Arc::new(source))),
7549 shuffle: Some(StageShuffleOutput {
7550 key_columns: vec![String::from(key)],
7551 num_output_partitions: parts,
7552 }),
7553 subqueries: None,
7554 }
7555 }
7556
7557 fn reader(stage: usize) -> Arc<dyn ExecutionPlan> {
7558 Arc::new(ShuffleReadExec::new(stage, 4, 4, scan_schema(), None))
7559 }
7560
7561 /// Two identical leaf stages collapse into one, and the consumer that
7562 /// pointed at the removed stage is repointed at the survivor. This is
7563 /// q18: `lineitem[l_orderkey, l_quantity]` scanned twice, unfiltered.
7564 #[test]
7565 fn identical_leaf_stages_collapse_and_readers_are_repointed() {
7566 let mut drafts = vec![leaf_draft(4, "k", 4), leaf_draft(4, "k", 4)];
7567 // Root reads BOTH stages; after the collapse both reads must
7568 // resolve to the surviving stage 0.
7569 let mut root: Arc<dyn ExecutionPlan> =
7570 datafusion::physical_plan::union::UnionExec::try_new(vec![reader(0), reader(1)])
7571 .expect("union");
7572
7573 let removed = dedupe_identical_stages_unconditionally(&mut root, &mut drafts);
7574
7575 assert_eq!(
7576 removed, 1,
7577 "one of the two identical stages must be removed"
7578 );
7579 assert_eq!(drafts.len(), 1, "one stage must survive");
7580 let upstreams = collect_upstream_stage_indexes(&root);
7581 assert_eq!(
7582 upstreams,
7583 vec![0],
7584 "both readers must point at the surviving stage, got {upstreams:?}"
7585 );
7586 }
7587
7588 /// Stages that compute the same rows but partition them differently are
7589 /// NOT interchangeable — the consumer reads by partition index.
7590 #[test]
7591 fn different_shuffle_contracts_do_not_collapse() {
7592 let mut drafts = vec![leaf_draft(4, "k", 4), leaf_draft(4, "k", 8)];
7593 let mut root: Arc<dyn ExecutionPlan> =
7594 datafusion::physical_plan::union::UnionExec::try_new(vec![reader(0), reader(1)])
7595 .expect("union");
7596 assert_eq!(
7597 dedupe_identical_stages_unconditionally(&mut root, &mut drafts),
7598 0,
7599 "a different output-partition count is a different stage"
7600 );
7601 assert_eq!(drafts.len(), 2);
7602 }
7603
7604 /// Same rows, different hash key: also not interchangeable.
7605 #[test]
7606 fn different_shuffle_keys_do_not_collapse() {
7607 let mut drafts = vec![leaf_draft(4, "k", 4), leaf_draft(4, "v", 4)];
7608 let mut root: Arc<dyn ExecutionPlan> =
7609 datafusion::physical_plan::union::UnionExec::try_new(vec![reader(0), reader(1)])
7610 .expect("union");
7611 assert_eq!(
7612 dedupe_identical_stages_unconditionally(&mut root, &mut drafts),
7613 0,
7614 "a different partitioning key is a different stage"
7615 );
7616 }
7617
7618 /// Stages that differ in content must never be merged — the guard that
7619 /// keeps this from silently returning wrong answers.
7620 #[test]
7621 fn different_content_does_not_collapse() {
7622 let mut drafts = vec![leaf_draft(4, "k", 4), leaf_draft(9, "k", 4)];
7623 let mut root: Arc<dyn ExecutionPlan> =
7624 datafusion::physical_plan::union::UnionExec::try_new(vec![reader(0), reader(1)])
7625 .expect("union");
7626 assert_eq!(
7627 dedupe_identical_stages_unconditionally(&mut root, &mut drafts),
7628 0,
7629 "stages producing different rows must stay separate"
7630 );
7631 assert_eq!(drafts.len(), 2);
7632 }
7633
7634 /// Non-leaf stages are out of scope: a stage containing a shuffle read
7635 /// carries stage indexes of its own, and collapsing it could invalidate
7636 /// a reference inside it.
7637 #[test]
7638 fn non_leaf_stages_are_left_alone() {
7639 let mk = || StageDraft {
7640 plan: reader(7),
7641 shuffle: Some(StageShuffleOutput {
7642 key_columns: vec![String::from("k")],
7643 num_output_partitions: 4,
7644 }),
7645 subqueries: None,
7646 };
7647 let mut drafts = vec![mk(), mk()];
7648 let mut root: Arc<dyn ExecutionPlan> =
7649 datafusion::physical_plan::union::UnionExec::try_new(vec![reader(0), reader(1)])
7650 .expect("union");
7651 assert_eq!(
7652 dedupe_identical_stages_unconditionally(&mut root, &mut drafts),
7653 0,
7654 "stages that read a shuffle are not eligible for leaf reuse"
7655 );
7656 }
7657
7658 /// Three identical stages collapse to one, and every reader lands on it.
7659 /// This is q21's shape, where lineitem is scanned three times.
7660 #[test]
7661 fn three_identical_stages_collapse_to_one() {
7662 let mut drafts = vec![
7663 leaf_draft(4, "k", 4),
7664 leaf_draft(4, "k", 4),
7665 leaf_draft(4, "k", 4),
7666 ];
7667 let mut root: Arc<dyn ExecutionPlan> =
7668 datafusion::physical_plan::union::UnionExec::try_new(vec![
7669 reader(0),
7670 reader(1),
7671 reader(2),
7672 ])
7673 .expect("union");
7674 assert_eq!(
7675 dedupe_identical_stages_unconditionally(&mut root, &mut drafts),
7676 2
7677 );
7678 assert_eq!(drafts.len(), 1);
7679 assert_eq!(collect_upstream_stage_indexes(&root), vec![0]);
7680 }
7681
7682 /// Surviving stages keep their relative order and their readers are
7683 /// renumbered — an off-by-one here silently feeds a consumer the wrong
7684 /// stage's data, which is a wrong answer, not a slow query.
7685 #[test]
7686 fn survivor_indexes_are_compacted_correctly() {
7687 // 0: A, 1: B, 2: A(dup of 0), 3: C
7688 let mut drafts = vec![
7689 leaf_draft(4, "k", 4),
7690 leaf_draft(7, "k", 4),
7691 leaf_draft(4, "k", 4),
7692 leaf_draft(9, "k", 4),
7693 ];
7694 let mut root: Arc<dyn ExecutionPlan> =
7695 datafusion::physical_plan::union::UnionExec::try_new(vec![
7696 reader(1),
7697 reader(2),
7698 reader(3),
7699 ])
7700 .expect("union");
7701 assert_eq!(
7702 dedupe_identical_stages_unconditionally(&mut root, &mut drafts),
7703 1
7704 );
7705 assert_eq!(drafts.len(), 3, "A, B, C survive");
7706 // B was 1 -> 1, the dup of A was 2 -> 0, C was 3 -> 2.
7707 assert_eq!(collect_upstream_stage_indexes(&root), vec![0, 1, 2]);
7708 }
7709
7710 /// Reuse replaces two evaluations with one, which is only sound for a
7711 /// deterministic subtree.
7712 #[test]
7713 fn volatile_markers_block_reuse() {
7714 assert!(
7715 VOLATILE_MARKERS.contains(&"random("),
7716 "random() must block reuse"
7717 );
7718 assert!(VOLATILE_MARKERS.contains(&"now("), "now() must block reuse");
7719 assert!(
7720 VOLATILE_MARKERS.contains(&"uuid("),
7721 "uuid() must block reuse"
7722 );
7723 }
7724
7725 /// The flag gates it: default off, so a plan is untouched until the
7726 /// rule has been measured.
7727 #[test]
7728 fn reuse_is_off_by_default() {
7729 let mut drafts = vec![leaf_draft(4, "k", 4), leaf_draft(4, "k", 4)];
7730 let mut root: Arc<dyn ExecutionPlan> =
7731 datafusion::physical_plan::union::UnionExec::try_new(vec![reader(0), reader(1)])
7732 .expect("union");
7733 if std::env::var(STAGE_REUSE_ENV).is_err() {
7734 assert_eq!(
7735 dedupe_identical_stages(&mut root, &mut drafts),
7736 0,
7737 "stage reuse must be off unless {STAGE_REUSE_ENV} is set"
7738 );
7739 }
7740 }
7741 }
7742
7743 // ── Cross-stage runtime filters ────────────────────────────────────────
7744
7745 mod runtime_filters {
7746 use super::super::*;
7747 use datafusion::common::{JoinType, NullEquality};
7748 use datafusion::physical_expr::expressions::Column;
7749 use datafusion::physical_plan::joins::{HashJoinExec, PartitionMode};
7750
7751 /// A sort-merge join across two stages now yields a candidate, exactly
7752 /// as the equivalent hash join does.
7753 ///
7754 /// This is the shape TPC-H q21 reaches the cutter in — see
7755 /// `a_sort_merge_join_is_counted_as_unreadable_not_as_no_join` for the
7756 /// measurement that produced it. Which algorithm the planner picked is
7757 /// not a property of the filter: a bloom over the join keys drops probe
7758 /// rows that cannot match either way.
7759 #[test]
7760 fn a_sort_merge_join_across_stages_is_a_candidate() {
7761 use datafusion::physical_expr::{LexOrdering, PhysicalSortExpr};
7762 use datafusion::physical_plan::joins::SortMergeJoinExec;
7763
7764 let build = read(0, Some(1_000), "k", arrow::datatypes::DataType::Int64);
7765 let probe = read(1, Some(10_000_000), "k", arrow::datatypes::DataType::Int64);
7766 let on: Vec<(
7767 Arc<dyn datafusion::physical_expr::PhysicalExpr>,
7768 Arc<dyn datafusion::physical_expr::PhysicalExpr>,
7769 )> = vec![(Arc::new(Column::new("k", 0)), Arc::new(Column::new("k", 0)))];
7770 let sort_options = LexOrdering::new(vec![PhysicalSortExpr::new_default(Arc::new(
7771 Column::new("k", 0),
7772 ))])
7773 .expect("ordering")
7774 .iter()
7775 .map(|e| e.options)
7776 .collect();
7777 let smj = SortMergeJoinExec::try_new(
7778 build,
7779 probe,
7780 on,
7781 None,
7782 JoinType::Inner,
7783 sort_options,
7784 NullEquality::NullEqualsNothing,
7785 )
7786 .expect("sort-merge join over two shuffle reads");
7787
7788 let plan: Arc<dyn ExecutionPlan> = Arc::new(smj);
7789 let mut candidates = Vec::new();
7790 let mut rejects = RuntimeFilterRejects::default();
7791 collect_runtime_filter_candidates(&plan, &mut candidates, &mut rejects);
7792
7793 assert_eq!(
7794 rejects.joins, 1,
7795 "the sort-merge join must now be inspected like any other equijoin"
7796 );
7797 assert_eq!(
7798 rejects.joins_of_unsupported_kind, 0,
7799 "and must no longer be written off as unreadable"
7800 );
7801 assert_eq!(
7802 candidates.len(),
7803 1,
7804 "a selective cross-stage join is a candidate"
7805 );
7806 assert_eq!(candidates[0].build_stage, 0);
7807 assert_eq!(candidates[0].probe_stage, 1);
7808 }
7809
7810 /// A nested-loop join stays uncounted-as-inspected: it has no equijoin
7811 /// pairs at all, so there is no key to build a filter over. That is a
7812 /// category error, not a gap to close.
7813 #[test]
7814 fn a_nested_loop_join_remains_unreadable() {
7815 use datafusion::physical_plan::joins::NestedLoopJoinExec;
7816
7817 let left = read(0, Some(1_000), "k", arrow::datatypes::DataType::Int64);
7818 let right = read(1, Some(10_000_000), "k", arrow::datatypes::DataType::Int64);
7819 let nlj = NestedLoopJoinExec::try_new(left, right, None, &JoinType::Inner, None)
7820 .expect("nested-loop join");
7821
7822 let plan: Arc<dyn ExecutionPlan> = Arc::new(nlj);
7823 let mut candidates = Vec::new();
7824 let mut rejects = RuntimeFilterRejects::default();
7825 collect_runtime_filter_candidates(&plan, &mut candidates, &mut rejects);
7826
7827 assert_eq!(rejects.joins_of_unsupported_kind, 1);
7828 assert_eq!(rejects.joins, 0);
7829 assert!(candidates.is_empty());
7830 }
7831
7832 fn schema(name: &str, key: arrow::datatypes::DataType) -> SchemaRef {
7833 Arc::new(arrow::datatypes::Schema::new(vec![
7834 arrow::datatypes::Field::new(name, key, false),
7835 arrow::datatypes::Field::new("payload", arrow::datatypes::DataType::Utf8, false),
7836 ]))
7837 }
7838
7839 /// A stage draft whose plan is a bare shuffle read of `stage`, standing
7840 /// in for whatever subtree really produced it.
7841 fn draft(stage: usize, rows: Option<usize>, key: &str) -> StageDraft {
7842 StageDraft {
7843 plan: Arc::new(
7844 ShuffleReadExec::new(
7845 stage,
7846 4,
7847 4,
7848 schema(key, arrow::datatypes::DataType::Int64),
7849 None,
7850 )
7851 .with_upstream_estimate(rows, None),
7852 ),
7853 shuffle: Some(StageShuffleOutput {
7854 key_columns: vec![String::from(key)],
7855 num_output_partitions: 4,
7856 }),
7857 subqueries: None,
7858 }
7859 }
7860
7861 fn read(
7862 stage: usize,
7863 rows: Option<usize>,
7864 key: &str,
7865 key_type: arrow::datatypes::DataType,
7866 ) -> Arc<dyn ExecutionPlan> {
7867 Arc::new(
7868 ShuffleReadExec::new(stage, 4, 4, schema(key, key_type), None)
7869 .with_upstream_estimate(rows, None),
7870 )
7871 }
7872
7873 fn join_of(
7874 build: Arc<dyn ExecutionPlan>,
7875 probe: Arc<dyn ExecutionPlan>,
7876 join_type: JoinType,
7877 ) -> datafusion::error::Result<Arc<dyn ExecutionPlan>> {
7878 Ok(Arc::new(HashJoinExec::try_new(
7879 build,
7880 probe,
7881 vec![(
7882 Arc::new(Column::new("bkey", 0)),
7883 Arc::new(Column::new("pkey", 0)),
7884 )],
7885 None,
7886 &join_type,
7887 None,
7888 PartitionMode::Partitioned,
7889 NullEquality::NullEqualsNothing,
7890 false,
7891 )?))
7892 }
7893
7894 /// The q10 shape: a small build stage, a probe stage 100x bigger, joined
7895 /// across a stage boundary.
7896 fn q10_shaped() -> datafusion::error::Result<(Arc<dyn ExecutionPlan>, Vec<StageDraft>)> {
7897 let root = join_of(
7898 read(
7899 0,
7900 Some(1_000_000),
7901 "bkey",
7902 arrow::datatypes::DataType::Int64,
7903 ),
7904 read(
7905 1,
7906 Some(100_000_000),
7907 "pkey",
7908 arrow::datatypes::DataType::Int64,
7909 ),
7910 JoinType::Inner,
7911 )?;
7912 Ok((
7913 root,
7914 vec![
7915 draft(0, Some(1_000_000), "bkey"),
7916 draft(1, Some(100_000_000), "pkey"),
7917 ],
7918 ))
7919 }
7920
7921 #[test]
7922 fn a_filter_stage_is_injected_and_the_probe_stage_waits_on_it() {
7923 let (root, mut drafts) = q10_shaped().expect("plan");
7924 assert_eq!(
7925 inject_runtime_filters_unconditionally(&root, &mut drafts),
7926 1
7927 );
7928 assert_eq!(drafts.len(), 3, "one filter stage must have been appended");
7929
7930 let filter = &drafts[2];
7931 let shuffle = filter.shuffle.as_ref().expect("filter stage shuffles");
7932 assert!(
7933 shuffle.key_columns.is_empty() && shuffle.num_output_partitions == 1,
7934 "the filter must gather to ONE keyless partition; any other shape means \
7935 every probe task fetches N partials instead of one filter"
7936 );
7937 assert_eq!(
7938 filter.plan.output_partitioning().partition_count(),
7939 1,
7940 "the filter stage must be a single task, or the broadcast it feeds \
7941 multiplies by the task count"
7942 );
7943
7944 // The inverted edge: the PROBE stage now depends on the filter stage.
7945 assert!(
7946 collect_upstream_stage_indexes(&drafts[1].plan).contains(&2),
7947 "the probe stage must declare the filter stage upstream, or the \
7948 scheduler will run it before the filter exists"
7949 );
7950 }
7951
7952 /// The probe stage's output schema is what its shuffle key columns are
7953 /// resolved against by name. Changing it would misroute every row.
7954 #[test]
7955 fn the_probe_stages_output_schema_is_untouched() {
7956 let (root, mut drafts) = q10_shaped().expect("plan");
7957 let before = drafts[1].plan.schema();
7958 inject_runtime_filters_unconditionally(&root, &mut drafts);
7959 assert_eq!(
7960 before.fields(),
7961 drafts[1].plan.schema().fields(),
7962 "wrapping the probe stage must not change its columns"
7963 );
7964 }
7965
7966 #[test]
7967 fn the_filter_stage_emits_one_binary_column() {
7968 let (root, mut drafts) = q10_shaped().expect("plan");
7969 inject_runtime_filters_unconditionally(&root, &mut drafts);
7970 assert_eq!(
7971 drafts[2].plan.schema().fields().len(),
7972 1,
7973 "a filter stage carries only the serialized bloom"
7974 );
7975 }
7976
7977 #[test]
7978 fn a_non_inner_join_gets_no_filter() {
7979 for join_type in [
7980 JoinType::Full,
7981 JoinType::Right,
7982 JoinType::RightAnti,
7983 JoinType::LeftAnti,
7984 ] {
7985 let root = join_of(
7986 read(
7987 0,
7988 Some(1_000_000),
7989 "bkey",
7990 arrow::datatypes::DataType::Int64,
7991 ),
7992 read(
7993 1,
7994 Some(100_000_000),
7995 "pkey",
7996 arrow::datatypes::DataType::Int64,
7997 ),
7998 join_type,
7999 )
8000 .expect("join");
8001 let mut drafts = vec![
8002 draft(0, Some(1_000_000), "bkey"),
8003 draft(1, Some(100_000_000), "pkey"),
8004 ];
8005 assert_eq!(
8006 inject_runtime_filters_unconditionally(&root, &mut drafts),
8007 0,
8008 "{join_type:?} can preserve unmatched PROBE rows; dropping them is a \
8009 wrong answer, not a slow one"
8010 );
8011 }
8012 }
8013
8014 #[test]
8015 fn a_join_inside_one_stage_gets_no_filter() {
8016 let root = join_of(
8017 read(
8018 0,
8019 Some(1_000_000),
8020 "bkey",
8021 arrow::datatypes::DataType::Int64,
8022 ),
8023 read(
8024 0,
8025 Some(100_000_000),
8026 "pkey",
8027 arrow::datatypes::DataType::Int64,
8028 ),
8029 JoinType::Inner,
8030 )
8031 .expect("join");
8032 let mut drafts = vec![draft(0, Some(1_000_000), "bkey")];
8033 assert_eq!(
8034 inject_runtime_filters_unconditionally(&root, &mut drafts),
8035 0,
8036 "a same-stage join already gets DataFusion's own dynamic filter"
8037 );
8038 }
8039
8040 #[test]
8041 fn an_absent_estimate_refuses_rather_than_guesses() {
8042 for (build_rows, probe_rows) in [(None, Some(100_000_000)), (Some(1_000_000), None)] {
8043 let root = join_of(
8044 read(0, build_rows, "bkey", arrow::datatypes::DataType::Int64),
8045 read(1, probe_rows, "pkey", arrow::datatypes::DataType::Int64),
8046 JoinType::Inner,
8047 )
8048 .expect("join");
8049 let mut drafts = vec![draft(0, build_rows, "bkey"), draft(1, probe_rows, "pkey")];
8050 assert_eq!(
8051 inject_runtime_filters_unconditionally(&root, &mut drafts),
8052 0,
8053 "Precision::Absent means 'no idea', never 'small' — guessing here is \
8054 the SpillableJoinSelection lesson"
8055 );
8056 }
8057 }
8058
8059 #[test]
8060 fn a_probe_barely_bigger_than_the_build_is_not_worth_a_stage() {
8061 let root = join_of(
8062 read(
8063 0,
8064 Some(1_000_000),
8065 "bkey",
8066 arrow::datatypes::DataType::Int64,
8067 ),
8068 read(
8069 1,
8070 Some(2_000_000),
8071 "pkey",
8072 arrow::datatypes::DataType::Int64,
8073 ),
8074 JoinType::Inner,
8075 )
8076 .expect("join");
8077 let mut drafts = vec![
8078 draft(0, Some(1_000_000), "bkey"),
8079 draft(1, Some(2_000_000), "pkey"),
8080 ];
8081 assert_eq!(
8082 inject_runtime_filters_unconditionally(&root, &mut drafts),
8083 0,
8084 "2x is not enough to repay an extra scan plus a broadcast"
8085 );
8086 }
8087
8088 #[test]
8089 fn an_oversized_filter_is_refused() {
8090 // Enough distinct keys that the planned filter hits the 16 MB cap,
8091 // where the false-positive rate has degraded towards "matches
8092 // everything" and the broadcast costs more than the scan it saves.
8093 let huge = 5_000_000_000usize;
8094 let root = join_of(
8095 read(0, Some(huge), "bkey", arrow::datatypes::DataType::Int64),
8096 read(1, Some(huge * 8), "pkey", arrow::datatypes::DataType::Int64),
8097 JoinType::Inner,
8098 )
8099 .expect("join");
8100 let mut drafts = vec![
8101 draft(0, Some(huge), "bkey"),
8102 draft(1, Some(huge * 8), "pkey"),
8103 ];
8104 assert_eq!(
8105 inject_runtime_filters_unconditionally(&root, &mut drafts),
8106 0
8107 );
8108 }
8109
8110 #[test]
8111 fn an_unsupported_key_type_is_refused_not_guessed() {
8112 let root = join_of(
8113 read(
8114 0,
8115 Some(1_000_000),
8116 "bkey",
8117 arrow::datatypes::DataType::Float64,
8118 ),
8119 read(
8120 1,
8121 Some(100_000_000),
8122 "pkey",
8123 arrow::datatypes::DataType::Float64,
8124 ),
8125 JoinType::Inner,
8126 )
8127 .expect("join");
8128 let mut drafts = vec![
8129 draft(0, Some(1_000_000), "bkey"),
8130 draft(1, Some(100_000_000), "pkey"),
8131 ];
8132 assert_eq!(
8133 inject_runtime_filters_unconditionally(&root, &mut drafts),
8134 0,
8135 "-0.0 == 0.0 compares equal but encodes differently, so a float bloom \
8136 would produce false negatives"
8137 );
8138 }
8139
8140 /// Guard 4: the new edge must never close a loop. If the build stage
8141 /// already reads the probe stage, `probe -> filter -> ... -> probe` is a
8142 /// cycle, and the scheduler's answer to a cyclic job is to reject it.
8143 #[test]
8144 fn a_filter_that_would_close_a_cycle_is_not_injected() {
8145 let root = join_of(
8146 read(
8147 0,
8148 Some(1_000_000),
8149 "bkey",
8150 arrow::datatypes::DataType::Int64,
8151 ),
8152 read(
8153 1,
8154 Some(100_000_000),
8155 "pkey",
8156 arrow::datatypes::DataType::Int64,
8157 ),
8158 JoinType::Inner,
8159 )
8160 .expect("join");
8161 let mut drafts = vec![
8162 draft(0, Some(1_000_000), "bkey"),
8163 draft(1, Some(100_000_000), "pkey"),
8164 ];
8165 // Make stage 0 (build) read stage 1 (probe).
8166 drafts[0].plan = Arc::new(
8167 ShuffleReadExec::new(
8168 1,
8169 4,
8170 4,
8171 schema("bkey", arrow::datatypes::DataType::Int64),
8172 None,
8173 )
8174 .with_upstream_estimate(Some(1_000_000), None),
8175 );
8176 assert!(
8177 stage_depends_on(&drafts, 0, 1),
8178 "precondition: build reads probe"
8179 );
8180 assert_eq!(
8181 inject_runtime_filters_unconditionally(&root, &mut drafts),
8182 0
8183 );
8184 }
8185
8186 /// With the feature off, the pass must still change nothing *and* still
8187 /// be able to count — the diagnostic is what tells an operator whether
8188 /// turning the flag on is worth trying.
8189 ///
8190 /// Before the dry run existed, `inject_runtime_filters` returned before
8191 /// computing its rejection breakdown, so every run since the counters
8192 /// landed produced zero `runtime-filter: pass complete` lines and the
8193 /// question stayed unanswerable without first enabling the thing being
8194 /// evaluated.
8195 #[test]
8196 fn the_dry_run_reports_without_rewriting_when_the_flag_is_off() {
8197 let root: Arc<dyn ExecutionPlan> = Arc::new(ShuffleReadExec::new(
8198 0,
8199 1,
8200 1,
8201 schema("k", arrow::datatypes::DataType::Int64),
8202 None,
8203 ));
8204 let mut drafts = vec![draft(0, Some(1), "a"), draft(1, Some(1), "b")];
8205 let before = drafts.len();
8206 // `enabled()` reads the environment, which this test does not touch:
8207 // the default is off, which is the case under audit.
8208 assert!(
8209 !crate::runtime_filter_exec::enabled(),
8210 "precondition: the feature ships dark"
8211 );
8212 assert_eq!(
8213 inject_runtime_filters(&root, &mut drafts),
8214 0,
8215 "a disabled pass must inject nothing"
8216 );
8217 assert_eq!(drafts.len(), before, "a disabled pass must not add a stage");
8218 }
8219
8220 #[test]
8221 fn stage_dependency_reachability_is_transitive_and_terminates_on_cycles() {
8222 let mut drafts = vec![
8223 draft(0, Some(1), "a"),
8224 draft(1, Some(1), "b"),
8225 draft(2, Some(1), "c"),
8226 ];
8227 // 2 -> 1 -> 0
8228 drafts[1].plan = Arc::new(ShuffleReadExec::new(
8229 0,
8230 1,
8231 1,
8232 schema("b", arrow::datatypes::DataType::Int64),
8233 None,
8234 ));
8235 drafts[2].plan = Arc::new(ShuffleReadExec::new(
8236 1,
8237 1,
8238 1,
8239 schema("c", arrow::datatypes::DataType::Int64),
8240 None,
8241 ));
8242 assert!(
8243 stage_depends_on(&drafts, 2, 0),
8244 "reachability must be transitive"
8245 );
8246 assert!(!stage_depends_on(&drafts, 0, 2), "and directional");
8247 }
8248
8249 /// A stage severed from a `ScalarSubqueryExec` is parameterised by a
8250 /// subquery result; cloning its subtree without the wrapper yields a
8251 /// fragment that cannot decode.
8252 #[test]
8253 fn a_subquery_parameterised_stage_is_never_cloned_into_a_filter() {
8254 let (root, mut drafts) = q10_shaped().expect("plan");
8255 drafts[0].subqueries = Some(StageSubqueryContext {
8256 links: Vec::new(),
8257 results: Default::default(),
8258 });
8259 assert_eq!(
8260 inject_runtime_filters_unconditionally(&root, &mut drafts),
8261 0
8262 );
8263 }
8264
8265 #[test]
8266 fn the_feature_is_off_unless_the_flag_says_otherwise() {
8267 let (root, mut drafts) = q10_shaped().expect("plan");
8268 assert_eq!(
8269 inject_runtime_filters(&root, &mut drafts),
8270 0,
8271 "the flag-checking entry point must decline by default: this rule \
8272 rewrites the stage DAG and ships dark until a clean 22-query sweep"
8273 );
8274 assert_eq!(drafts.len(), 2, "and it must not have touched the drafts");
8275 }
8276
8277 /// Every node in an injected plan must survive the proto round trip.
8278 /// A node the codec cannot encode does not fail loudly — the coordinator
8279 /// silently runs the whole query as a SINGLE TASK.
8280 #[test]
8281 fn the_injected_stages_round_trip_through_the_codec() {
8282 let (root, mut drafts) = q10_shaped().expect("plan");
8283 assert_eq!(
8284 inject_runtime_filters_unconditionally(&root, &mut drafts),
8285 1
8286 );
8287
8288 let codec = KrishivPhysicalCodec::coordinator();
8289 let session = fragment_decode_session_context();
8290 let ctx = session.task_ctx();
8291 for (index, draft) in drafts.iter().enumerate() {
8292 let bytes = encode_dfplan_bytes(Arc::clone(&draft.plan), &codec)
8293 .unwrap_or_else(|e| panic!("stage {index} did not encode: {e}"));
8294 verify_dfplan_roundtrip(&bytes, &codec, &ctx, Some(&draft.plan))
8295 .unwrap_or_else(|e| panic!("stage {index} did not decode: {e}"));
8296 }
8297 }
8298 }
8299}
8300
8301#[cfg(test)]
8302#[allow(clippy::unwrap_used, clippy::expect_used)]
8303mod registration_parity_tests {
8304 use super::*;
8305
8306 /// Write one small parquet file and return the path to register.
8307 async fn parquet_at(dir: &std::path::Path) -> String {
8308 let path = dir.join("t.parquet");
8309 let path = path.to_str().expect("temp path is utf-8").to_owned();
8310 let ctx = SessionContext::new();
8311 ctx.sql(&format!(
8312 "COPY (SELECT * FROM (VALUES (1, 'a'), (2, 'b'), (3, 'c')) t(k, v)) \
8313 TO '{path}' STORED AS PARQUET"
8314 ))
8315 .await
8316 .unwrap()
8317 .collect()
8318 .await
8319 .unwrap();
8320 path
8321 }
8322
8323 /// Register the same file both ways and hand back the two providers.
8324 async fn both_ways(
8325 path: &str,
8326 ) -> (
8327 Arc<dyn datafusion::datasource::TableProvider>,
8328 Arc<dyn datafusion::datasource::TableProvider>,
8329 ) {
8330 let ctx = planning_session_context(4);
8331 register_parquet_table(&ctx, &ParquetTableSpec::new("plain", path))
8332 .await
8333 .unwrap();
8334 register_parquet_table(
8335 &ctx,
8336 &ParquetTableSpec::new("keyed", path).with_primary_key(["k"]),
8337 )
8338 .await
8339 .unwrap();
8340 (
8341 ctx.table_provider("plain").await.unwrap(),
8342 ctx.table_provider("keyed").await.unwrap(),
8343 )
8344 }
8345
8346 fn listing_options(
8347 provider: &Arc<dyn datafusion::datasource::TableProvider>,
8348 ) -> datafusion::datasource::listing::ListingOptions {
8349 // `TableProvider: Any` — upcast to downcast, as elsewhere in this file
8350 // (DF 54 has no `as_any` on the trait).
8351 let any = provider.as_ref() as &dyn std::any::Any;
8352 any.downcast_ref::<datafusion::datasource::listing::ListingTable>()
8353 .expect("parquet registration produces a ListingTable")
8354 .options()
8355 .clone()
8356 }
8357
8358 /// An object-store directory must reach `ListingTableUrl` as a prefix.
8359 ///
8360 /// `ListingTableUrl::parse` decides file-vs-directory by statting, which an
8361 /// object store cannot answer, so `s3://b/sf100/lineitem` was read as a
8362 /// file and rejected for not ending in `.parquet` — a message that blames
8363 /// the extension for a path that is simply a directory. The same
8364 /// registration worked locally and failed remotely.
8365 #[test]
8366 fn an_extensionless_object_store_path_is_treated_as_a_directory() {
8367 assert_eq!(
8368 super::directory_aware_url("s3://b/sf100/lineitem"),
8369 "s3://b/sf100/lineitem/"
8370 );
8371 // Already a prefix: unchanged, no doubled slash.
8372 assert_eq!(
8373 super::directory_aware_url("s3://b/sf100/lineitem/"),
8374 "s3://b/sf100/lineitem/"
8375 );
8376 // A named file keeps file semantics.
8377 assert_eq!(
8378 super::directory_aware_url("s3://b/sf100/nation.parquet"),
8379 "s3://b/sf100/nation.parquet"
8380 );
8381 // Local paths are untouched: statting them is better information than
8382 // any guess this function could make.
8383 assert_eq!(
8384 super::directory_aware_url("/data/sf100/lineitem"),
8385 "/data/sf100/lineitem"
8386 );
8387 assert_eq!(super::directory_aware_url("relative/dir"), "relative/dir");
8388 }
8389
8390 /// The invariant the two branches of [`register_parquet_table`] exist under:
8391 /// declaring a key changes the *constraints* and nothing else.
8392 ///
8393 /// It was violated silently. The keyed branch built its own
8394 /// `ListingOptions::new(..)`, whose documented defaults are `collect_stat:
8395 /// false` and `target_partitions: 1` — while `register_parquet` routes
8396 /// through `ReadOptions::to_listing_options`, which ends in
8397 /// `.with_session_config_options(config)` and takes both from the session.
8398 ///
8399 /// So every table with a declared primary key had **statistics collection
8400 /// off**. On the SF100 cluster that made `SpillableJoinSelection` report
8401 /// `unmeasurable == hash_joins` in all 414 passes and convert zero joins,
8402 /// leaving q21's oversized build side with nothing to catch it.
8403 ///
8404 /// Comparing the whole `ListingOptions` rather than the two fields that
8405 /// were wrong is deliberate: the next field DataFusion adds to
8406 /// `with_session_config_options` fails this test instead of quietly
8407 /// re-opening the same hole.
8408 #[tokio::test]
8409 async fn declaring_a_primary_key_changes_only_the_constraints() {
8410 let dir = tempfile::tempdir().unwrap();
8411 let path = parquet_at(dir.path()).await;
8412 let (plain, keyed) = both_ways(&path).await;
8413
8414 let (plain_options, keyed_options) = (listing_options(&plain), listing_options(&keyed));
8415 assert_eq!(
8416 format!("{plain_options:?}"),
8417 format!("{keyed_options:?}"),
8418 "a declared key must not change how the table is read"
8419 );
8420 assert!(
8421 keyed_options.collect_stat,
8422 "statistics collection must stay on: every size-based rule goes \
8423 blind without it"
8424 );
8425
8426 // The one difference that is supposed to exist.
8427 assert!(plain.constraints().is_none_or(|c| c.is_empty()));
8428 assert!(
8429 keyed.constraints().is_some_and(|c| !c.is_empty()),
8430 "the declared key must reach the optimizer as a constraint"
8431 );
8432 }
8433
8434 /// The behavioural half: a keyed table must still report row counts.
8435 ///
8436 /// The options comparison above pins the mechanism; this pins the outcome
8437 /// the mechanism exists for, so the test still fails if a future change
8438 /// keeps `collect_stat` set but loses statistics some other way.
8439 #[tokio::test]
8440 async fn a_keyed_table_still_reports_row_counts() {
8441 use datafusion::common::stats::Precision;
8442 let dir = tempfile::tempdir().unwrap();
8443 let path = parquet_at(dir.path()).await;
8444 let (_, keyed) = both_ways(&path).await;
8445
8446 let ctx = planning_session_context(4);
8447 ctx.register_table("keyed", Arc::clone(&keyed)).unwrap();
8448 let stats = ctx
8449 .sql("SELECT k, v FROM keyed")
8450 .await
8451 .unwrap()
8452 .create_physical_plan()
8453 .await
8454 .unwrap()
8455 .partition_statistics(None)
8456 .unwrap();
8457 assert!(
8458 matches!(stats.num_rows, Precision::Exact(3) | Precision::Inexact(3)),
8459 "expected a row count for a keyed table, got {:?} — this is the \
8460 shape that made every join unmeasurable at SF100",
8461 stats.num_rows
8462 );
8463 }
8464}