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 // Before cutting: a broadcast join whose unmatched build rows are emitted
2305 // only after the last probe partition cannot be split one-partition-per-task
2306 // without silently dropping those rows. Convert such joins to
2307 // hash-partitioned ones, which are split-safe by construction.
2308 let plan = redistribute_unsplittable_broadcast_joins(plan)?;
2309
2310 // Re-run the spillable-join rule over the rewritten plan.
2311 //
2312 // This is a sequencing fix, not a belt-and-braces repeat. `SpillableJoinSelection`
2313 // is a physical optimizer rule, so it ran BEFORE the rewrite above — when
2314 // q21's joins were still `CollectLeft` over a multi-partition probe, a shape
2315 // it declines by design (`convertible_mode`). The rewrite then turns them
2316 // into `Partitioned` joins whose build side each task must hold as a hash
2317 // table, and nothing had re-examined whether it fits.
2318 //
2319 // Measured: with the rewrite but without this pass, q21 at SF100 stopped
2320 // being slow and started FAILING — `Resources exhausted: HashJoinInput[4]
2321 // with 806.0 MB already allocated` out of a 2.6 GB pool. One degenerate
2322 // statistic was poisoning two decisions; fixing only the first turned a slow
2323 // query into a broken one.
2324 let plan = {
2325 use datafusion::physical_optimizer::PhysicalOptimizerRule as _;
2326 crate::spillable_join::SpillableJoinSelection::from_capacity()
2327 // This plan is about to be cut into stages, so an added exchange is
2328 // an added stage boundary — see `without_broadcast_rescue`.
2329 .without_broadcast_rescue()
2330 .optimize(plan, &datafusion::common::config::ConfigOptions::default())
2331 }
2332 .map_err(|e| SqlError::DataFusion {
2333 message: format!("spillable-join pass over the redistributed plan: {e}"),
2334 })?;
2335
2336 let mut drafts: Vec<StageDraft> = Vec::new();
2337 let mut root = match cut_exchanges(plan, &mut drafts) {
2338 Ok(root) => root,
2339 Err(Unsupported(reason)) => {
2340 return Err(SqlError::DataFusion {
2341 message: format!("stage split unsupported: {reason}"),
2342 });
2343 }
2344 };
2345 if drafts.is_empty() {
2346 return Err(SqlError::DataFusion {
2347 message: String::from("plan has no exchange to cut, so it cannot be split into stages"),
2348 });
2349 }
2350 // Collapse identical leaf stages FIRST, so the runtime-filter rule sees the
2351 // deduplicated stage list and cannot build a filter for a stage that is
2352 // about to be removed (and thereby leave a dangling upstream index).
2353 // A no-op unless `KRISHIV_STAGE_REUSE` is on.
2354 dedupe_identical_stages(&mut root, &mut drafts);
2355
2356 // Cross-stage runtime filters, before the root is pushed so the Result
2357 // stage stays last. Appends filter stages and rewrites probe stages in
2358 // place; a no-op unless `KRISHIV_CROSS_STAGE_RUNTIME_FILTER` is on.
2359 inject_runtime_filters(&root, &mut drafts);
2360
2361 drafts.push(StageDraft {
2362 plan: root,
2363 shuffle: None,
2364 // The root keeps whatever `ScalarSubqueryExec` it was planned with, so
2365 // it is never the severed side.
2366 subqueries: None,
2367 });
2368
2369 // Prove every stage subtree is partition-independent: no exchange may
2370 // remain inside a stage (each task executes one root partition; a
2371 // leftover RepartitionExec would re-drive all inputs per task).
2372 for draft in &drafts {
2373 if let Some(reason) = find_unsupported_stage_node(&draft.plan) {
2374 return Err(SqlError::DataFusion {
2375 message: format!("stage subtree not partition-independent: {reason}"),
2376 });
2377 }
2378 }
2379
2380 let codec = KrishivPhysicalCodec::coordinator();
2381 // One executor-equivalent decode context for the whole query: building a
2382 // `SqlEngine` registers the full UDF set, and every stage rehearses against
2383 // the same one the executor would use (A5).
2384 let decode_session = fragment_decode_session_context();
2385 if !udf_directive_source.is_empty() {
2386 register_python_udf_signatures_and_strip(&decode_session, udf_directive_source)?;
2387 }
2388 let decode_ctx = decode_session.task_ctx();
2389 let mut stages = Vec::with_capacity(drafts.len());
2390 for draft in drafts {
2391 let partition_count = draft.plan.output_partitioning().partition_count();
2392 if partition_count == 0 {
2393 return Err(SqlError::DataFusion {
2394 message: String::from("stage subtree has zero output partitions"),
2395 });
2396 }
2397 let upstream_stage_indexes = collect_upstream_stage_indexes(&draft.plan);
2398 // Encoding successfully is not the same as being shippable — a fragment
2399 // can encode and then fail to decode on the executor. Rehearse the
2400 // decode locally (same codec, same object-store registry as the
2401 // executor's runtime) so an encode/decode asymmetry degrades to
2402 // correct-but-serial execution instead of a remote fragment failure.
2403 //
2404 // A stage cut out from beneath a `ScalarSubqueryExec` gets two attempts:
2405 // bare first, then wrapped. Trying bare first is what keeps the repair
2406 // precise — only the stage that genuinely carries a `ScalarSubqueryExpr`
2407 // pays to re-evaluate the subquery, and the rest of the query's stages
2408 // are shipped exactly as before. There is no generic way to ask a
2409 // physical plan "do you contain this expression" (`ExecutionPlan` has no
2410 // expression accessor), and the decoder's own answer is the
2411 // authoritative one anyway.
2412 let attempts = match &draft.subqueries {
2413 Some(context) => vec![
2414 Arc::clone(&draft.plan),
2415 wrap_in_scalar_subquery_exec(Arc::clone(&draft.plan), context),
2416 ],
2417 None => vec![Arc::clone(&draft.plan)],
2418 };
2419 let mut shippable = None;
2420 let mut last_error = None;
2421 for stage_plan in attempts {
2422 let bytes = match encode_dfplan_bytes(Arc::clone(&stage_plan), &codec) {
2423 Ok(bytes) => bytes,
2424 Err(error) => {
2425 // Plans over non-serializable providers (memory tables,
2426 // custom scans) fall back rather than fail the query.
2427 last_error = Some(error.to_string());
2428 continue;
2429 }
2430 };
2431 match verify_dfplan_roundtrip(&bytes, &codec, &decode_ctx, Some(&stage_plan)) {
2432 Ok(()) => {
2433 shippable = Some(bytes);
2434 break;
2435 }
2436 Err(error) => last_error = Some(error.to_string()),
2437 }
2438 }
2439 let Some(bytes) = shippable else {
2440 // At `warn`: declining to distribute is not a detail, it is the
2441 // difference between a query using the cluster and one task
2442 // scanning the whole table. This was `debug` on a coordinator that
2443 // runs at `info`, so TPC-H q22 quietly ran serially for three
2444 // sweeps with nothing in the logs saying why.
2445 tracing::warn!(
2446 error = %last_error.unwrap_or_else(|| String::from("unknown")),
2447 "stage plan cannot be encoded and decoded; running this query as a SINGLE TASK"
2448 );
2449 return Ok(None);
2450 };
2451 let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
2452 let task_bodies = (0..partition_count)
2453 .map(|p| dfplan_task_body(&b64, p))
2454 .collect();
2455 stages.push(DistributedStage {
2456 task_bodies,
2457 shuffle: draft.shuffle,
2458 upstream_stage_indexes,
2459 });
2460 }
2461 Ok(Some(DistributedStagePlan { stages }))
2462}
2463
2464/// Identity of a cut subtree, for deciding whether two exchanges can share one
2465/// stage.
2466///
2467/// # Why reuse is worth having
2468///
2469/// The cutter gave every exchange its own stage, so a subtree feeding two
2470/// consumers was scanned and shuffled twice. TPC-H q21 shuffles
2471/// `["l_orderkey","l_suppkey"]` in **three** separate stages — one per
2472/// EXISTS/NOT EXISTS self-join over `lineitem` — and q7 shuffles the **25-row**
2473/// `nation` table in two. On a cluster whose pod network is the binding
2474/// constraint, a redundant shuffle is redundant wire time. Spark calls this
2475/// `ReusedExchange`.
2476///
2477/// # Why this key is the whole safety argument
2478///
2479/// A false match merges two stages that are *not* equivalent, and the consumers
2480/// then read someone else's rows — a wrong answer with no error. So the key is
2481/// the full indented physical plan of the subtree, which renders operators,
2482/// projections, filter predicates and scanned file groups, plus the schema and
2483/// the shuffle's own key columns and partition count (compared separately by
2484/// the caller). Anything that changes what the stage *emits* changes this
2485/// string.
2486///
2487/// Deliberately conservative in two ways:
2488///
2489/// * Stages carrying a [`StageSubqueryContext`] are never reused. Their tasks
2490/// are parameterised by a subquery result, so identical plan text does not
2491/// imply identical output.
2492/// * It matches on rendered text rather than pointer identity, so it finds the
2493/// real duplicates (separately-planned subqueries) rather than only shared
2494/// `Arc`s — which is the case that actually occurs.
2495///
2496/// The empirical guard is `every_tpch_query_stages_to_the_same_answer_in_every_configuration`
2497/// in krishiv-bench: all 22 queries, four join/broadcast configurations, staged
2498/// through this cutter and compared against single-node execution.
2499fn exchange_reuse_key(
2500 plan: &Arc<dyn ExecutionPlan>,
2501 key_columns: &[String],
2502 num_partitions: usize,
2503) -> String {
2504 use datafusion::physical_plan::displayable;
2505 format!(
2506 "keys={key_columns:?}|parts={num_partitions}|schema={:?}|plan=\n{}",
2507 plan.schema(),
2508 displayable(plan.as_ref()).indent(true)
2509 )
2510}
2511
2512/// Largest `fetch` for which a `SortPreservingMergeExec` is worth turning into
2513/// a gather + re-sort.
2514///
2515/// Cutting the merge buys distribution of everything beneath it, and costs
2516/// buffering `partitions x fetch` rows in the stage that re-sorts them. At
2517/// TPC-H's fetches (10-100) over 18 partitions that is under two thousand
2518/// rows. A very large `LIMIT` inverts the trade: the merge would stream, the
2519/// replacement would buffer, and the subtree below is usually cheap anyway —
2520/// so those keep the streaming k-way merge they were planned with.
2521const MAX_GATHERED_SORT_FETCH: usize = 10_000;
2522
2523/// Does this subtree contain a hash-partitioned join?
2524///
2525/// The signal that a gather is stranding real distributed work. A
2526/// `Partitioned` join exists *because* N tasks should each handle one
2527/// partition; finding one below a gather means the plan paid for the shuffle
2528/// and is about to throw the parallelism away. A `CollectLeft` join says
2529/// nothing — its build side was chosen to be small and a single probe
2530/// partition may be entirely correct.
2531fn contains_partitioned_join(plan: &Arc<dyn ExecutionPlan>) -> bool {
2532 use datafusion::physical_plan::joins::{HashJoinExec, PartitionMode};
2533 if let Some(join) = plan.downcast_ref::<HashJoinExec>()
2534 && *join.partition_mode() == PartitionMode::Partitioned
2535 {
2536 return true;
2537 }
2538 plan.children()
2539 .iter()
2540 .any(|child| contains_partitioned_join(child))
2541}
2542
2543fn cut_exchanges(
2544 plan: Arc<dyn ExecutionPlan>,
2545 stages: &mut Vec<StageDraft>,
2546) -> Result<Arc<dyn ExecutionPlan>, Unsupported> {
2547 if let Some(repartition) = plan.downcast_ref::<RepartitionExec>() {
2548 let Partitioning::Hash(exprs, num_partitions) = repartition.partitioning() else {
2549 return Err(Unsupported(format!(
2550 "non-hash exchange in plan: {}",
2551 repartition.partitioning()
2552 )));
2553 };
2554 let key_columns = hash_expr_column_names(exprs).ok_or_else(|| {
2555 Unsupported(String::from(
2556 "hash exchange uses non-column expressions; cannot derive shuffle keys",
2557 ))
2558 })?;
2559 let input = cut_exchanges(Arc::clone(repartition.input()), stages)?;
2560 let map_task_count = input.output_partitioning().partition_count();
2561 if map_task_count == 0 {
2562 return Err(Unsupported(String::from("hash exchange over empty input")));
2563 }
2564 let schema = input.schema();
2565 // D3(2): capture the estimate before `input` is moved into the stage —
2566 // this is the only point where the cut subtree is still in hand.
2567 let estimate = ShuffleReadExec::estimate_of(&input);
2568
2569 // Exchange reuse: an identical subtree shuffled on identical keys into
2570 // identical partitions produces byte-identical output, so cut it once
2571 // and let both consumers read the same stage.
2572 let reuse_key = exchange_reuse_key(&input, &key_columns, *num_partitions);
2573 let stage_index = match stages.iter().position(|draft| {
2574 draft.subqueries.is_none()
2575 && draft.shuffle.as_ref().is_some_and(|sh| {
2576 sh.key_columns == key_columns && sh.num_output_partitions == *num_partitions
2577 })
2578 && exchange_reuse_key(&draft.plan, &key_columns, *num_partitions) == reuse_key
2579 }) {
2580 Some(existing) => existing,
2581 None => {
2582 let index = stages.len();
2583 stages.push(StageDraft {
2584 plan: input,
2585 shuffle: Some(StageShuffleOutput {
2586 key_columns,
2587 num_output_partitions: *num_partitions,
2588 }),
2589 subqueries: None,
2590 });
2591 index
2592 }
2593 };
2594 return Ok(Arc::new(
2595 ShuffleReadExec::new(stage_index, map_task_count, *num_partitions, schema, None)
2596 .with_upstream_estimate(estimate.0, estimate.1),
2597 ));
2598 }
2599
2600 // A gather (N partitions -> 1) is an exchange too, and cutting it is what
2601 // makes ungrouped aggregates distributable. `SELECT sum(x) FROM lineitem`
2602 // plans as Final(gather(Partial(scan))) with no hash exchange anywhere, so
2603 // a cutter that only recognised RepartitionExec declined the whole query
2604 // and one executor scanned the entire table — 518 s for TPC-H q6 at SF100
2605 // on a 3-node cluster, with the other two nodes idle.
2606 //
2607 // Cutting here puts the Partial aggregate in a map stage (one task per file
2608 // group, running everywhere) and the Final aggregate in a reduce stage
2609 // reading a single shuffle partition. The shuffle writer already routes
2610 // every row to partition 0 when no key column is given, so a keyless
2611 // 1-partition output is exactly a gather.
2612 if let Some(coalesce) = plan.downcast_ref::<CoalescePartitionsExec>() {
2613 let input = cut_exchanges(Arc::clone(coalesce.input()), stages)?;
2614 let map_task_count = input.output_partitioning().partition_count();
2615 if map_task_count <= 1 {
2616 // Nothing to spread: a one-partition input gathers to itself, and
2617 // a stage boundary here would add a shuffle round trip for no
2618 // parallelism. Keep the node as-is.
2619 return plan
2620 .with_new_children(vec![input])
2621 .map_err(|e| Unsupported(format!("gather rewrite: {e}")));
2622 }
2623 let schema = input.schema();
2624 // D3(2): as in the hash-exchange arm, capture before the move.
2625 let estimate = ShuffleReadExec::estimate_of(&input);
2626 let stage_index = stages.len();
2627 stages.push(StageDraft {
2628 plan: input,
2629 shuffle: Some(StageShuffleOutput {
2630 key_columns: Vec::new(),
2631 num_output_partitions: 1,
2632 }),
2633 subqueries: None,
2634 });
2635 // The read replaces the whole gather: coalesce(N->1) and
2636 // shuffle(N->1)+read(partition 0) produce the same single stream, and
2637 // CoalescePartitionsExec carries no ordering guarantee to preserve.
2638 return Ok(Arc::new(
2639 ShuffleReadExec::new(stage_index, map_task_count, 1, schema, None)
2640 .with_upstream_estimate(estimate.0, estimate.1),
2641 ));
2642 }
2643
2644 // A `SortPreservingMergeExec` with a fetch is a **bounded** gather, and not
2645 // cutting it is what leaves the whole query's real work in a one-task stage.
2646 //
2647 // TPC-H q3 at SF100, measured 2026-07-30: the plan is
2648 //
2649 // SortPreservingMerge(fetch=10) <- 1 partition
2650 // SortExec TopK(fetch=10) <- 18 partitions
2651 // Aggregate(SinglePartitioned) <- 18 partitions
2652 // HashJoin(Partitioned) <- 18 partitions
2653 // ShuffleRead(stage 1) / ShuffleRead(stage 2)
2654 //
2655 // The only exchanges are the two `RepartitionExec`s *below* the join, so
2656 // everything above them became one stage — and because the merge outputs a
2657 // single partition, that stage got exactly **one task**. One executor then
2658 // ran the entire 18-partition join and aggregate by itself and pulled both
2659 // shuffles (13.2 GB) across an ~11 MiB/s pod network
2660 // ([[bench-storage-longhorn-bottleneck]]): 13.2 GB / 11 MiB/s is ~20
2661 // minutes, which is exactly what the live run showed, with 8 of 9 slots
2662 // idle throughout.
2663 //
2664 // Cutting here puts the join, the aggregate and the per-partition TopK in a
2665 // real 18-task stage; only each partition's `fetch` rows cross the wire.
2666 //
2667 // **Only when `fetch` is set.** `SortPreservingMerge` streams a k-way merge
2668 // of already-sorted inputs; the replacement is a blocking `SortExec`, which
2669 // buffers what it gathers. With a fetch that is bounded by
2670 // `partitions x fetch` (180 rows at q3's shape) and the sort is trivial.
2671 // Without one it would buffer the entire result to re-sort rows that were
2672 // already sorted — trading a distribution win for an unbounded memory
2673 // liability, so those merges are left exactly as they are.
2674 if let Some(merge) = plan.downcast_ref::<SortPreservingMergeExec>() {
2675 let input = cut_exchanges(Arc::clone(merge.input()), stages)?;
2676 let fetch = merge.fetch();
2677 // Two ways this merge is worth cutting:
2678 //
2679 // * it has a small `fetch`, so the gather is bounded by
2680 // `partitions x fetch` and the re-sort is trivial; or
2681 // * there is no fetch, but a **hash-partitioned join** sits below it,
2682 // which is the q11/q15/q20 shape: without a cut, one task performs
2683 // that whole join. Buffering the gathered rows is worth it because
2684 // what reaches the final task is the join's *output*, while the
2685 // uncut plan drags the join's much larger *inputs* to a single node.
2686 //
2687 // Anything else keeps the streaming k-way merge it was planned with:
2688 // an unbounded gather + blocking sort over a subtree that was never
2689 // distributed in the first place buys nothing and risks a large spill.
2690 let worth_cutting = match fetch {
2691 Some(n) => n <= MAX_GATHERED_SORT_FETCH,
2692 None => contains_partitioned_join(&input),
2693 };
2694 if !worth_cutting {
2695 return plan
2696 .with_new_children(vec![input])
2697 .map_err(|e| Unsupported(format!("sort-merge passthrough: {e}")));
2698 }
2699 let map_task_count = input.output_partitioning().partition_count();
2700 if map_task_count <= 1 {
2701 // Already a single stream: a stage boundary here would add a
2702 // shuffle round trip and buy no parallelism.
2703 return plan
2704 .with_new_children(vec![input])
2705 .map_err(|e| Unsupported(format!("sort-merge gather rewrite: {e}")));
2706 }
2707 let schema = input.schema();
2708 let estimate = ShuffleReadExec::estimate_of(&input);
2709 let stage_index = stages.len();
2710 stages.push(StageDraft {
2711 plan: input,
2712 shuffle: Some(StageShuffleOutput {
2713 key_columns: Vec::new(),
2714 num_output_partitions: 1,
2715 }),
2716 subqueries: None,
2717 });
2718 let read = Arc::new(
2719 ShuffleReadExec::new(stage_index, map_task_count, 1, schema, None)
2720 .with_upstream_estimate(estimate.0, estimate.1),
2721 );
2722 // The gather loses the cross-partition ordering the merge guaranteed,
2723 // so re-establish it. Sorting the gathered rows is equivalent: every
2724 // upstream partition already emitted its own sorted run (its top-`fetch`
2725 // when there is a fetch), so a sort with the same expressions and the
2726 // same fetch yields the same rows in the same order.
2727 return Ok(Arc::new(
2728 SortExec::new(merge.expr().clone(), read).with_fetch(fetch),
2729 ));
2730 }
2731
2732 // An uncorrelated scalar subquery is not an exchange, but it is a boundary:
2733 // `ScalarSubqueryExec::children()` returns `[main_input, subquery…]`, and
2734 // the generic recursion below would treat a subquery plan as ordinary
2735 // pipeline and cut it. It must not: a subquery runs *whole* inside whatever
2736 // task evaluates it, so a `ShuffleReadExec` left in one would read a
2737 // sibling stage's output out of dependency order.
2738 //
2739 // Cut only the main input, and record the subquery context on every stage
2740 // that came out of it — those are exactly the stages that may have been
2741 // severed from the wrapper their `ScalarSubqueryExpr` nodes need. See
2742 // [`StageSubqueryContext`].
2743 if let Some(subquery_exec) = plan.downcast_ref::<ScalarSubqueryExec>() {
2744 let first_new_stage = stages.len();
2745 let input = cut_exchanges(Arc::clone(subquery_exec.input()), stages)?;
2746 let context = || StageSubqueryContext {
2747 links: subquery_exec.subqueries().to_vec(),
2748 results: subquery_exec.results().clone(),
2749 };
2750 if let Some(new_stages) = stages.get_mut(first_new_stage..) {
2751 for draft in new_stages {
2752 // Nested levels compose: an inner exec records its own
2753 // subqueries first, and only stages with no context yet belong
2754 // to this level.
2755 draft.subqueries.get_or_insert_with(context);
2756 }
2757 }
2758 // Rebuild in `children()` order: main input first, subqueries after.
2759 let mut children = Vec::with_capacity(subquery_exec.subqueries().len() + 1);
2760 children.push(input);
2761 children.extend(
2762 subquery_exec
2763 .subqueries()
2764 .iter()
2765 .map(|link| Arc::clone(&link.plan)),
2766 );
2767 return plan
2768 .with_new_children(children)
2769 .map_err(|e| Unsupported(format!("scalar-subquery rewrite: {e}")));
2770 }
2771
2772 let children = plan.children();
2773 if children.is_empty() {
2774 return Ok(plan);
2775 }
2776 let mut new_children = Vec::with_capacity(children.len());
2777 let mut changed = false;
2778 for child in children {
2779 let rewritten = cut_exchanges(Arc::clone(child), stages)?;
2780 changed = changed || !Arc::ptr_eq(&rewritten, child);
2781 new_children.push(rewritten);
2782 }
2783 if !changed {
2784 return Ok(plan);
2785 }
2786 plan.with_new_children(new_children)
2787 .map_err(|e| Unsupported(format!("plan rewrite: {e}")))
2788}
2789
2790/// Join types whose unmatched BUILD-side rows are emitted only after every
2791/// probe partition has been seen.
2792///
2793/// `HashJoinExec` tracks which build rows matched in a shared bitmap and emits
2794/// the unmatched ones from whichever probe partition finishes last
2795/// (`report_probe_completed`). Everything else streams straight through from
2796/// the probe side and needs no such rendezvous.
2797fn emits_unmatched_build_rows(join_type: datafusion::logical_expr::JoinType) -> bool {
2798 use datafusion::logical_expr::JoinType;
2799 matches!(
2800 join_type,
2801 JoinType::Left
2802 | JoinType::LeftAnti
2803 | JoinType::LeftSemi
2804 | JoinType::LeftMark
2805 | JoinType::Full
2806 )
2807}
2808
2809/// Is this join a broadcast join that cannot survive being split across tasks?
2810///
2811/// `PartitionMode::CollectLeft` sizes its probe-completion counter from the
2812/// PLAN's probe partition count (`hash_join/exec.rs`: `probe_threads_count =
2813/// self.right().output_partitioning().partition_count()`). A distributed task
2814/// executes exactly ONE partition of that plan, so the counter is decremented
2815/// once and never reaches "last probe" — and the unmatched build rows are
2816/// never emitted at all.
2817///
2818/// That is a silent wrong answer, not an error: TPC-H q22's `NOT EXISTS`
2819/// anti-join returned ZERO rows per task, and nothing in the plan, the logs or
2820/// the schema said so. `PartitionMode::Partitioned` passes `1` for the same
2821/// counter — each task owns a disjoint hash range and is its own last probe —
2822/// which is why the fix is to convert rather than to decline.
2823///
2824/// A single-partition probe side is safe as it stands: the count is already 1.
2825fn is_unsplittable_broadcast_join(join: &datafusion::physical_plan::joins::HashJoinExec) -> bool {
2826 use datafusion::physical_plan::joins::PartitionMode;
2827 *join.partition_mode() == PartitionMode::CollectLeft
2828 && emits_unmatched_build_rows(*join.join_type())
2829 && join.right().output_partitioning().partition_count() > 1
2830}
2831
2832/// Does this build side's estimate say the relation is **empty**?
2833///
2834/// Not "small" — empty. This is deliberately the narrowest possible
2835/// disagreement with DataFusion, and the narrowness is the whole design.
2836///
2837/// DataFusion's `supports_collect_by_thresholds` asks `estimate < ceiling`,
2838/// which a degenerate estimate of **zero** passes more convincingly than any
2839/// real small table. TPC-H q21 at SF100, verbatim: its `NOT EXISTS` becomes a
2840/// `LeftAnti` join whose two sides are both `lineitem` on `l_orderkey`, so
2841/// `estimate_join_statistics` computes `outer_rows - semi_estimate` =
2842/// `593462145 - 593462145` = **0 rows, 0 bytes** (`joins/utils.rs`, the
2843/// semi/anti arm). The real output is tens of millions of rows. Three
2844/// `CollectLeft` joins stacked above it each believed they were broadcasting
2845/// nothing, so the stage cutter gathered that intermediate to ONE partition
2846/// three times over (`shuffle=([], 1)` in the stage dump) and the whole top
2847/// half of the query ran on a single task.
2848///
2849/// # Why this does not also enforce a ceiling
2850///
2851/// It used to, and that was a **measured regression**. An earlier version of
2852/// this rule demanded a positive estimate *below the ceiling*, which converted
2853/// q8's and q9's `CollectLeft` build sides — estimated at `rows=~4000000,
2854/// bytes=absent`, i.e. above the 1M row ceiling but perfectly plausible. On the
2855/// cluster q8 went 92 s -> 375 s and q9 226 s -> 576 s, because the alternative
2856/// to broadcasting those few million rows is hash-partitioning the 600M-row
2857/// `lineitem` scan across an ~11 MiB/s pod network. That ceiling is DataFusion's
2858/// decision to make and it was already made with the numbers this rule can see;
2859/// second-guessing it lost more than the q21 bug cost.
2860///
2861/// So: a positive estimate is trusted, however large. Only "the planner thinks
2862/// there is nothing here" is overridden — because for a non-empty relation that
2863/// is not a measurement, it is the estimator giving up. A genuinely empty
2864/// relation pays one extra pair of hash exchanges over an empty stream.
2865fn broadcast_build_estimate_is_empty(
2866 join: &datafusion::physical_plan::joins::HashJoinExec,
2867) -> bool {
2868 // One shared reading of the statistics — see `crate::join_estimates` for
2869 // why this is not two hand-rolled matches any more, and for why the
2870 // broadcast override and the spill choice are allowed to want different
2871 // things from the same numbers.
2872 crate::join_estimates::BuildSideEstimate::of(join.left()).is_wholly_degenerate()
2873}
2874
2875/// Is this a broadcast join chosen on an estimate that says its build side is
2876/// empty when it is not?
2877///
2878/// Distinct from [`is_unsplittable_broadcast_join`], which is a *correctness*
2879/// test. This one is about throughput, and the two must stay separate: the
2880/// correctness test also gates [`find_unsupported_stage_node`], which refuses
2881/// to stage a plan rather than return wrong rows. Folding a performance
2882/// heuristic into that gate would turn a merely slow plan into a query that
2883/// declines to distribute at all.
2884///
2885/// Only fires where there is parallelism to lose: if both the probe side and
2886/// the build side's own input are already single-partition, the gather costs
2887/// nothing and the exchanges would be pure overhead.
2888fn is_degenerate_broadcast_join(join: &datafusion::physical_plan::joins::HashJoinExec) -> bool {
2889 use datafusion::physical_plan::joins::PartitionMode;
2890
2891 if *join.partition_mode() != PartitionMode::CollectLeft {
2892 return false;
2893 }
2894 // A null-aware anti join tracks probe-side state across the whole build and
2895 // is only correct as `CollectLeft` (DataFusion rejects any other mode for
2896 // it at construction). Never convert one.
2897 if join.null_aware {
2898 return false;
2899 }
2900 if !broadcast_build_estimate_is_empty(join) {
2901 return false;
2902 }
2903 let build_input_partitions = match join.left().downcast_ref::<CoalescePartitionsExec>() {
2904 Some(coalesce) => coalesce.input().output_partitioning().partition_count(),
2905 None => join.left().output_partitioning().partition_count(),
2906 };
2907 join.right().output_partitioning().partition_count() > 1 || build_input_partitions > 1
2908}
2909
2910/// The broadcast byte ceiling, resolved exactly as
2911/// [`planning_session_context_with_options`] resolves it.
2912///
2913/// Read here rather than threaded through because this pass runs after
2914/// planning, where the `SessionConfig` is gone. A caller that overrode the
2915/// ceiling to 0 ("never broadcast") cannot be affected: DataFusion then never
2916/// picks `CollectLeft`, so nothing downstream of this can fire.
2917fn broadcast_byte_ceiling() -> usize {
2918 std::env::var(BROADCAST_JOIN_BYTES_ENV)
2919 .ok()
2920 .and_then(|v| v.trim().parse::<usize>().ok())
2921 .filter(|n| *n > 0)
2922 .unwrap_or(DEFAULT_BROADCAST_JOIN_BYTES)
2923}
2924
2925/// Is this a broadcast whose build side is *wide* enough that the row ceiling
2926/// admitted something the byte ceiling would have refused?
2927///
2928/// `supports_collect_by_thresholds` prefers `total_byte_size` and falls back to
2929/// `num_rows`. Above a shuffle boundary the byte estimate is frequently absent —
2930/// `ShuffleReadExec` reports what the cut subtree reported, and DataFusion loses
2931/// `total_byte_size` through joins and aggregates — so the decision lands on the
2932/// row ceiling, which cannot see how wide a row is. At q10's customer shape
2933/// (~180 B/row) the 1,000,000-row ceiling admits ~155 MB, five times the 32 MiB
2934/// byte ceiling it stands in for, and `CollectLeft` then copies that to *every*
2935/// task of the stage. That is the shape of q10 moving ~63 GB in 13.5 minutes
2936/// against a plan implying ~3.5 GB of shuffle.
2937///
2938/// Narrow on purpose, because widening it is what regressed four queries
2939/// (see [`crate::join_estimates`]):
2940///
2941/// * Only fires where DataFusion *already chose* `CollectLeft`. A build side it
2942/// left partitioned — q8/q9/q17, whose row counts are far over the ceiling —
2943/// is never examined.
2944/// * Only when the byte estimate is **absent**. A positive byte estimate means
2945/// DataFusion decided on a real number with the same information, and
2946/// overriding that is precisely the regression.
2947fn broadcast_build_is_too_wide(join: &datafusion::physical_plan::joins::HashJoinExec) -> bool {
2948 use datafusion::physical_plan::joins::PartitionMode;
2949
2950 if *join.partition_mode() != PartitionMode::CollectLeft {
2951 return false;
2952 }
2953 // Null-aware anti joins are only correct as `CollectLeft` — never convert.
2954 if join.null_aware {
2955 return false;
2956 }
2957 let build = join.left();
2958 let Some(implied) =
2959 crate::join_estimates::BuildSideEstimate::of(build).bytes_implied_by_rows(&build.schema())
2960 else {
2961 return false;
2962 };
2963 implied > broadcast_byte_ceiling()
2964}
2965
2966/// Convert broadcast joins that cannot be split — or that were chosen on an
2967/// estimate claiming their build side is empty, or on a row ceiling blind to
2968/// how wide those rows are — into hash-partitioned joins (see
2969/// [`is_unsplittable_broadcast_join`], [`is_degenerate_broadcast_join`] and
2970/// [`broadcast_build_is_too_wide`]).
2971///
2972/// Both sides gain a hash exchange on the join keys, which the stage cutter
2973/// then turns into ordinary map stages — so the join keeps running across the
2974/// cluster instead of being declined back to a single task.
2975///
2976/// The build side's `CoalescePartitionsExec` is dropped when present: it exists
2977/// only to satisfy `CollectLeft`'s `Distribution::SinglePartition` requirement,
2978/// and keeping it would funnel the whole build side through one partition
2979/// before re-splitting it.
2980pub fn redistribute_unsplittable_broadcast_joins(
2981 plan: Arc<dyn ExecutionPlan>,
2982) -> SqlResult<Arc<dyn ExecutionPlan>> {
2983 use datafusion::physical_plan::joins::{HashJoinExec, PartitionMode};
2984
2985 // Bottom-up: children are rewritten before the node that joins them, so a
2986 // converted child's new partitioning is what the parent sees.
2987 let children = plan.children();
2988 let plan = if children.is_empty() {
2989 plan
2990 } else {
2991 let mut new_children = Vec::with_capacity(children.len());
2992 let mut changed = false;
2993 for child in children {
2994 let rewritten = redistribute_unsplittable_broadcast_joins(Arc::clone(child))?;
2995 changed = changed || !Arc::ptr_eq(&rewritten, child);
2996 new_children.push(rewritten);
2997 }
2998 if changed {
2999 plan.with_new_children(new_children)
3000 .map_err(|e| SqlError::DataFusion {
3001 message: format!("broadcast-join redistribution rewrite: {e}"),
3002 })?
3003 } else {
3004 plan
3005 }
3006 };
3007
3008 let Some(join) = plan.downcast_ref::<HashJoinExec>() else {
3009 return Ok(plan);
3010 };
3011 let unsplittable = is_unsplittable_broadcast_join(join);
3012 if !unsplittable && !is_degenerate_broadcast_join(join) && !broadcast_build_is_too_wide(join) {
3013 return Ok(plan);
3014 }
3015
3016 let partitions = join.right().output_partitioning().partition_count();
3017 let (left_keys, right_keys): (Vec<_>, Vec<_>) = join
3018 .on()
3019 .iter()
3020 .map(|(l, r)| (Arc::clone(l), Arc::clone(r)))
3021 .unzip();
3022
3023 let build_side = match join.left().downcast_ref::<CoalescePartitionsExec>() {
3024 Some(coalesce) => Arc::clone(coalesce.input()),
3025 None => Arc::clone(join.left()),
3026 };
3027 let exchange = |input: Arc<dyn ExecutionPlan>,
3028 keys: Vec<Arc<dyn datafusion::physical_expr::PhysicalExpr>>|
3029 -> SqlResult<Arc<dyn ExecutionPlan>> {
3030 RepartitionExec::try_new(input, Partitioning::Hash(keys, partitions))
3031 .map(|r| Arc::new(r) as Arc<dyn ExecutionPlan>)
3032 .map_err(|e| SqlError::DataFusion {
3033 message: format!("broadcast-join redistribution exchange: {e}"),
3034 })
3035 };
3036
3037 let converted = join
3038 .builder()
3039 .with_new_children(vec![
3040 exchange(build_side, left_keys)?,
3041 exchange(Arc::clone(join.right()), right_keys)?,
3042 ])
3043 .and_then(|b| {
3044 b.with_partition_mode(PartitionMode::Partitioned)
3045 .recompute_properties()
3046 .reset_state()
3047 .build_exec()
3048 })
3049 .map_err(|e| SqlError::DataFusion {
3050 message: format!("broadcast-join redistribution rebuild: {e}"),
3051 })?;
3052 tracing::debug!(
3053 join_type = ?join.join_type(),
3054 partitions,
3055 reason = if unsplittable { "unsplittable" } else { "oversized" },
3056 "converted a broadcast join to a hash-partitioned join"
3057 );
3058 Ok(converted)
3059}
3060
3061/// Give a severed stage subtree back the [`ScalarSubqueryExec`] wrapper its
3062/// `ScalarSubqueryExpr` nodes need in order to decode and to resolve.
3063///
3064/// A pass-through node: it reports its input's partitioning and statistics
3065/// verbatim, so wrapping changes neither the stage's task count nor its
3066/// shuffle keys — only whether the fragment can be rebuilt on an executor.
3067fn wrap_in_scalar_subquery_exec(
3068 plan: Arc<dyn ExecutionPlan>,
3069 context: &StageSubqueryContext,
3070) -> Arc<dyn ExecutionPlan> {
3071 Arc::new(ScalarSubqueryExec::new(
3072 plan,
3073 context.links.clone(),
3074 context.results.clone(),
3075 ))
3076}
3077
3078/// Extract plain column names from hash-partitioning expressions.
3079fn hash_expr_column_names(
3080 exprs: &[Arc<dyn datafusion::physical_expr::PhysicalExpr>],
3081) -> Option<Vec<String>> {
3082 use datafusion::physical_expr::expressions::Column;
3083 let mut names = Vec::with_capacity(exprs.len());
3084 for expr in exprs {
3085 let column = (expr.as_ref() as &dyn std::any::Any).downcast_ref::<Column>()?;
3086 names.push(column.name().to_owned());
3087 }
3088 (!names.is_empty()).then_some(names)
3089}
3090
3091/// Detect nodes that break the task-per-partition execution model.
3092fn find_unsupported_stage_node(plan: &Arc<dyn ExecutionPlan>) -> Option<String> {
3093 if plan.is::<RepartitionExec>() {
3094 return Some(String::from("RepartitionExec inside stage subtree"));
3095 }
3096 // The safety net behind `redistribute_unsplittable_broadcast_joins`. If a
3097 // broadcast join that emits unmatched build rows ever reaches a stage
3098 // subtree unconverted, declining to stage is the only correct outcome:
3099 // shipping it returns the wrong ANSWER rather than an error, and a wrong
3100 // answer that looks like a clean pass is the worst failure this builder
3101 // can produce.
3102 if let Some(join) = plan.downcast_ref::<datafusion::physical_plan::joins::HashJoinExec>()
3103 && is_unsplittable_broadcast_join(join)
3104 {
3105 return Some(format!(
3106 "broadcast {:?} join inside a stage subtree: its unmatched build rows are \
3107 emitted only after the last probe partition, which a task executing one \
3108 partition can never observe",
3109 join.join_type()
3110 ));
3111 }
3112 // A scalar subquery is executed WHOLE by whichever task evaluates it —
3113 // `ScalarSubqueryExec` runs each through `execute_stream`, which coalesces
3114 // the plan to a single partition. An exchange inside one is therefore
3115 // ordinary single-node execution, not a violation of the task-per-partition
3116 // model, and the rule below must not reach into it: descending would reject
3117 // any query whose subquery happens to contain a hash exchange and quietly
3118 // run the whole thing as one task.
3119 if let Some(subquery_exec) = plan.downcast_ref::<ScalarSubqueryExec>() {
3120 return find_unsupported_stage_node(subquery_exec.input());
3121 }
3122 for child in plan.children() {
3123 if let Some(reason) = find_unsupported_stage_node(child) {
3124 return Some(reason);
3125 }
3126 }
3127 None
3128}
3129
3130// ── Cross-stage runtime filters ────────────────────────────────────────────
3131
3132/// How many times larger the probe side must be estimated before a filter is
3133/// worth its stage.
3134///
3135/// The filter costs one extra scan of the build side plus a broadcast of a few
3136/// MB to every probe task. Below this ratio that is not obviously repaid, and a
3137/// rule that fires on marginal cases is exactly how the semi-join rule and the
3138/// broadcast over-reach each cost more than they gained.
3139const RUNTIME_FILTER_MIN_RATIO: usize = 8;
3140
3141/// A join that can carry a cross-stage runtime filter, with everything the
3142/// rewrite needs already validated.
3143#[derive(Debug, Clone, Copy)]
3144struct RuntimeFilterCandidate {
3145 build_stage: usize,
3146 probe_stage: usize,
3147 build_key_index: usize,
3148 probe_key_index: usize,
3149 filter_bytes: usize,
3150}
3151
3152/// What a join child reads, when it reads one upstream stage with the column
3153/// layout that stage's root emits.
3154#[derive(Debug, Clone, Copy)]
3155struct JoinSideRead {
3156 stage: usize,
3157 rows: Option<usize>,
3158}
3159
3160/// Find the [`ShuffleReadExec`] under a join child, if the column indexes at the
3161/// join are the same indexes the upstream stage's root emits.
3162///
3163/// The whole rewrite hinges on that equality: the key index comes from the
3164/// join's `on` expressions, which are `Column`s into the join child's schema,
3165/// and it is applied at the *root of the upstream stage*. So this descends only
3166/// through single-child nodes that leave the field list untouched — a
3167/// projection that reorders or renames stops the walk rather than silently
3168/// shifting which column gets filtered.
3169fn join_side_read(plan: &Arc<dyn ExecutionPlan>) -> Option<JoinSideRead> {
3170 let mut current = Arc::clone(plan);
3171 loop {
3172 if let Some(read) = current.downcast_ref::<ShuffleReadExec>() {
3173 return Some(JoinSideRead {
3174 stage: read.upstream_stage_index(),
3175 rows: read.upstream_estimate().0,
3176 });
3177 }
3178 let next = {
3179 let children = current.children();
3180 let [child] = children.as_slice() else {
3181 return None;
3182 };
3183 if current.schema().fields() != child.schema().fields() {
3184 return None;
3185 }
3186 Arc::clone(child)
3187 };
3188 current = next;
3189 }
3190}
3191
3192/// Decide whether one join earns a runtime filter, and on which key.
3193///
3194/// Every guard here exists because a plan rule that fires too widely has
3195/// already cost this engine more than it gained, twice.
3196/// Why joins were not even considered as runtime-filter candidates.
3197///
3198/// The guards inside [`runtime_filter_candidate`] all return `None`, so a join
3199/// rejected there never reaches the injection loop and never appears in its
3200/// counters. Instrumenting only the injection loop would have answered "how
3201/// many candidates were rejected" while leaving "why were there no candidates"
3202/// exactly as invisible as before — which is the half-fix that makes a rule
3203/// look installed-but-idle.
3204#[derive(Debug, Default, Clone, Copy)]
3205struct RuntimeFilterRejects {
3206 /// Joins inspected.
3207 joins: usize,
3208 /// Not an INNER join, so dropping probe rows would change the answer.
3209 not_inner: usize,
3210 /// A side does not resolve to a `ShuffleReadExec` through schema-preserving
3211 /// parents — a projection between the join and the read stops the walk.
3212 side_not_a_shuffle_read: usize,
3213 /// Both sides read the same stage; DataFusion's own dynamic filter covers it.
3214 same_stage: usize,
3215 /// No row estimate on one of the sides.
3216 no_row_estimate: usize,
3217 /// The build side is not selective enough to be worth a stage.
3218 not_selective: usize,
3219 /// The bloom would be clamped at the size ceiling, degrading to
3220 /// "matches everything".
3221 filter_too_large: usize,
3222 /// No equijoin pair of a type the filter can encode on both sides.
3223 no_encodable_key: usize,
3224 /// A join node this pass cannot even look at, because it is not a
3225 /// `HashJoinExec`.
3226 ///
3227 /// This is not a rejection — it is the pass being structurally blind, and
3228 /// it is why every other counter here read zero on the query this feature
3229 /// was built for. `SpillableJoinSelection` runs *before* stage cutting and
3230 /// converts an oversized hash join into a sort-merge join; on TPC-H q21 at
3231 /// SF100 it converted all five (`hash_joins: 5, converted: 3`, plus two it
3232 /// declined that were already sort-merge). By the time this pass walks the
3233 /// plan there is no `HashJoinExec` left to inspect, so it logged
3234 /// `joins_inspected: 0` — indistinguishable, in a log, from a query with no
3235 /// joins at all.
3236 ///
3237 /// Counting them separately makes "the rule declined" and "the rule could
3238 /// not see it" different observations. Generalising the rule to sort-merge
3239 /// (and grace) joins is the actual fix: a runtime filter is a semantic
3240 /// operation on the join's keys and does not care which algorithm executes
3241 /// it, and `runtime_filter_candidate` only needs `join_type`, `left`,
3242 /// `right` and the equijoin keys, all of which those nodes also have.
3243 joins_of_unsupported_kind: usize,
3244}
3245
3246/// The four things a runtime filter needs from a join, independent of which
3247/// algorithm executes it.
3248///
3249/// A bloom filter is a statement about the join's *keys*: probe rows whose key
3250/// cannot appear on the build side cannot join, whichever way the rows are
3251/// matched. Hash, sort-merge and grace joins all answer the same four
3252/// questions, so the rule reads them through this view instead of being
3253/// hard-wired to one node type — which is what left it inspecting zero joins on
3254/// q21 (see `joins_of_unsupported_kind`).
3255struct JoinView<'a> {
3256 /// By value: `HashJoinExec` hands back a reference and
3257 /// `SortMergeJoinExec` a copy, and `JoinType` is `Copy`.
3258 join_type: datafusion::logical_expr::JoinType,
3259 left: &'a Arc<dyn ExecutionPlan>,
3260 right: &'a Arc<dyn ExecutionPlan>,
3261 /// DataFusion's own name for the equijoin-pair slice, so this reads the
3262 /// same shape both join nodes expose rather than restating it.
3263 on: datafusion::physical_plan::joins::utils::JoinOnRef<'a>,
3264}
3265
3266fn runtime_filter_candidate(
3267 join: &JoinView<'_>,
3268 rejects: &mut RuntimeFilterRejects,
3269) -> Option<RuntimeFilterCandidate> {
3270 use datafusion::logical_expr::JoinType;
3271 use datafusion::physical_expr::expressions::Column;
3272 use krishiv_shuffle::{FilterKeyType, MAX_FILTER_BYTES, plan_filter_bytes};
3273
3274 // Guard — INNER only. A bloom drops probe rows that cannot match, which is
3275 // invisible to an inner join and catastrophic to anything that preserves
3276 // unmatched probe rows: RightAnti emits exactly the rows this removes, and
3277 // Full/Right pad them with nulls. `RuntimeFilter::contains` also drops null
3278 // keys, which is correct only where a null key cannot produce output.
3279 rejects.joins += 1;
3280 if join.join_type != JoinType::Inner {
3281 rejects.not_inner += 1;
3282 return None;
3283 }
3284 // Guard 1 — different stages. A same-stage join already gets DataFusion's
3285 // own dynamic filter, so firing there duplicates work for nothing.
3286 let (Some(build), Some(probe)) = (join_side_read(join.left), join_side_read(join.right)) else {
3287 rejects.side_not_a_shuffle_read += 1;
3288 return None;
3289 };
3290 if build.stage == probe.stage {
3291 rejects.same_stage += 1;
3292 return None;
3293 }
3294
3295 // Guard 2 — selectivity, on estimates that exist. `Precision::Absent`
3296 // arrives here as `None` and means "no idea", never "small": guessing is
3297 // the `SpillableJoinSelection` lesson, and guessing wrong here adds a stage
3298 // and a broadcast to a query that gains nothing from either.
3299 let (Some(build_rows), Some(probe_rows)) = (build.rows, probe.rows) else {
3300 rejects.no_row_estimate += 1;
3301 return None;
3302 };
3303 if build_rows == 0 || probe_rows / RUNTIME_FILTER_MIN_RATIO < build_rows {
3304 rejects.not_selective += 1;
3305 return None;
3306 }
3307
3308 // Guard 3 — size cap. `plan_filter_bytes` clamps at the ceiling, and a
3309 // clamped filter is one whose false-positive rate has quietly degraded
3310 // towards "matches everything" — correct, but pure cost.
3311 let filter_bytes = plan_filter_bytes(build_rows as u64);
3312 if filter_bytes >= MAX_FILTER_BYTES {
3313 rejects.filter_too_large += 1;
3314 return None;
3315 }
3316
3317 // Guard 6 — read the join's own equijoin pairs rather than re-deriving
3318 // them. Only plain columns of a type the filter can encode canonically;
3319 // for a composite key the first usable column is enough, because a row that
3320 // matches on every key column necessarily matches on one of them.
3321 let found = join.on.iter().find_map(|(left, right)| {
3322 let build_column = (left.as_ref() as &dyn std::any::Any).downcast_ref::<Column>()?;
3323 let probe_column = (right.as_ref() as &dyn std::any::Any).downcast_ref::<Column>()?;
3324 let build_schema = join.left.schema();
3325 let probe_schema = join.right.schema();
3326 let build_type =
3327 FilterKeyType::for_data_type(build_schema.field(build_column.index()).data_type())?;
3328 let probe_type =
3329 FilterKeyType::for_data_type(probe_schema.field(probe_column.index()).data_type())?;
3330 // A disagreement fails open at runtime, but there is no reason to build
3331 // a filter that will be ignored.
3332 (build_type == probe_type).then_some(RuntimeFilterCandidate {
3333 build_stage: build.stage,
3334 probe_stage: probe.stage,
3335 build_key_index: build_column.index(),
3336 probe_key_index: probe_column.index(),
3337 filter_bytes,
3338 })
3339 });
3340 if found.is_none() {
3341 rejects.no_encodable_key += 1;
3342 }
3343 found
3344}
3345
3346fn collect_runtime_filter_candidates(
3347 plan: &Arc<dyn ExecutionPlan>,
3348 out: &mut Vec<RuntimeFilterCandidate>,
3349 rejects: &mut RuntimeFilterRejects,
3350) {
3351 // Both equijoin algorithms are read through the same view. Which one the
3352 // planner picked is not a property of the filter: `SpillableJoinSelection`
3353 // converts an oversized hash join to sort-merge *before* stage cutting, and
3354 // on q21 at SF100 it converted all five — so keying on `HashJoinExec` alone
3355 // meant the rule saw nothing at all on the query it was written for.
3356 if let Some(join) = plan.downcast_ref::<datafusion::physical_plan::joins::HashJoinExec>() {
3357 let view = JoinView {
3358 join_type: *join.join_type(),
3359 left: join.left(),
3360 right: join.right(),
3361 on: join.on(),
3362 };
3363 if let Some(candidate) = runtime_filter_candidate(&view, rejects) {
3364 out.push(candidate);
3365 }
3366 } else if let Some(join) =
3367 plan.downcast_ref::<datafusion::physical_plan::joins::SortMergeJoinExec>()
3368 {
3369 let view = JoinView {
3370 join_type: join.join_type(),
3371 left: join.left(),
3372 right: join.right(),
3373 on: join.on(),
3374 };
3375 if let Some(candidate) = runtime_filter_candidate(&view, rejects) {
3376 out.push(candidate);
3377 }
3378 } else if plan
3379 .downcast_ref::<datafusion::physical_plan::joins::NestedLoopJoinExec>()
3380 .is_some()
3381 || plan
3382 .downcast_ref::<crate::grace_hash_join::GraceHashJoinExec>()
3383 .is_some()
3384 {
3385 // Still unreachable, for different reasons, and both honest:
3386 //
3387 // * `NestedLoopJoinExec` has no equijoin pairs at all — it is the node
3388 // DataFusion picks when there is no equality to key on, so there is
3389 // no key to build a filter over. Not a gap; a category error.
3390 // * `GraceHashJoinExec` is ours and does carry equi keys, but it
3391 // partitions its build side into buckets on disk, and whether a probe
3392 // filter composes with that spill protocol is a question this pass
3393 // should not answer by assumption. Left counted until it is measured.
3394 rejects.joins_of_unsupported_kind += 1;
3395 }
3396 for child in plan.children() {
3397 collect_runtime_filter_candidates(child, out, rejects);
3398 }
3399}
3400
3401/// Does stage `from` depend, transitively, on stage `target`?
3402///
3403/// Guard 4. The filter stage inherits the build stage's upstreams, and the probe
3404/// stage gains a dependency on the filter stage — so if the build side already
3405/// depends on the probe side, that new edge closes a loop. The scheduler would
3406/// catch it (Kahn's algorithm, `validate_job`) but only by rejecting the whole
3407/// job, which turns an optimization into an outage.
3408fn stage_depends_on(drafts: &[StageDraft], from: usize, target: usize) -> bool {
3409 let mut seen = vec![false; drafts.len()];
3410 let mut stack = vec![from];
3411 while let Some(index) = stack.pop() {
3412 if index == target {
3413 return true;
3414 }
3415 match seen.get_mut(index) {
3416 Some(flag) if !*flag => *flag = true,
3417 _ => continue,
3418 }
3419 if let Some(draft) = drafts.get(index) {
3420 stack.extend(collect_upstream_stage_indexes(&draft.plan));
3421 }
3422 }
3423 false
3424}
3425
3426// ── Stage reuse (Spark's ReuseExchange) ────────────────────────────────────
3427
3428/// Env flag for cross-stage reuse of identical leaf stages. Default **off**.
3429pub const STAGE_REUSE_ENV: &str = "KRISHIV_STAGE_REUSE";
3430
3431/// Whether identical leaf stages are collapsed into one.
3432pub fn stage_reuse_enabled() -> bool {
3433 std::env::var(STAGE_REUSE_ENV)
3434 .map(|v| {
3435 let v = v.trim();
3436 v == "1" || v.eq_ignore_ascii_case("true") || v.eq_ignore_ascii_case("on")
3437 })
3438 .unwrap_or(false)
3439}
3440
3441/// Function names whose presence makes a subtree non-reusable.
3442///
3443/// Reuse replaces two evaluations with one, which is only sound when the
3444/// subtree is **deterministic**. `ExecutionPlan` exposes no expression
3445/// accessor, so the rendered plan text is the only place a volatile call is
3446/// visible — this matches on that text. Matching is deliberately over-eager: a
3447/// column merely *named* `random_score` also blocks reuse. A false positive
3448/// costs one missed optimization; a false negative would silently collapse two
3449/// evaluations that were supposed to differ.
3450const VOLATILE_MARKERS: &[&str] = &[
3451 "random(",
3452 "rand(",
3453 "uuid(",
3454 "now(",
3455 "current_timestamp",
3456 "current_date",
3457 "current_time",
3458 "nextval",
3459];
3460
3461/// Rewrite every `ShuffleReadExec` in `plan`, remapping its upstream stage
3462/// index through `remap`.
3463fn remap_shuffle_reads(
3464 plan: &Arc<dyn ExecutionPlan>,
3465 remap: &std::collections::HashMap<usize, usize>,
3466) -> Arc<dyn ExecutionPlan> {
3467 if let Some(read) = plan.downcast_ref::<ShuffleReadExec>() {
3468 let old = read.upstream_stage_index();
3469 if let Some(&new) = remap.get(&old)
3470 && new != old
3471 {
3472 return Arc::new(read.clone_with_upstream_stage_index(new));
3473 }
3474 return Arc::clone(plan);
3475 }
3476 let children = plan.children();
3477 if children.is_empty() {
3478 return Arc::clone(plan);
3479 }
3480 let new_children: Vec<_> = children
3481 .iter()
3482 .map(|child| remap_shuffle_reads(child, remap))
3483 .collect();
3484 let changed = new_children
3485 .iter()
3486 .zip(children.iter())
3487 .any(|(new, old)| !Arc::ptr_eq(new, old));
3488 if !changed {
3489 return Arc::clone(plan);
3490 }
3491 Arc::clone(plan)
3492 .with_new_children(new_children)
3493 .unwrap_or_else(|_| Arc::clone(plan))
3494}
3495
3496/// Collapse identical leaf stages into one, so a subtree computed twice is
3497/// computed once and both consumers read the same shuffle output.
3498///
3499/// This is Spark's `ReuseExchange`, and it fits our model exactly: the cutter
3500/// already materializes every stage boundary, so two stages that compute the
3501/// same thing are two writes of the same bytes.
3502///
3503/// # What it does NOT reach, measured
3504///
3505/// The three cases originally listed here (q18/q21 `lineitem`, q2 `partsupp`)
3506/// were identified as duplicated **scans**, and this rule was written as if
3507/// that made them duplicated **stages**. It does not. On the SF100 sweep of
3508/// `fast-6f586954` the rule fired **twice across all 22 queries**, one stage
3509/// each.
3510///
3511/// `tests/stage_reuse_duplicate_scan.rs` reproduces q18's shape over real
3512/// parquet and shows why:
3513///
3514/// ```text
3515/// stage A: AggregateExec(Partial, gby=l_orderkey, sum(l_quantity))
3516/// DataSourceExec lineitem[l_orderkey, l_quantity] DynamicFilter [ empty ]
3517/// stage B: DataSourceExec lineitem[l_orderkey, l_quantity] DynamicFilter [ empty ]
3518/// ```
3519///
3520/// Identical scan, identical projection, identical predicate — and a partial
3521/// aggregate fused onto one of them. They are not the same stage and never
3522/// will be. (The `DynamicFilter` is *identical* on both sides and is not the
3523/// blocker, which is what an earlier note here guessed.)
3524///
3525/// # And sharing the scan is probably the wrong trade here anyway
3526///
3527/// Both consumers want `lineitem` partitioned by `l_orderkey`, so one shuffle
3528/// could serve both — but only by lifting the partial aggregate above the
3529/// exchange, which puts ~600M raw rows on the wire in place of the partially
3530/// aggregated ~150M. This cluster's floor is the network (pod-to-pod ~11 MiB/s
3531/// VXLAN vs 150-286 MB/s node-local MinIO — `bench-storage-longhorn-bottleneck`,
3532/// `bench-storage-locality-fix`), so converting a cheap local re-read into an
3533/// expensive shuffle read is a loss, not a win.
3534///
3535/// The lever for q18 is not scanning `lineitem` once; it is not shipping 600M
3536/// rows at all (`cross-stage-runtime-filter-design`).
3537///
3538/// **Restricted to leaf stages** (no `ShuffleReadExec` inside, no severed
3539/// scalar-subquery context). Two reasons, both load-bearing: a leaf stage
3540/// contains no stage indexes, so collapsing it can never invalidate a
3541/// reference *inside* it; and a leaf stage is a scan + row-wise work, the case
3542/// where textual identity is most trustworthy. Non-leaf reuse (q17's
3543/// projection-subsumed scan) needs a different, wider rule.
3544///
3545/// Returns the number of stages removed.
3546fn dedupe_identical_stages(
3547 root: &mut Arc<dyn ExecutionPlan>,
3548 drafts: &mut Vec<StageDraft>,
3549) -> usize {
3550 if !stage_reuse_enabled() {
3551 return 0;
3552 }
3553 dedupe_identical_stages_unconditionally(root, drafts)
3554}
3555
3556/// [`dedupe_identical_stages`] without the flag check, so the rewrite and every
3557/// guard are testable without `set_var` racing across test threads.
3558fn dedupe_identical_stages_unconditionally(
3559 root: &mut Arc<dyn ExecutionPlan>,
3560 drafts: &mut Vec<StageDraft>,
3561) -> usize {
3562 use datafusion::physical_plan::displayable;
3563
3564 // The identity is the **encoded plan**, not the rendered plan text.
3565 //
3566 // Plan text is a display function, not a semantic identity: two
3567 // `DataSourceExec`s over different in-memory data render identically,
3568 // because the printer has nothing to show. Keying on text collapsed two
3569 // stages producing different rows — a wrong answer, caught by
3570 // `different_content_does_not_collapse`. The protobuf encoding is the
3571 // bytes we actually ship to executors, so byte-identical encodings are the
3572 // same computation by construction, and a plan that will not encode is
3573 // simply not eligible (fails closed).
3574 let codec = KrishivPhysicalCodec::coordinator();
3575
3576 // canonical key -> first draft index carrying it
3577 let mut first_seen: std::collections::HashMap<Vec<u8>, usize> =
3578 std::collections::HashMap::new();
3579 // duplicate index -> index it is replaced by
3580 let mut replaced_by: std::collections::HashMap<usize, usize> = std::collections::HashMap::new();
3581
3582 for (index, draft) in drafts.iter().enumerate() {
3583 if draft.subqueries.is_some() {
3584 continue;
3585 }
3586 if !collect_upstream_stage_indexes(&draft.plan).is_empty() {
3587 continue;
3588 }
3589 let Some(shuffle) = &draft.shuffle else {
3590 continue;
3591 };
3592 let text = displayable(draft.plan.as_ref()).indent(true).to_string();
3593 let lowered = text.to_ascii_lowercase();
3594 if VOLATILE_MARKERS.iter().any(|m| lowered.contains(m)) {
3595 continue;
3596 }
3597 // The shuffle contract is part of the identity: two stages computing
3598 // the same rows but partitioning them differently are NOT
3599 // interchangeable, because the consumer reads by partition index.
3600 //
3601 // `map_tasks` matters for a less obvious reason: a `ShuffleReadExec` is
3602 // constructed with the producer's task count, so repointing a reader at
3603 // a stage with a different task count would make it look for map
3604 // outputs that do not exist.
3605 let Ok(encoded) = encode_dfplan_bytes(Arc::clone(&draft.plan), &codec) else {
3606 // Not shippable, so not reusable. The main loop has its own
3607 // fallback for this; here it just means "skip".
3608 continue;
3609 };
3610 let mut key = encoded;
3611 key.extend_from_slice(
3612 format!(
3613 "|keys={:?}|parts={}|map_tasks={}|schema={:?}",
3614 shuffle.key_columns,
3615 shuffle.num_output_partitions,
3616 draft.plan.output_partitioning().partition_count(),
3617 draft.plan.schema()
3618 )
3619 .as_bytes(),
3620 );
3621 match first_seen.get(&key) {
3622 Some(&canonical) => {
3623 replaced_by.insert(index, canonical);
3624 }
3625 None => {
3626 first_seen.insert(key, index);
3627 }
3628 }
3629 }
3630
3631 if replaced_by.is_empty() {
3632 return 0;
3633 }
3634
3635 // Compact the draft list, building old -> new for the survivors.
3636 let mut remap: std::collections::HashMap<usize, usize> = std::collections::HashMap::new();
3637 let mut survivors: Vec<StageDraft> = Vec::with_capacity(drafts.len() - replaced_by.len());
3638 for (old_index, draft) in std::mem::take(drafts).into_iter().enumerate() {
3639 if replaced_by.contains_key(&old_index) {
3640 continue;
3641 }
3642 remap.insert(old_index, survivors.len());
3643 survivors.push(draft);
3644 }
3645 // Duplicates point at their canonical stage's NEW index. The canonical is
3646 // always a survivor (it is the first occurrence, and only later
3647 // occurrences are removed), so this lookup cannot fail.
3648 for (duplicate, canonical) in &replaced_by {
3649 if let Some(&new_canonical) = remap.get(canonical) {
3650 remap.insert(*duplicate, new_canonical);
3651 }
3652 }
3653
3654 let removed = replaced_by.len();
3655 for draft in &mut survivors {
3656 draft.plan = remap_shuffle_reads(&draft.plan, &remap);
3657 }
3658 *root = remap_shuffle_reads(root, &remap);
3659 *drafts = survivors;
3660
3661 tracing::info!(
3662 removed_stages = removed,
3663 "collapsed identical leaf stages (stage reuse)"
3664 );
3665 removed
3666}
3667
3668/// Insert cross-stage runtime filters: for each qualifying join, add a stage
3669/// that builds a bloom of the build-side key and make the probe stage filter
3670/// its rows through it before shuffling them.
3671///
3672/// Returns the number of filters injected. Off unless
3673/// [`crate::runtime_filter_exec::enabled`]; a failure to build any single
3674/// filter skips that filter and leaves the plan untouched, because a
3675/// throughput optimization must never be why a query fails.
3676fn inject_runtime_filters(root: &Arc<dyn ExecutionPlan>, drafts: &mut Vec<StageDraft>) -> usize {
3677 if !crate::runtime_filter_exec::enabled() {
3678 // Count and report anyway, then change nothing.
3679 //
3680 // The early return used to happen here, *before* the pass computed its
3681 // rejection breakdown — so the one diagnostic that would tell an
3682 // operator whether enabling the flag is worth trying was available only
3683 // after enabling it. Every run since the counters landed in `59243a94`
3684 // has therefore produced zero `runtime-filter: pass complete` lines, and
3685 // the note-to-self to "read that line before theorising" was unsatisfiable
3686 // by construction.
3687 //
3688 // A dry run costs one read-only walk of a plan that has just been built
3689 // and cut, and it makes "would this fire?" answerable from an ordinary
3690 // benchmark log.
3691 report_runtime_filter_candidates(root, drafts);
3692 return 0;
3693 }
3694 inject_runtime_filters_unconditionally(root, drafts)
3695}
3696
3697/// Walk the plan exactly as the injector would and log the same
3698/// `runtime-filter: pass complete` breakdown, without rewriting anything.
3699///
3700/// Deliberately shares [`collect_runtime_filter_candidates`] with the real pass:
3701/// a dry run that used its own traversal would answer a question nobody asked.
3702fn report_runtime_filter_candidates(root: &Arc<dyn ExecutionPlan>, drafts: &[StageDraft]) {
3703 let mut candidates = Vec::new();
3704 let mut rejects = RuntimeFilterRejects::default();
3705 collect_runtime_filter_candidates(root, &mut candidates, &mut rejects);
3706 for draft in drafts {
3707 collect_runtime_filter_candidates(&draft.plan, &mut candidates, &mut rejects);
3708 }
3709 tracing::info!(
3710 joins_inspected = rejects.joins,
3711 not_inner = rejects.not_inner,
3712 side_not_a_shuffle_read = rejects.side_not_a_shuffle_read,
3713 same_stage = rejects.same_stage,
3714 no_row_estimate = rejects.no_row_estimate,
3715 not_selective = rejects.not_selective,
3716 filter_too_large = rejects.filter_too_large,
3717 no_encodable_key = rejects.no_encodable_key,
3718 joins_of_unsupported_kind = rejects.joins_of_unsupported_kind,
3719 candidates = candidates.len(),
3720 injected = 0,
3721 enabled = false,
3722 "runtime-filter: pass complete"
3723 );
3724}
3725
3726/// [`inject_runtime_filters`] without the flag check.
3727///
3728/// Split out so the rewrite and every guard can be tested directly. Reading the
3729/// flag inside the tested function would make the whole rule depend on process
3730/// environment, and `set_var` across parallel test threads is a race, not a
3731/// fixture.
3732fn inject_runtime_filters_unconditionally(
3733 root: &Arc<dyn ExecutionPlan>,
3734 drafts: &mut Vec<StageDraft>,
3735) -> usize {
3736 use crate::runtime_filter_exec::{
3737 RuntimeFilterBuildExec, RuntimeFilterProbeExec, filter_schema,
3738 };
3739 use datafusion::physical_expr::expressions::Column;
3740 use datafusion::physical_plan::projection::ProjectionExec;
3741
3742 let mut candidates = Vec::new();
3743 let mut rejects = RuntimeFilterRejects::default();
3744 collect_runtime_filter_candidates(root, &mut candidates, &mut rejects);
3745 for draft in drafts.iter() {
3746 collect_runtime_filter_candidates(&draft.plan, &mut candidates, &mut rejects);
3747 }
3748
3749 let mut touched: Vec<usize> = Vec::new();
3750 let mut injected = 0usize;
3751 // Why each candidate was turned away.
3752 //
3753 // Every rejection below was previously either `debug!` (invisible at the
3754 // executors' and coordinator's `info` level) or a bare `continue`. So a run
3755 // that found no candidates and a run that rejected all of them produced
3756 // *identical* output: nothing. Turning the flag on for q18 changed the
3757 // wall time by 2.4% and left 0 log lines, and there was no way to tell
3758 // whether the rule had declined or was simply not installed.
3759 //
3760 // That is the exact failure that made `SpillableJoinSelection` cost hours
3761 // of live investigation before its "pass complete" line existed. One
3762 // counted summary, at info, is the whole fix.
3763 let candidate_count = candidates.len();
3764 let (mut already_touched, mut missing_stage, mut severed_subquery) = (0usize, 0usize, 0usize);
3765 let (mut would_cycle, mut no_key_field, mut build_failed, mut probe_failed) =
3766 (0usize, 0usize, 0usize, 0usize);
3767 for candidate in candidates {
3768 // One filter per stage, and never over a stage this pass has already
3769 // rewritten: stacking rewrites would have each filter stage clone the
3770 // previous one's probe node, which is correct but compounds cost for a
3771 // shrinking return.
3772 if touched.contains(&candidate.build_stage) || touched.contains(&candidate.probe_stage) {
3773 already_touched += 1;
3774 continue;
3775 }
3776 let (Some(build), Some(probe)) = (
3777 drafts.get(candidate.build_stage),
3778 drafts.get(candidate.probe_stage),
3779 ) else {
3780 missing_stage += 1;
3781 continue;
3782 };
3783 // A stage severed from a `ScalarSubqueryExec` is parameterised by a
3784 // subquery result; cloning its subtree without the wrapper produces a
3785 // fragment that cannot decode.
3786 if build.subqueries.is_some() || probe.subqueries.is_some() {
3787 severed_subquery += 1;
3788 continue;
3789 }
3790 if stage_depends_on(drafts, candidate.build_stage, candidate.probe_stage) {
3791 would_cycle += 1;
3792 continue;
3793 }
3794
3795 let source = Arc::clone(&build.plan);
3796 let probe_plan = Arc::clone(&probe.plan);
3797 let schema = source.schema();
3798 let Some(field) = schema.fields().get(candidate.build_key_index) else {
3799 no_key_field += 1;
3800 continue;
3801 };
3802 let name = field.name().clone();
3803 // Project to the key column alone before coalescing: the filter stage
3804 // needs one column, and carrying the rest through a single task is
3805 // memory spent to be thrown away.
3806 let projected = ProjectionExec::try_new(
3807 vec![(
3808 Arc::new(Column::new(&name, candidate.build_key_index)) as _,
3809 name.clone(),
3810 )],
3811 source,
3812 );
3813 let filter_plan = projected.and_then(|projected| {
3814 let coalesced = Arc::new(CoalescePartitionsExec::new(Arc::new(projected)));
3815 RuntimeFilterBuildExec::try_new(coalesced, 0, candidate.filter_bytes)
3816 });
3817 let filter_plan = match filter_plan {
3818 Ok(plan) => Arc::new(plan) as Arc<dyn ExecutionPlan>,
3819 Err(error) => {
3820 build_failed += 1;
3821 tracing::debug!(%error, "declined to build a runtime filter stage");
3822 continue;
3823 }
3824 };
3825
3826 let filter_stage = drafts.len();
3827 // A single map task (the coalesce above) writing one keyless partition:
3828 // exactly the gather shape the cutter already emits for ungrouped
3829 // aggregates, so the writer and reader need no special case.
3830 let read = ShuffleReadExec::new(filter_stage, 1, 1, filter_schema(), None);
3831 let rewritten =
3832 RuntimeFilterProbeExec::try_new(probe_plan, Arc::new(read), candidate.probe_key_index);
3833 let rewritten = match rewritten {
3834 Ok(plan) => Arc::new(plan) as Arc<dyn ExecutionPlan>,
3835 Err(error) => {
3836 probe_failed += 1;
3837 tracing::debug!(%error, "declined to apply a runtime filter to the probe stage");
3838 continue;
3839 }
3840 };
3841 let Some(probe_draft) = drafts.get_mut(candidate.probe_stage) else {
3842 missing_stage += 1;
3843 continue;
3844 };
3845 probe_draft.plan = rewritten;
3846 drafts.push(StageDraft {
3847 plan: filter_plan,
3848 shuffle: Some(StageShuffleOutput {
3849 key_columns: Vec::new(),
3850 num_output_partitions: 1,
3851 }),
3852 subqueries: None,
3853 });
3854 touched.push(candidate.build_stage);
3855 touched.push(candidate.probe_stage);
3856 injected += 1;
3857 tracing::info!(
3858 build_stage = candidate.build_stage,
3859 probe_stage = candidate.probe_stage,
3860 filter_stage,
3861 filter_bytes = candidate.filter_bytes,
3862 "injected a cross-stage runtime filter"
3863 );
3864 }
3865 // At info, unconditionally: "no candidates" and "rejected every candidate"
3866 // must never again be indistinguishable from "rule not installed".
3867 tracing::info!(
3868 joins_inspected = rejects.joins,
3869 not_inner = rejects.not_inner,
3870 side_not_a_shuffle_read = rejects.side_not_a_shuffle_read,
3871 same_stage = rejects.same_stage,
3872 no_row_estimate = rejects.no_row_estimate,
3873 not_selective = rejects.not_selective,
3874 filter_too_large = rejects.filter_too_large,
3875 no_encodable_key = rejects.no_encodable_key,
3876 joins_of_unsupported_kind = rejects.joins_of_unsupported_kind,
3877 candidates = candidate_count,
3878 injected,
3879 already_touched,
3880 missing_stage,
3881 severed_subquery,
3882 would_cycle,
3883 no_key_field,
3884 build_failed,
3885 probe_failed,
3886 "runtime-filter: pass complete"
3887 );
3888 injected
3889}
3890
3891fn collect_upstream_stage_indexes(plan: &Arc<dyn ExecutionPlan>) -> Vec<usize> {
3892 let mut indexes = Vec::new();
3893 collect_upstream_inner(plan, &mut indexes);
3894 indexes.sort_unstable();
3895 indexes.dedup();
3896 indexes
3897}
3898
3899fn collect_upstream_inner(plan: &Arc<dyn ExecutionPlan>, out: &mut Vec<usize>) {
3900 if let Some(read) = plan.downcast_ref::<ShuffleReadExec>() {
3901 out.push(read.upstream_stage_index());
3902 }
3903 for child in plan.children() {
3904 collect_upstream_inner(child, out);
3905 }
3906}
3907
3908#[cfg(test)]
3909mod tests {
3910
3911 /// Does ANY node of the plan carry a scalar subquery?
3912 ///
3913 /// `LogicalPlan::expressions()` returns only the expressions of the node it
3914 /// is called on — for `SELECT ... WHERE x > (subquery)` the root is a
3915 /// Projection and the subquery lives in the Filter beneath it. Checking the
3916 /// root alone silently proves nothing, which is what the precondition
3917 /// assertion in these tests exists to catch.
3918 fn plan_has_scalar_subquery(plan: &datafusion::logical_expr::LogicalPlan) -> bool {
3919 use datafusion::common::tree_node::{TreeNode, TreeNodeRecursion};
3920 use datafusion::logical_expr::Expr;
3921 let mut found = false;
3922 let _ = plan.apply(|node| {
3923 if node
3924 .expressions()
3925 .iter()
3926 .any(Expr::contains_scalar_subquery)
3927 {
3928 found = true;
3929 return Ok(TreeNodeRecursion::Stop);
3930 }
3931 Ok(TreeNodeRecursion::Continue)
3932 });
3933 found
3934 }
3935
3936 /// q22: the whole point is that a query carrying an uncorrelated scalar
3937 /// subquery becomes stageable. Before the fold, `ScalarSubqueryExpr` cannot
3938 /// round-trip through `dfplan` encoding, the verify step refuses the
3939 /// fragment, and the caller runs the query as ONE task while reporting a
3940 /// clean pass.
3941 ///
3942 /// Asserts the outcome (stages exist, and the plan no longer carries a
3943 /// scalar subquery) rather than the mechanism, so the test survives a
3944 /// change of folding strategy.
3945 #[tokio::test]
3946 async fn an_uncorrelated_scalar_subquery_is_folded_so_the_query_can_stage() {
3947 let ctx = planning_session_context(4);
3948 ctx.sql(
3949 "CREATE TABLE acct(id BIGINT, bal DOUBLE) AS VALUES (1, 10.0), (2, 30.0), (3, 50.0)",
3950 )
3951 .await
3952 .unwrap()
3953 .collect()
3954 .await
3955 .unwrap();
3956
3957 let sql = "SELECT id FROM acct WHERE bal > (SELECT avg(bal) FROM acct)";
3958 let before = ctx.sql(sql).await.unwrap();
3959 assert!(
3960 plan_has_scalar_subquery(before.logical_plan()),
3961 "precondition: the planned query must actually carry a scalar \
3962 subquery, or this test proves nothing"
3963 );
3964
3965 let after = inline_uncorrelated_scalar_subqueries(&ctx, before)
3966 .await
3967 .unwrap();
3968 assert!(
3969 !plan_has_scalar_subquery(after.logical_plan()),
3970 "the uncorrelated subquery must be folded to a constant"
3971 );
3972
3973 // And the fold must not change the answer: avg is 30.0, so only id=3.
3974 let rows = after.collect().await.unwrap();
3975 let total: usize = rows.iter().map(|b| b.num_rows()).sum();
3976 assert_eq!(
3977 total, 1,
3978 "folding a constant must not change the result set"
3979 );
3980 }
3981
3982 /// A CORRELATED subquery references the outer row, so it is not a constant
3983 /// and must be left exactly as it was. Getting this wrong would produce
3984 /// silently wrong answers, which is far worse than the single-task
3985 /// fallback this fix exists to remove.
3986 #[tokio::test]
3987 async fn a_correlated_scalar_subquery_is_left_alone() {
3988 let ctx = planning_session_context(4);
3989 ctx.sql("CREATE TABLE t(k BIGINT, v DOUBLE) AS VALUES (1, 10.0), (2, 30.0)")
3990 .await
3991 .unwrap()
3992 .collect()
3993 .await
3994 .unwrap();
3995 ctx.sql("CREATE TABLE u(k BIGINT, w DOUBLE) AS VALUES (1, 5.0), (2, 40.0)")
3996 .await
3997 .unwrap()
3998 .collect()
3999 .await
4000 .unwrap();
4001
4002 let sql = "SELECT k FROM t WHERE v > (SELECT max(w) FROM u WHERE u.k = t.k)";
4003 let Ok(before) = ctx.sql(sql).await else {
4004 // Some correlated shapes are decorrelated by the optimizer before
4005 // we ever see them; nothing to assert if this one is rejected.
4006 return;
4007 };
4008 let had_subquery = plan_has_scalar_subquery(before.logical_plan());
4009 let after = inline_uncorrelated_scalar_subqueries(&ctx, before)
4010 .await
4011 .unwrap();
4012 let still_has = plan_has_scalar_subquery(after.logical_plan());
4013 assert_eq!(
4014 had_subquery, still_has,
4015 "a correlated subquery depends on the outer row and must never be \
4016 folded to a constant"
4017 );
4018 }
4019 use datafusion::prelude::SessionConfig;
4020
4021 /// D3(2): the point of carrying the upstream estimate is that a
4022 /// shuffle-fed join side stops reporting `Absent`. This asserts the
4023 /// property the optimizer rules actually key on, not the field value —
4024 /// `SpillableJoinSelection` returns `Ok(None)` on `Absent` by design, so
4025 /// "absent" and "known" is the whole distinction that matters.
4026 #[test]
4027 fn a_shuffle_read_reports_its_upstream_estimate_instead_of_unknown() {
4028 use datafusion::common::stats::Precision;
4029 let schema = Arc::new(arrow::datatypes::Schema::new(vec![
4030 arrow::datatypes::Field::new("a", arrow::datatypes::DataType::Int64, false),
4031 ]));
4032
4033 let unknown = ShuffleReadExec::new(0, 4, 4, Arc::clone(&schema), None);
4034 assert_eq!(
4035 unknown.partition_statistics(None).unwrap().total_byte_size,
4036 Precision::Absent,
4037 "a read with no estimate must stay Absent — inventing a size is how \
4038 a spill decision gets made on a guess"
4039 );
4040
4041 let known = ShuffleReadExec::new(0, 4, 4, Arc::clone(&schema), None)
4042 .with_upstream_estimate(Some(1_000), Some(800_000));
4043 let whole = known.partition_statistics(None).unwrap();
4044 assert_eq!(whole.num_rows, Precision::Inexact(1_000));
4045 assert_eq!(
4046 whole.total_byte_size,
4047 Precision::Inexact(800_000),
4048 "the whole-plan question gets the whole stage's size"
4049 );
4050
4051 let one = known.partition_statistics(Some(0)).unwrap();
4052 assert_eq!(
4053 one.total_byte_size,
4054 Precision::Inexact(200_000),
4055 "a per-partition question gets the even-split share of 4 partitions"
4056 );
4057 }
4058
4059 /// The estimate has to survive the wire, or the executor runs a plan whose
4060 /// sizes disagree with the plan the coordinator optimized.
4061 #[test]
4062 fn the_upstream_estimate_survives_encode_decode() {
4063 let schema = Arc::new(arrow::datatypes::Schema::new(vec![
4064 arrow::datatypes::Field::new("a", arrow::datatypes::DataType::Int64, false),
4065 ]));
4066 let node: Arc<dyn ExecutionPlan> = Arc::new(
4067 ShuffleReadExec::new(3, 2, 4, schema, None)
4068 .with_upstream_estimate(Some(77), Some(4_096)),
4069 );
4070 let codec = KrishivPhysicalCodec::coordinator();
4071 let mut buf = Vec::new();
4072 codec.try_encode(Arc::clone(&node), &mut buf).unwrap();
4073
4074 let ctx = crate::SqlEngine::new_with_engine_memory(crate::EngineMemory::Unbounded);
4075 let task_ctx = ctx.session_context().task_ctx();
4076 let decoded = codec.try_decode(&buf, &[], &task_ctx).unwrap();
4077
4078 // Re-encode and compare bytes rather than downcasting: it asserts the
4079 // same property (the estimate made the trip intact) and it also catches
4080 // a field that decodes but is dropped on the way back out.
4081 let mut round_tripped = Vec::new();
4082 codec.try_encode(decoded, &mut round_tripped).unwrap();
4083 assert_eq!(
4084 String::from_utf8(round_tripped).unwrap(),
4085 String::from_utf8(buf).unwrap(),
4086 "the upstream estimate must survive encode -> decode -> encode"
4087 );
4088 }
4089 use super::*;
4090 use arrow::record_batch::RecordBatch;
4091 use datafusion::physical_plan::displayable;
4092 use datafusion_proto::physical_plan::DefaultPhysicalExtensionCodec;
4093 use std::collections::HashMap;
4094 use std::sync::Mutex;
4095
4096 /// The stage builder plans on a throwaway context, so an `s3://` table must
4097 /// resolve to an object store there. When it did not, `register_parquet`
4098 /// errored, the caller read that as "decline to stage", and the whole
4099 /// dataset was scanned by a single executor — correct results, silently
4100 /// zero distribution.
4101 ///
4102 /// The property under test is that the bucket resolves, and that an
4103 /// explicit registration takes precedence over the lazy fallback (explicit
4104 /// registration is what carries endpoint and credential configuration).
4105 /// This test previously opened by asserting a fresh context could *not*
4106 /// resolve the bucket; installing `LazyCloudObjectStoreRegistry` on the
4107 /// planning context made that precondition false, so the assertion, not
4108 /// the behavior, was wrong.
4109 ///
4110 /// The round-trip guard must reject bytes that do not decode — feeding it
4111 /// garbage proves it inspects them rather than returning Ok
4112 /// unconditionally.
4113 #[test]
4114 fn the_roundtrip_guard_rejects_bytes_that_do_not_decode() {
4115 let codec = DefaultPhysicalExtensionCodec {};
4116 let err = verify_dfplan_roundtrip(
4117 b"not a physical plan proto",
4118 &codec,
4119 &fragment_decode_session_context().task_ctx(),
4120 None,
4121 )
4122 .expect_err("undecodable bytes must be rejected");
4123 assert!(format!("{err}").contains("decode"), "got: {err}");
4124 }
4125
4126 /// The regression that got the first guard reverted: it verified against a
4127 /// bare context with no object-store registry, so every s3-scanning
4128 /// fragment failed the check and silently fell back to single-task (q1:
4129 /// 13 tasks -> 1 task, 156 s -> 595 s). The verify context must resolve
4130 /// object stores exactly like the executor's runtime — this asserts the
4131 /// capability delta that broke, without needing a network round trip
4132 /// (constructing a lazy store does not contact the endpoint).
4133 #[test]
4134 fn the_verify_context_resolves_object_stores_like_the_executor() {
4135 use datafusion::execution::object_store::ObjectStoreUrl;
4136 let url = ObjectStoreUrl::parse("s3://roundtrip-bucket").expect("url");
4137
4138 // A bare context cannot resolve the bucket — the first guard's bug.
4139 assert!(
4140 SessionContext::new()
4141 .runtime_env()
4142 .object_store(url.clone())
4143 .is_err(),
4144 "precondition: a bare context must NOT resolve s3, or this test proves nothing"
4145 );
4146
4147 // The context the guard actually uses must.
4148 planning_session_context(1)
4149 .task_ctx()
4150 .runtime_env()
4151 .object_store(url)
4152 .expect("the verify context must resolve s3 buckets like the executor runtime");
4153 }
4154
4155 /// Logical and physical optimizer rule names installed on a session.
4156 fn optimizer_rule_names(ctx: &SessionContext) -> (Vec<String>, Vec<String>) {
4157 let state = ctx.state();
4158 (
4159 state
4160 .optimizers()
4161 .iter()
4162 .map(|r| r.name().to_owned())
4163 .collect(),
4164 state
4165 .physical_optimizers()
4166 .iter()
4167 .map(|r| r.name().to_owned())
4168 .collect(),
4169 )
4170 }
4171
4172 /// E4 (review 2026-07-27): the staged planner must carry the same optimizer
4173 /// rules as the engine, or every rule the engine installs is dead on the
4174 /// distributed path.
4175 ///
4176 /// This is the whole of finding A6 in one assertion, and it currently
4177 /// FAILS: `planning_session_context` is a bare `SessionContext`, so it
4178 /// carries none of `CooperativeAmplifiers` (distributed cancel cannot
4179 /// preempt an amplifying operator without it), `SpillableJoinSelection`
4180 /// (q18's shipped fix), `SemiJoinReductionThroughAggregate` or
4181 /// `SemiJoinPushdownThroughInnerJoin` (q17's shipped fix — 88 % of a 252 s
4182 /// query). Two shipped performance fixes do not apply to the path being
4183 /// benchmarked, and nothing said so.
4184 ///
4185 /// Left executable-but-ignored deliberately: the fix is A6 (Batch 3, plan
4186 /// the staged query on `SqlEngine`'s own `SessionStateBuilder`), and this
4187 /// documents the gap in a form that turns green the moment it lands rather
4188 /// than in prose that can rot.
4189 #[test]
4190 fn the_staging_context_carries_the_engines_optimizer_rules() {
4191 let engine = crate::SqlEngine::new_with_engine_memory(crate::EngineMemory::Unbounded);
4192 let (engine_logical, engine_physical) = optimizer_rule_names(engine.session_context());
4193 let staging = planning_session_context(engine.target_parallelism().get());
4194 let (staging_logical, staging_physical) = optimizer_rule_names(&staging);
4195
4196 assert_eq!(
4197 engine_logical, staging_logical,
4198 "the staged planner must run the engine's logical optimizer rules; \
4199 missing here means SemiJoinReductionThroughAggregate / \
4200 SemiJoinPushdownThroughInnerJoin never fire distributed (D4)"
4201 );
4202 assert_eq!(
4203 engine_physical, staging_physical,
4204 "the staged planner must run the engine's physical optimizer rules; \
4205 missing here means SpillableJoinSelection (D3) and \
4206 CooperativeAmplifiers (distributed cancel) never fire"
4207 );
4208
4209 // The config half of A6: the four runtime-filter switches and the
4210 // lambda-capable dialect are what make `KRISHIV_RUNTIME_FILTERS` mean
4211 // anything distributed, and what let a Phase-60 lambda query stage at
4212 // all instead of silently degrading to one task.
4213 let engine_opts = engine.session_context().copied_config();
4214 let staging_opts = staging.copied_config();
4215 for option in [
4216 "datafusion.optimizer.enable_dynamic_filter_pushdown",
4217 "datafusion.optimizer.enable_join_dynamic_filter_pushdown",
4218 "datafusion.optimizer.enable_topk_dynamic_filter_pushdown",
4219 "datafusion.optimizer.enable_aggregate_dynamic_filter_pushdown",
4220 ] {
4221 assert_eq!(
4222 engine_opts
4223 .options()
4224 .entries()
4225 .iter()
4226 .find(|e| e.key == option)
4227 .map(|e| e.value.clone()),
4228 staging_opts
4229 .options()
4230 .entries()
4231 .iter()
4232 .find(|e| e.key == option)
4233 .map(|e| e.value.clone()),
4234 "{option} must match the engine's setting on the staged planner"
4235 );
4236 }
4237 assert_eq!(
4238 engine_opts.options().sql_parser.dialect,
4239 staging_opts.options().sql_parser.dialect,
4240 "the staged planner must parse in the engine's dialect"
4241 );
4242 assert_eq!(
4243 engine_opts.options().execution.batch_size,
4244 staging_opts.options().execution.batch_size,
4245 "the staged planner must use the engine's batch size"
4246 );
4247 }
4248
4249 /// A5: the guard rehearses the decode against the wrong session.
4250 ///
4251 /// The executor decodes a fragment on `task_sql_engine`, a real
4252 /// `SqlEngine` carrying Krishiv's registered UDFs. The guard decoded on
4253 /// `planning_session_context`, a bare `SessionContext` that carries none
4254 /// of them — so a fragment referencing `get_json_object` (a Phase-60 front
4255 /// door function, always available on the engine) fails the guard, the
4256 /// caller reads that as "decline to stage", and the query silently runs as
4257 /// a single task on one executor.
4258 ///
4259 /// The plan is built on the engine and decoded through the guard, which is
4260 /// exactly the asymmetry: encode-side capability the verify side lacks.
4261 #[tokio::test]
4262 async fn the_roundtrip_guard_accepts_a_fragment_using_an_engine_udf() {
4263 let engine = crate::SqlEngine::new_with_engine_memory(crate::EngineMemory::Unbounded);
4264 let ctx = engine.session_context();
4265 ctx.sql("CREATE TABLE docs AS VALUES ('{\"a\":1}'), ('{\"a\":2}')")
4266 .await
4267 .unwrap()
4268 .collect()
4269 .await
4270 .unwrap();
4271 // The argument must be a column: a literal one is const-folded away and
4272 // the encoded plan then carries no UDF reference at all.
4273 let plan = ctx
4274 .sql("SELECT get_json_object(column1, '$.a') AS a FROM docs")
4275 .await
4276 .unwrap()
4277 .create_physical_plan()
4278 .await
4279 .unwrap();
4280 let codec = DefaultPhysicalExtensionCodec {};
4281 let bytes = encode_dfplan_bytes(plan, &codec).expect("encode");
4282
4283 // Precondition: the bare planning context genuinely cannot decode it,
4284 // or this test proves nothing about which context the guard uses.
4285 let bare = planning_session_context(1).task_ctx();
4286 assert!(
4287 datafusion_proto::bytes::physical_plan_from_bytes_with_extension_codec(
4288 &bytes, &bare, &codec
4289 )
4290 .is_err(),
4291 "precondition: a bare planning context must NOT resolve engine UDFs"
4292 );
4293
4294 verify_dfplan_roundtrip(
4295 &bytes,
4296 &codec,
4297 &fragment_decode_session_context().task_ctx(),
4298 None,
4299 )
4300 .expect(
4301 "the guard must decode on the engine the executor uses; failing here \
4302 silently degrades the query to a single task",
4303 );
4304 }
4305
4306 /// And it must not reject ordinary plans, or every query silently loses
4307 /// distribution — the worse failure of the two.
4308 #[tokio::test]
4309 async fn the_roundtrip_guard_accepts_an_ordinary_plan() {
4310 let ctx = SessionContext::new();
4311 ctx.sql("CREATE TABLE t AS VALUES (1, 'a'), (2, 'b')")
4312 .await
4313 .unwrap()
4314 .collect()
4315 .await
4316 .unwrap();
4317 let plan = ctx
4318 .sql("SELECT column1 FROM t WHERE column1 > 1")
4319 .await
4320 .unwrap()
4321 .create_physical_plan()
4322 .await
4323 .unwrap();
4324 let codec = DefaultPhysicalExtensionCodec {};
4325 let bytes = encode_dfplan_bytes(plan, &codec).expect("encode");
4326 verify_dfplan_roundtrip(
4327 &bytes,
4328 &codec,
4329 &fragment_decode_session_context().task_ctx(),
4330 None,
4331 )
4332 .expect("ordinary plans must pass");
4333 }
4334
4335 #[tokio::test]
4336 async fn s3_paths_resolve_on_the_planning_context_and_explicit_registration_wins() {
4337 // No environment setup: `build_s3_object_store` defaults the region and
4338 // constructing a store does not contact the endpoint, so this stays a
4339 // pure unit test rather than one that mutates process-wide env.
4340 use datafusion::execution::object_store::ObjectStoreUrl;
4341 let ctx = planning_session_context(4);
4342 let url = ObjectStoreUrl::parse("s3://tpch-bucket").expect("bucket url");
4343
4344 let lazily_built = ctx
4345 .runtime_env()
4346 .object_store(url.clone())
4347 .expect("the planning context must resolve an s3 bucket on demand");
4348
4349 register_object_store_for_path(&ctx, "s3://tpch-bucket/tpch/sf100/lineitem/")
4350 .expect("registering an s3 path must succeed");
4351
4352 let explicit = ctx
4353 .runtime_env()
4354 .object_store(url)
4355 .expect("after registration the planning context must resolve the bucket");
4356 assert!(
4357 !Arc::ptr_eq(&lazily_built, &explicit),
4358 "explicit registration must replace the lazily-constructed store, \
4359 or configured endpoints and credentials would be ignored"
4360 );
4361 }
4362
4363 /// Local paths must not be routed through the S3 builder — it reads
4364 /// credentials from the environment and would fail on a machine that has
4365 /// none, turning every ordinary filesystem-backed staged job into a
4366 /// single-task job.
4367 #[tokio::test]
4368 async fn local_paths_are_left_alone_by_object_store_registration() {
4369 let ctx = planning_session_context(4);
4370 register_object_store_for_path(&ctx, "/home/krishiv-bench-data/tpch/sf1/lineitem.parquet")
4371 .expect("a local path must be a no-op, not an error");
4372 register_object_store_for_path(&ctx, "relative/dir")
4373 .expect("a relative local path must be a no-op, not an error");
4374 }
4375
4376 /// Write a 4-file parquet dataset (1000 rows total) and return the
4377 /// directory path (registered as a multi-file table so scans genuinely
4378 /// have multiple partitions, like real distributed inputs).
4379 async fn write_test_parquet(dir: &std::path::Path) -> std::path::PathBuf {
4380 use arrow::array::{Int64Array, StringArray};
4381 use arrow::datatypes::{DataType, Field, Schema};
4382
4383 let schema = Arc::new(Schema::new(vec![
4384 Field::new("id", DataType::Int64, false),
4385 Field::new("category", DataType::Utf8, false),
4386 Field::new("amount", DataType::Int64, false),
4387 ]));
4388 let table_dir = dir.join("t");
4389 std::fs::create_dir_all(&table_dir).expect("table dir");
4390 for file_index in 0..4i64 {
4391 let ids: Vec<i64> = (0..250).map(|i| file_index * 250 + i).collect();
4392 let batch = RecordBatch::try_new(
4393 schema.clone(),
4394 vec![
4395 Arc::new(Int64Array::from(ids.clone())),
4396 Arc::new(StringArray::from(
4397 ids.iter()
4398 .map(|i| match i % 3 {
4399 0 => "red",
4400 1 => "green",
4401 _ => "blue",
4402 })
4403 .collect::<Vec<_>>(),
4404 )),
4405 Arc::new(Int64Array::from(
4406 ids.iter().map(|i| i * 3).collect::<Vec<_>>(),
4407 )),
4408 ],
4409 )
4410 .expect("test batch");
4411 let path = table_dir.join(format!("part-{file_index}.parquet"));
4412 let file = std::fs::File::create(&path).expect("create parquet");
4413 let mut writer =
4414 datafusion::parquet::arrow::ArrowWriter::try_new(file, schema.clone(), None)
4415 .expect("writer init");
4416 writer.write(&batch).expect("write batch");
4417 writer.close().expect("close writer");
4418 }
4419 table_dir
4420 }
4421
4422 /// A declared primary key must actually shrink the GROUP BY.
4423 ///
4424 /// This is the whole point of `ParquetTableSpec::with_primary_key`:
4425 /// DataFusion's `optimize_projections` already calls
4426 /// `get_required_group_by_exprs_indices` to reduce a GROUP BY to the
4427 /// minimal functionally-equivalent subset, but it can only do so when the
4428 /// table declares a key. Without the declaration the rule is live and
4429 /// inert.
4430 ///
4431 /// Measured stakes (TPC-H q10, SF100, 2026-07-31): grouping by seven
4432 /// customer columns instead of the one key costs **14.8x** end to end —
4433 /// 1784.6 s versus 120.9 s — because the six determined columns ride
4434 /// through every join and shuffle.
4435 #[tokio::test]
4436 async fn a_declared_primary_key_shrinks_the_group_by() {
4437 let tmp = tempfile::tempdir().expect("tempdir");
4438 let table_dir = write_test_parquet(tmp.path()).await;
4439 let path = table_dir.to_string_lossy().to_string();
4440 // `category` and `amount` are determined by `id`, so grouping by all
4441 // three is equivalent to grouping by `id`.
4442 //
4443 // The two dependent columns are deliberately NOT selected. DataFusion's
4444 // rule keeps `(what the parent requires) ∪ (minimal FD subset)`, so a
4445 // column the output still needs stays in the GROUP BY no matter what
4446 // the key says. Selecting them would test nothing.
4447 let sql = "SELECT id, count(*) AS n FROM t GROUP BY id, category, amount";
4448
4449 let plan_text = |spec: ParquetTableSpec| async move {
4450 let ctx = planning_session_context(4);
4451 register_parquet_table(&ctx, &spec)
4452 .await
4453 .expect("register table");
4454 let df = ctx.sql(sql).await.expect("plan sql");
4455 // The OPTIMIZED plan: `optimize_projections` is where
4456 // `get_required_group_by_exprs_indices` runs, so the unoptimized
4457 // plan always shows the full GROUP BY and proves nothing.
4458 let optimized = df.into_optimized_plan().expect("optimize");
4459 format!("{}", optimized.display_indent())
4460 };
4461
4462 let without = plan_text(ParquetTableSpec::new("t", &path)).await;
4463 let with = plan_text(ParquetTableSpec::new("t", &path).with_primary_key(["id"])).await;
4464
4465 // With the key declared, the aggregate groups by `id` alone.
4466 let group_line = |text: &str| {
4467 text.lines()
4468 .find(|line| line.contains("Aggregate:"))
4469 .unwrap_or("<no Aggregate>")
4470 .to_owned()
4471 };
4472 let (g_without, g_with) = (group_line(&without), group_line(&with));
4473 assert_ne!(
4474 g_without, g_with,
4475 "declaring a primary key changed nothing about the aggregate; \
4476 the constraint is not reaching DataFusion's functional-dependency \
4477 machinery.\n without: {g_without}\n with: {g_with}"
4478 );
4479 assert!(
4480 g_with.len() < g_without.len(),
4481 "the declared key should SHRINK the grouping list, not grow it.\n\
4482 without: {g_without}\n with: {g_with}"
4483 );
4484 }
4485
4486 /// A key naming a column the table does not have is an error, not a
4487 /// silently ignored declaration — a typo would otherwise present as "the
4488 /// optimization mysteriously never applies".
4489 #[tokio::test]
4490 async fn an_unknown_primary_key_column_is_rejected() {
4491 let tmp = tempfile::tempdir().expect("tempdir");
4492 let table_dir = write_test_parquet(tmp.path()).await;
4493 let ctx = planning_session_context(4);
4494 let spec = ParquetTableSpec::new("t", table_dir.to_string_lossy().as_ref())
4495 .with_primary_key(["nonexistent_column"]);
4496 let error = register_parquet_table(&ctx, &spec)
4497 .await
4498 .expect_err("an unknown key column must be rejected");
4499 let message = error.to_string();
4500 assert!(
4501 message.contains("nonexistent_column") && message.contains("not in table"),
4502 "the error must name the offending column and the table: {message}"
4503 );
4504 }
4505
4506 /// ADR-0003 risk gate: a scan→filter→hash-aggregate plan round-trips
4507 /// An ungrouped aggregate must split into stages.
4508 ///
4509 /// `SELECT sum(x) FROM t` plans as Final(gather(Partial(scan))) — there is
4510 /// no hash exchange anywhere, because there are no grouping keys to hash
4511 /// on. A cutter that only recognised `RepartitionExec` therefore declined
4512 /// the entire query class and ran it as one task: TPC-H q6 at SF100 took
4513 /// 518 s on a 3-node cluster with two nodes idle. The work is
4514 /// embarrassingly parallel — partial aggregates per file group, combined
4515 /// once — so declining was a pure loss.
4516 ///
4517 /// Asserting on stage COUNT is what makes this a regression test: a plan
4518 /// that merely round-trips proves nothing about distribution.
4519 #[test]
4520 fn target_partitions_scale_with_the_cluster_not_a_constant() {
4521 // The defect: this was 4 regardless of the cluster, so a large cluster
4522 // sat mostly idle and a small one queued work behind itself.
4523 let two_slots = ClusterCapacity { total_slots: 2 };
4524 let thirty_two = ClusterCapacity { total_slots: 32 };
4525 let small = derive_stage_target_partitions(None, Some(two_slots), 8);
4526 let large = derive_stage_target_partitions(None, Some(thirty_two), 8);
4527 assert!(
4528 large > small,
4529 "a 16x larger cluster planned {large} vs {small} partitions"
4530 );
4531 assert_eq!(large, 32 * TASKS_PER_SLOT);
4532 }
4533
4534 #[test]
4535 fn multiple_waves_per_slot_leave_room_to_absorb_stragglers() {
4536 // One task per slot makes a stage as slow as its slowest task. More
4537 // tasks than slots lets a fast slot take a second while a slow one is
4538 // still on its first.
4539 let cluster = ClusterCapacity { total_slots: 8 };
4540 assert!(
4541 derive_stage_target_partitions(None, Some(cluster), 8) > cluster.total_slots,
4542 "a stage should plan more tasks than slots, not exactly one wave"
4543 );
4544 }
4545
4546 #[test]
4547 fn an_explicit_setting_overrides_the_derivation() {
4548 let cluster = ClusterCapacity { total_slots: 64 };
4549 assert_eq!(derive_stage_target_partitions(Some(6), Some(cluster), 8), 6);
4550 // ...but a value that would defeat stage splitting entirely does not:
4551 // below 2 partitions there is no exchange to cut.
4552 assert!(derive_stage_target_partitions(Some(1), Some(cluster), 8) >= MIN_STAGE_PARTITIONS);
4553 assert!(derive_stage_target_partitions(Some(0), Some(cluster), 8) >= MIN_STAGE_PARTITIONS);
4554 }
4555
4556 #[test]
4557 fn no_cluster_view_falls_back_to_the_local_machine() {
4558 // The embedded runtime and any caller without a coordinator.
4559 assert_eq!(
4560 derive_stage_target_partitions(None, None, 6),
4561 6 * TASKS_PER_SLOT
4562 );
4563 }
4564
4565 #[test]
4566 fn partition_counts_stay_inside_the_shuffle_fragment_budget() {
4567 // Shuffle fragments grow as partitions², so an enormous cluster must
4568 // not translate into an unbounded fragment count.
4569 let huge = ClusterCapacity {
4570 total_slots: usize::MAX,
4571 };
4572 assert_eq!(
4573 derive_stage_target_partitions(None, Some(huge), 8),
4574 MAX_STAGE_PARTITIONS
4575 );
4576 // A single-slot cluster still gets a splittable plan.
4577 let one = ClusterCapacity { total_slots: 1 };
4578 assert!(derive_stage_target_partitions(None, Some(one), 1) >= MIN_STAGE_PARTITIONS);
4579 }
4580
4581 #[tokio::test]
4582 async fn ungrouped_aggregate_splits_into_map_and_reduce_stages() {
4583 let tmp = tempfile::tempdir().expect("tempdir");
4584 let path = write_test_parquet(tmp.path()).await;
4585 let tables = vec![(
4586 String::from("t"),
4587 path.to_str().expect("utf8 path").to_owned(),
4588 )];
4589
4590 let staged = build_stages_for_parquet_query(
4591 "SELECT SUM(amount) AS total, COUNT(*) AS n FROM t WHERE id >= 100",
4592 &tables,
4593 Some(ClusterCapacity { total_slots: 4 }),
4594 )
4595 .await
4596 .expect("planning must not error")
4597 .expect("an ungrouped aggregate must be stage-split, not declined");
4598
4599 assert!(
4600 staged.stages.len() >= 2,
4601 "expected a map stage and a reduce stage, got {} stage(s) — \
4602 the gather was not cut, so the whole scan runs in one task",
4603 staged.stages.len()
4604 );
4605
4606 // The map stage gathers to exactly one reduce partition, and carries no
4607 // hash key: every row goes to partition 0, which is what a gather means.
4608 let map = &staged.stages[0];
4609 let shuffle = map
4610 .shuffle
4611 .as_ref()
4612 .expect("the map stage must write a shuffle output");
4613 assert_eq!(
4614 shuffle.num_output_partitions, 1,
4615 "a gather must produce exactly one reduce partition"
4616 );
4617 assert!(
4618 shuffle.key_columns.is_empty(),
4619 "a gather has no partitioning key; got {:?}",
4620 shuffle.key_columns
4621 );
4622 }
4623
4624 /// A grouped aggregate keeps cutting at the hash exchange, with real hash
4625 /// keys — the gather cut must not have swallowed that path.
4626 #[tokio::test]
4627 async fn grouped_aggregate_still_cuts_at_the_hash_exchange() {
4628 let tmp = tempfile::tempdir().expect("tempdir");
4629 let path = write_test_parquet(tmp.path()).await;
4630 let tables = vec![(
4631 String::from("t"),
4632 path.to_str().expect("utf8 path").to_owned(),
4633 )];
4634
4635 let staged = build_stages_for_parquet_query(
4636 "SELECT category, SUM(amount) AS total FROM t GROUP BY category",
4637 &tables,
4638 Some(ClusterCapacity { total_slots: 4 }),
4639 )
4640 .await
4641 .expect("planning must not error")
4642 .expect("a grouped aggregate must be stage-split");
4643
4644 let map = &staged.stages[0];
4645 let shuffle = map.shuffle.as_ref().expect("map stage writes a shuffle");
4646 assert_eq!(
4647 shuffle.key_columns,
4648 vec![String::from("category")],
4649 "a grouped aggregate must shuffle on its grouping key"
4650 );
4651 }
4652
4653 /// through datafusion-proto on the pinned DataFusion and executes
4654 /// identically from a fresh context.
4655 #[tokio::test]
4656 async fn aggregate_plan_round_trips_through_proto() {
4657 let tmp = tempfile::tempdir().expect("tempdir");
4658 let path = write_test_parquet(tmp.path()).await;
4659
4660 let ctx = SessionContext::new();
4661 ctx.register_parquet(
4662 "t",
4663 path.to_str().expect("utf8 path"),
4664 datafusion::prelude::ParquetReadOptions::default(),
4665 )
4666 .await
4667 .expect("register parquet");
4668 let df = ctx
4669 .sql("SELECT category, COUNT(*) AS n, SUM(amount) AS total FROM t WHERE id >= 100 GROUP BY category")
4670 .await
4671 .expect("sql");
4672 let plan = df.create_physical_plan().await.expect("physical plan");
4673 let original_display = displayable(plan.as_ref()).indent(true).to_string();
4674
4675 let codec = DefaultPhysicalExtensionCodec {};
4676 let bytes = encode_dfplan_bytes(Arc::clone(&plan), &codec).expect("encode");
4677 let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
4678 let body = dfplan_task_body(&b64, 0);
4679 assert!(is_dfplan_body(&body));
4680
4681 // Decode on a FRESH context with no tables registered — the executor
4682 // side never re-registers coordinator tables.
4683 let exec_ctx = SessionContext::new();
4684 let (spec, decoded) =
4685 decode_dfplan_task(&body, &exec_ctx.task_ctx(), &codec).expect("decode");
4686 assert_eq!(spec, DfplanTaskSpec::single(0));
4687 assert_eq!(
4688 original_display,
4689 displayable(decoded.as_ref()).indent(true).to_string(),
4690 "decoded plan display must match original"
4691 );
4692
4693 let task_ctx = exec_ctx.task_ctx();
4694 let mut results = Vec::new();
4695 for partition in 0..decoded.output_partitioning().partition_count() {
4696 let stream = decoded
4697 .execute(partition, Arc::clone(&task_ctx))
4698 .expect("execute decoded partition");
4699 let batches: Vec<_> = futures::TryStreamExt::try_collect(stream)
4700 .await
4701 .expect("collect decoded stream");
4702 results.extend(batches);
4703 }
4704 let total_rows: usize = results.iter().map(|b| b.num_rows()).sum();
4705 assert_eq!(total_rows, 3, "three category groups expected");
4706 }
4707
4708 #[test]
4709 fn non_dfplan_body_is_rejected() {
4710 let err = parse_dfplan_body("sql: SELECT 1").unwrap_err();
4711 assert!(err.to_string().contains("not a dfplan:v1: fragment"));
4712 }
4713
4714 /// The shuffle seam names the producer when the two sides disagree.
4715 ///
4716 /// Without this check the mismatch flows on and dies in whichever
4717 /// downstream operator first builds a batch against the plan's schema —
4718 /// q17's bare `expected Decimal128(15, 2) but found Decimal128(30, 15)`,
4719 /// which identifies no stage, no partition and no map task.
4720 #[test]
4721 fn a_shuffle_batch_that_contradicts_the_declared_schema_is_named_not_passed_on() {
4722 use arrow::array::{Int64Array, StringViewArray};
4723 use arrow::datatypes::{DataType, Field, Schema};
4724
4725 // q19's exact disagreement: the plan declares the revenue decimal, the
4726 // batch carries a `Utf8View` string column (Parquet reads produce view
4727 // types by default in DataFusion 54).
4728 let declared: SchemaRef = Arc::new(Schema::new(vec![Field::new(
4729 "revenue",
4730 DataType::Decimal128(15, 2),
4731 false,
4732 )]));
4733 let batch = RecordBatch::try_new(
4734 Arc::new(Schema::new(vec![Field::new(
4735 "p_brand",
4736 DataType::Utf8View,
4737 false,
4738 )])),
4739 vec![Arc::new(StringViewArray::from(vec!["Brand#23"]))],
4740 )
4741 .expect("utf8view batch");
4742
4743 let error = check_shuffle_batch_schema(&declared, batch, 3, 7, 5)
4744 .expect_err("a contradicting batch must not be passed on");
4745 let text = error.to_string();
4746 for expected in ["stage 3", "map 7", "partition 5", "revenue", "p_brand"] {
4747 assert!(
4748 text.contains(expected),
4749 "error must name {expected}, got: {text}"
4750 );
4751 }
4752
4753 // Arity disagreement is reported too, and separately.
4754 let two_col = RecordBatch::try_new(
4755 Arc::new(Schema::new(vec![
4756 Field::new("a", DataType::Int64, false),
4757 Field::new("b", DataType::Int64, false),
4758 ])),
4759 vec![
4760 Arc::new(Int64Array::from(vec![1])),
4761 Arc::new(Int64Array::from(vec![2])),
4762 ],
4763 )
4764 .expect("two column batch");
4765 let error = check_shuffle_batch_schema(&declared, two_col, 0, 0, 0)
4766 .expect_err("column-count disagreement must not be passed on");
4767 assert!(
4768 error
4769 .to_string()
4770 .contains("2 columns but the plan declares 1"),
4771 "got: {error}"
4772 );
4773 }
4774
4775 /// The check must not reject a batch that merely carries different field
4776 /// metadata or nullability — those differ harmlessly across a Parquet read
4777 /// and an IPC round trip, and rejecting them would fail correct queries.
4778 #[test]
4779 fn matching_column_types_pass_even_when_metadata_and_nullability_differ() {
4780 use arrow::array::Int64Array;
4781 use arrow::datatypes::{DataType, Field, Schema};
4782
4783 let declared: SchemaRef = Arc::new(Schema::new(vec![
4784 Field::new("n", DataType::Int64, false).with_metadata(
4785 [(String::from("origin"), String::from("coordinator"))]
4786 .into_iter()
4787 .collect(),
4788 ),
4789 ]));
4790 let batch = RecordBatch::try_new(
4791 Arc::new(Schema::new(vec![Field::new("n", DataType::Int64, true)])),
4792 vec![Arc::new(Int64Array::from(vec![1, 2, 3]))],
4793 )
4794 .expect("batch");
4795
4796 check_shuffle_batch_schema(&declared, batch, 0, 0, 0)
4797 .expect("metadata and nullability differences must not fail the query");
4798 }
4799
4800 /// In-memory [`ShufflePartitionReader`] + writer used to execute a
4801 /// stage plan end-to-end in tests (the executor's store stands in).
4802 #[derive(Debug, Default)]
4803 struct TestShuffleStore {
4804 partitions: Mutex<HashMap<(usize, usize, usize), Vec<RecordBatch>>>,
4805 }
4806
4807 impl TestShuffleStore {
4808 fn write(&self, stage: usize, map_task: usize, partition: usize, batch: RecordBatch) {
4809 self.partitions
4810 .lock()
4811 .expect("store lock")
4812 .entry((stage, map_task, partition))
4813 .or_default()
4814 .push(batch);
4815 }
4816 }
4817
4818 impl ShufflePartitionReader for Arc<TestShuffleStore> {
4819 fn open_partition(
4820 &self,
4821 upstream_stage_index: usize,
4822 map_task_index: usize,
4823 partition: usize,
4824 ) -> futures::future::BoxFuture<'static, Result<ShuffleFragmentStream, String>> {
4825 let batches = self
4826 .partitions
4827 .lock()
4828 .expect("store lock")
4829 .get(&(upstream_stage_index, map_task_index, partition))
4830 .cloned()
4831 .unwrap_or_default();
4832 Box::pin(async move {
4833 Ok(Box::pin(futures::stream::iter(batches.into_iter().map(Ok)))
4834 as ShuffleFragmentStream)
4835 })
4836 }
4837 }
4838
4839 /// A reader that models the shuffle server's `serve_permits`: a permit is
4840 /// taken before the fragment is served and released only when the response
4841 /// stream is fully consumed.
4842 ///
4843 /// `reverse_open_order` makes the *last* map task acquire first and the
4844 /// first acquire last. Without it the deadlock is not reproducible, because
4845 /// `buffered` polls the futures in order, so map task 0 wins the permit race
4846 /// by accident and the whole read drains sequentially. The production race is
4847 /// decided by network timing, not poll order, so the ordering must be forced
4848 /// to test the invariant rather than the scheduler's luck.
4849 #[derive(Debug)]
4850 struct ServeLimitedReader {
4851 inner: Arc<TestShuffleStore>,
4852 permits: Arc<tokio::sync::Semaphore>,
4853 reverse_open_order: bool,
4854 map_tasks: usize,
4855 }
4856
4857 impl ShufflePartitionReader for ServeLimitedReader {
4858 fn open_partition(
4859 &self,
4860 stage: usize,
4861 map_task: usize,
4862 partition: usize,
4863 ) -> futures::future::BoxFuture<'static, Result<ShuffleFragmentStream, String>> {
4864 let batches = self
4865 .inner
4866 .partitions
4867 .lock()
4868 .expect("store lock")
4869 .get(&(stage, map_task, partition))
4870 .cloned()
4871 .unwrap_or_default();
4872 let permits = Arc::clone(&self.permits);
4873 let delay = if self.reverse_open_order {
4874 // Later map tasks reach the semaphore first.
4875 20 * (self.map_tasks.saturating_sub(map_task)) as u64
4876 } else {
4877 0
4878 };
4879 Box::pin(async move {
4880 if delay > 0 {
4881 tokio::time::sleep(std::time::Duration::from_millis(delay)).await;
4882 }
4883 let permit = permits
4884 .acquire_owned()
4885 .await
4886 .map_err(|_| String::from("serve semaphore closed"))?;
4887 // The permit rides along with the stream, exactly as
4888 // `PermitHoldingStream` does on the server.
4889 let held = futures::stream::iter(batches.into_iter().map(Ok)).chain(
4890 futures::stream::unfold(Some(permit), |permit| async move {
4891 // Releasing the permit only when the stream is fully
4892 // consumed is the whole point: that is what the server's
4893 // `PermitHoldingStream` does.
4894 drop(permit?);
4895 None
4896 }),
4897 );
4898 Ok(Box::pin(held) as ShuffleFragmentStream)
4899 })
4900 }
4901 }
4902
4903 /// A reduce read must complete when the producer serves fewer concurrent
4904 /// responses than the reduce side has fragments to read.
4905 ///
4906 /// This pins the second deadlock found on 2026-07-30, and it is a *cluster*
4907 /// hang rather than a slow query: `ShuffleFlightService::do_get` holds a
4908 /// `serve_permits` permit for its response stream's lifetime, so a client
4909 /// that opens `n` fragments ahead and drains them in order holds `n` server
4910 /// permits while consuming one. With every executor acting as both client and
4911 /// server the waits form cycles across nodes, nothing times out, and the job
4912 /// sits at 0% CPU forever. Measured live: TPC-H q2 wedged at 132/181 tasks
4913 /// with a prefetch of 8, and ran in 104.3 s on the identical image with a
4914 /// prefetch of 1.
4915 ///
4916 /// Four fragments against one serve permit is the smallest case that
4917 /// reproduces it. If `DEFAULT_SHUFFLE_FETCH_BUFFER` is ever raised without
4918 /// first changing `do_get` to bound resident bytes instead of open
4919 /// responses, this test hangs and the timeout fails it.
4920 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
4921 async fn a_reduce_read_completes_even_when_the_producer_serves_one_at_a_time() {
4922 use arrow::array::Int64Array;
4923 use arrow::datatypes::{DataType, Field, Schema};
4924 let store = Arc::new(TestShuffleStore::default());
4925 let schema = Arc::new(Schema::new(vec![Field::new("v", DataType::Int64, false)]));
4926 let map_tasks = 4usize;
4927 for map_task in 0..map_tasks {
4928 let batch = RecordBatch::try_new(
4929 Arc::clone(&schema),
4930 vec![Arc::new(Int64Array::from(vec![map_task as i64; 3]))],
4931 )
4932 .expect("batch");
4933 store.write(0, map_task, 0, batch);
4934 }
4935
4936 let reader: Arc<dyn ShufflePartitionReader> = Arc::new(ServeLimitedReader {
4937 inner: Arc::clone(&store),
4938 // One fewer permit than there are fragments, and the LAST map task
4939 // reaches the semaphore first. With a prefetch of `map_tasks` the
4940 // later fragments take every permit, map task 0 waits for one, and
4941 // nothing releases because `buffered` cannot yield fragment 1 before
4942 // fragment 0 has opened.
4943 permits: Arc::new(tokio::sync::Semaphore::new(map_tasks - 1)),
4944 reverse_open_order: true,
4945 map_tasks,
4946 });
4947 let read = ShuffleReadExec::new(0, map_tasks, 1, Arc::clone(&schema), Some(reader));
4948 let ctx = SessionContext::new();
4949 let stream = read.execute(0, ctx.task_ctx()).expect("execute");
4950
4951 let batches = tokio::time::timeout(
4952 std::time::Duration::from_secs(20),
4953 futures::TryStreamExt::try_collect::<Vec<_>>(stream),
4954 )
4955 .await
4956 .expect(
4957 "the reduce read deadlocked: it is holding more producer response \
4958 streams open than the producer will serve, and only downstream \
4959 consumption releases them",
4960 )
4961 .expect("read");
4962 let rows: usize = batches.iter().map(|b| b.num_rows()).sum();
4963 assert_eq!(rows, map_tasks * 3, "every fragment's rows must arrive");
4964 }
4965
4966 /// Consistent test-side hash partitioner (any consistent hash is
4967 /// correct; the executor uses krishiv-shuffle's seeded partitioner).
4968 fn partition_batch_by_key(
4969 batch: &RecordBatch,
4970 key_column: &str,
4971 num_partitions: usize,
4972 ) -> Vec<RecordBatch> {
4973 use std::hash::{Hash as _, Hasher as _};
4974 let key_idx = batch.schema().index_of(key_column).expect("key column");
4975 let column = batch.column(key_idx);
4976 let mut selections: Vec<Vec<u32>> = vec![Vec::new(); num_partitions];
4977 for row in 0..batch.num_rows() {
4978 let value = arrow::util::display::array_value_to_string(column, row).expect("value");
4979 let mut hasher = std::collections::hash_map::DefaultHasher::new();
4980 value.hash(&mut hasher);
4981 let bucket = (hasher.finish() as usize) % num_partitions;
4982 selections[bucket].push(row as u32);
4983 }
4984 selections
4985 .into_iter()
4986 .map(|rows| {
4987 let indices = arrow::array::UInt32Array::from(rows);
4988 arrow::compute::take_record_batch(batch, &indices).expect("take")
4989 })
4990 .collect()
4991 }
4992
4993 /// End-to-end stage execution: build stages for a GROUP BY, execute the
4994 /// map tasks (hash-partition into the test store), execute the result
4995 /// stage through ShuffleReadExec, and compare with direct execution.
4996 #[tokio::test]
4997 async fn staged_group_by_matches_direct_execution() {
4998 let tmp = tempfile::tempdir().expect("tempdir");
4999 let path = write_test_parquet(tmp.path()).await;
5000
5001 let plan_ctx = planning_session_context(4);
5002 plan_ctx
5003 .register_parquet(
5004 "t",
5005 path.to_str().expect("utf8 path"),
5006 datafusion::prelude::ParquetReadOptions::default(),
5007 )
5008 .await
5009 .expect("register parquet");
5010 let query = "SELECT category, COUNT(*) AS n, SUM(amount) AS total FROM t GROUP BY category ORDER BY category";
5011 let df = plan_ctx.sql(query).await.expect("sql");
5012 let plan = df.create_physical_plan().await.expect("physical plan");
5013
5014 let staged = build_distributed_stages(plan)
5015 .expect("build stages")
5016 .expect("plan must be splittable");
5017 assert_eq!(staged.stages.len(), 2, "one map stage + one result stage");
5018 let map_stage = &staged.stages[0];
5019 let result_stage = &staged.stages[1];
5020 let shuffle = map_stage.shuffle.as_ref().expect("map stage shuffles");
5021 assert_eq!(shuffle.key_columns, vec!["category".to_owned()]);
5022 assert!(
5023 map_stage.task_count() > 1,
5024 "multi-file scan must yield a multi-task map stage, got {}",
5025 map_stage.task_count()
5026 );
5027 assert!(result_stage.shuffle.is_none());
5028 assert_eq!(result_stage.upstream_stage_indexes, vec![0]);
5029
5030 // Execute map tasks: each runs its partition of the decoded subtree
5031 // and hash-partitions the output into the test store.
5032 let store = Arc::new(TestShuffleStore::default());
5033 let exec_ctx = SessionContext::new();
5034 let exec_codec = KrishivPhysicalCodec::executor(Arc::new(Arc::clone(&store)));
5035 for (task_index, body) in map_stage.task_bodies.iter().enumerate() {
5036 let (spec, plan) =
5037 decode_dfplan_task(body, &exec_ctx.task_ctx(), &exec_codec).expect("decode map");
5038 assert_eq!(spec, DfplanTaskSpec::single(task_index));
5039 let stream = plan
5040 .execute(task_index, exec_ctx.task_ctx())
5041 .expect("execute map partition");
5042 let batches: Vec<_> = futures::TryStreamExt::try_collect(stream)
5043 .await
5044 .expect("collect map output");
5045 for batch in batches {
5046 if batch.num_rows() == 0 {
5047 continue;
5048 }
5049 for (bucket, part) in partition_batch_by_key(
5050 &batch,
5051 &shuffle.key_columns[0],
5052 shuffle.num_output_partitions,
5053 )
5054 .into_iter()
5055 .enumerate()
5056 {
5057 if part.num_rows() > 0 {
5058 store.write(0, task_index, bucket, part);
5059 }
5060 }
5061 }
5062 }
5063
5064 // Execute the result stage through ShuffleReadExec.
5065 let mut staged_results = Vec::new();
5066 for (task_index, body) in result_stage.task_bodies.iter().enumerate() {
5067 let (spec, plan) =
5068 decode_dfplan_task(body, &exec_ctx.task_ctx(), &exec_codec).expect("decode result");
5069 assert_eq!(spec, DfplanTaskSpec::single(task_index));
5070 let stream = plan
5071 .execute(task_index, exec_ctx.task_ctx())
5072 .expect("execute result partition");
5073 let batches: Vec<_> = futures::TryStreamExt::try_collect(stream)
5074 .await
5075 .expect("collect result output");
5076 staged_results.extend(batches);
5077 }
5078
5079 let direct = plan_ctx
5080 .sql(query)
5081 .await
5082 .expect("direct sql")
5083 .collect()
5084 .await
5085 .expect("direct collect");
5086
5087 let render = |batches: &[RecordBatch]| {
5088 let mut rows: Vec<String> = batches
5089 .iter()
5090 .flat_map(|b| {
5091 (0..b.num_rows()).map(move |r| {
5092 (0..b.num_columns())
5093 .map(|c| {
5094 arrow::util::display::array_value_to_string(b.column(c), r)
5095 .expect("cell")
5096 })
5097 .collect::<Vec<_>>()
5098 .join("|")
5099 })
5100 })
5101 .collect();
5102 rows.sort();
5103 rows
5104 };
5105 assert_eq!(
5106 render(&staged_results),
5107 render(&direct),
5108 "staged execution must match direct execution"
5109 );
5110 }
5111
5112 /// A plain scan (no exchange) is not worth splitting: builder says None.
5113 #[tokio::test]
5114 async fn scan_only_plan_declines_with_a_stated_reason() {
5115 // A projection-and-filter plan has no exchange, so there is nothing to
5116 // cut and it correctly runs as one task. What changed is how that is
5117 // reported: declining used to be a bare `Ok(None)`, indistinguishable
5118 // at the call site from every other reason to fall back, which is how
5119 // a genuine planning bug hid behind "the planner declined" for a whole
5120 // benchmarking session. The reason is now a value, and this asserts it
5121 // says which plan property was missing.
5122 let tmp = tempfile::tempdir().expect("tempdir");
5123 let path = write_test_parquet(tmp.path()).await;
5124 let plan_ctx = planning_session_context(4);
5125 plan_ctx
5126 .register_parquet(
5127 "t",
5128 path.to_str().expect("utf8 path"),
5129 datafusion::prelude::ParquetReadOptions::default(),
5130 )
5131 .await
5132 .expect("register parquet");
5133 let df = plan_ctx
5134 .sql("SELECT id, amount FROM t WHERE id < 10")
5135 .await
5136 .expect("sql");
5137 let plan = df.create_physical_plan().await.expect("physical plan");
5138 let reason = build_distributed_stages(plan)
5139 .expect_err("a scan-only plan has no exchange and must decline")
5140 .to_string();
5141 assert!(
5142 reason.contains("no exchange"),
5143 "the decline must name the missing plan property, got: {reason}"
5144 );
5145 }
5146
5147 /// Hash-join splits into two map stages + a result stage, and staged
5148 /// execution matches direct execution.
5149 #[tokio::test]
5150 async fn staged_join_matches_direct_execution() {
5151 let tmp = tempfile::tempdir().expect("tempdir");
5152 let path = write_test_parquet(tmp.path()).await;
5153
5154 // Force a partitioned (repartition-both-sides) hash join: the test
5155 // table is tiny, and DF would otherwise broadcast it below the
5156 // single-partition thresholds — which the builder correctly declines
5157 // to split (`scan_only_plan_is_not_split` covers that shape).
5158 let mut config = SessionConfig::new().with_target_partitions(4);
5159 config
5160 .options_mut()
5161 .optimizer
5162 .enable_round_robin_repartition = false;
5163 config
5164 .options_mut()
5165 .optimizer
5166 .hash_join_single_partition_threshold = 0;
5167 config
5168 .options_mut()
5169 .optimizer
5170 .hash_join_single_partition_threshold_rows = 0;
5171 let plan_ctx = SessionContext::new_with_config(config);
5172 for name in ["a", "b"] {
5173 plan_ctx
5174 .register_parquet(
5175 name,
5176 path.to_str().expect("utf8 path"),
5177 datafusion::prelude::ParquetReadOptions::default(),
5178 )
5179 .await
5180 .expect("register parquet");
5181 }
5182 let query = "SELECT a.category, COUNT(*) AS n, SUM(b.amount) AS total \
5183 FROM a JOIN b ON a.id = b.id GROUP BY a.category";
5184 let df = plan_ctx.sql(query).await.expect("sql");
5185 let plan = df.create_physical_plan().await.expect("physical plan");
5186 let staged = build_distributed_stages(plan)
5187 .expect("build stages")
5188 .expect("partitioned join must split into stages");
5189 assert!(
5190 staged.stages.len() >= 3,
5191 "expected two join-side map stages + result, got {}",
5192 staged.stages.len()
5193 );
5194
5195 let store = Arc::new(TestShuffleStore::default());
5196 let exec_ctx = SessionContext::new();
5197 let exec_codec = KrishivPhysicalCodec::executor(Arc::new(Arc::clone(&store)));
5198
5199 // Execute stages in order (map stages precede the result stage).
5200 let mut staged_results = Vec::new();
5201 for (stage_index, stage) in staged.stages.iter().enumerate() {
5202 for (task_index, body) in stage.task_bodies.iter().enumerate() {
5203 let (spec, plan) = decode_dfplan_task(body, &exec_ctx.task_ctx(), &exec_codec)
5204 .expect("decode stage task");
5205 assert_eq!(spec, DfplanTaskSpec::single(task_index));
5206 let stream = plan
5207 .execute(task_index, exec_ctx.task_ctx())
5208 .expect("execute stage partition");
5209 let batches: Vec<_> = futures::TryStreamExt::try_collect(stream)
5210 .await
5211 .expect("collect stage output");
5212 match &stage.shuffle {
5213 Some(shuffle) => {
5214 for batch in batches {
5215 if batch.num_rows() == 0 {
5216 continue;
5217 }
5218 for (bucket, part) in partition_batch_by_key(
5219 &batch,
5220 &shuffle.key_columns[0],
5221 shuffle.num_output_partitions,
5222 )
5223 .into_iter()
5224 .enumerate()
5225 {
5226 if part.num_rows() > 0 {
5227 store.write(stage_index, task_index, bucket, part);
5228 }
5229 }
5230 }
5231 }
5232 None => staged_results.extend(batches),
5233 }
5234 }
5235 }
5236
5237 let direct = plan_ctx
5238 .sql(query)
5239 .await
5240 .expect("direct sql")
5241 .collect()
5242 .await
5243 .expect("direct collect");
5244
5245 let render = |batches: &[RecordBatch]| {
5246 let mut rows: Vec<String> = batches
5247 .iter()
5248 .flat_map(|b| {
5249 (0..b.num_rows()).map(move |r| {
5250 (0..b.num_columns())
5251 .map(|c| {
5252 arrow::util::display::array_value_to_string(b.column(c), r)
5253 .expect("cell")
5254 })
5255 .collect::<Vec<_>>()
5256 .join("|")
5257 })
5258 })
5259 .collect();
5260 rows.sort();
5261 rows
5262 };
5263 assert_eq!(
5264 render(&staged_results),
5265 render(&direct),
5266 "staged join must match direct execution"
5267 );
5268 }
5269
5270 // ── Phase 54: partition-spec grammar ─────────────────────────────────
5271
5272 #[test]
5273 fn partition_spec_grammar_round_trips() {
5274 let multi = DfplanTaskSpec {
5275 partitions: vec![1, 4, 7],
5276 map_range: None,
5277 };
5278 let body = dfplan_task_body_for_spec("QUJD", &multi);
5279 assert_eq!(body, "dfplan:v1:1,4,7:QUJD");
5280 assert_eq!(dfplan_body_partition_spec(&body).expect("parse"), multi);
5281
5282 let split = DfplanTaskSpec {
5283 partitions: vec![5],
5284 map_range: Some(DfplanMapRange {
5285 upstream_stage_index: 0,
5286 start: 2,
5287 end: 4,
5288 }),
5289 };
5290 let body = dfplan_task_body_for_spec("QUJD", &split);
5291 assert_eq!(body, "dfplan:v1:5/s0m2-4:QUJD");
5292 assert_eq!(dfplan_body_partition_spec(&body).expect("parse"), split);
5293
5294 // Legacy single-partition form parses as a single spec.
5295 assert_eq!(
5296 dfplan_body_partition_spec("dfplan:v1:3:QUJD").expect("parse"),
5297 DfplanTaskSpec::single(3)
5298 );
5299 }
5300
5301 #[test]
5302 fn partition_spec_rewrite_preserves_payload() {
5303 let original = dfplan_task_body("cGF5bG9hZA==", 2);
5304 let rewritten = dfplan_body_with_spec(
5305 &original,
5306 &DfplanTaskSpec {
5307 partitions: vec![0, 2],
5308 map_range: None,
5309 },
5310 )
5311 .expect("rewrite");
5312 assert_eq!(rewritten, "dfplan:v1:0,2:cGF5bG9hZA==");
5313 }
5314
5315 #[test]
5316 fn partition_spec_rejects_malformed_segments() {
5317 assert!(dfplan_body_partition_spec("dfplan:v1::QUJD").is_err());
5318 assert!(dfplan_body_partition_spec("dfplan:v1:x:QUJD").is_err());
5319 assert!(dfplan_body_partition_spec("dfplan:v1:1/s0m4-4:QUJD").is_err());
5320 assert!(dfplan_body_partition_spec("dfplan:v1:1/m0-2:QUJD").is_err());
5321 }
5322
5323 /// Coalescing correctness: a Result-stage task executing SEVERAL root
5324 /// partitions produces exactly the union the one-task-per-partition
5325 /// layout produces (the exit-gate mechanism for AQE coalescing).
5326 #[tokio::test]
5327 async fn coalesced_result_stage_matches_direct_execution() {
5328 let tmp = tempfile::tempdir().expect("tempdir");
5329 let path = write_test_parquet(tmp.path()).await;
5330 let plan_ctx = planning_session_context(4);
5331 plan_ctx
5332 .register_parquet(
5333 "t",
5334 path.to_str().expect("utf8 path"),
5335 datafusion::prelude::ParquetReadOptions::default(),
5336 )
5337 .await
5338 .expect("register parquet");
5339 let query = "SELECT category, COUNT(*) AS n, SUM(amount) AS total FROM t GROUP BY category";
5340 let df = plan_ctx.sql(query).await.expect("sql");
5341 let plan = df.create_physical_plan().await.expect("physical plan");
5342 let staged = build_distributed_stages(plan)
5343 .expect("build stages")
5344 .expect("splittable");
5345 let map_stage = staged.stages.first().expect("map stage");
5346 let result_stage = staged.stages.get(1).expect("result stage");
5347 let shuffle = map_stage.shuffle.as_ref().expect("map shuffles");
5348
5349 // Run the map stage into the test store (as in the staged tests).
5350 let store = Arc::new(TestShuffleStore::default());
5351 let exec_ctx = SessionContext::new();
5352 for (task_index, body) in map_stage.task_bodies.iter().enumerate() {
5353 let reader: Arc<dyn ShufflePartitionReader> = Arc::new(Arc::clone(&store));
5354 let (_, mut stream) =
5355 execute_dfplan_body(body, &exec_ctx, Some(reader)).expect("map exec");
5356 while let Some(batch) = futures::StreamExt::next(&mut stream).await {
5357 let batch = batch.expect("map batch");
5358 if batch.num_rows() == 0 {
5359 continue;
5360 }
5361 for (bucket, part) in partition_batch_by_key(
5362 &batch,
5363 &shuffle.key_columns[0],
5364 shuffle.num_output_partitions,
5365 )
5366 .into_iter()
5367 .enumerate()
5368 {
5369 if part.num_rows() > 0 {
5370 store.write(0, task_index, bucket, part);
5371 }
5372 }
5373 }
5374 }
5375
5376 // ONE coalesced task executing every result partition.
5377 let all_partitions: Vec<usize> = (0..result_stage.task_count()).collect();
5378 let coalesced_body = dfplan_body_with_spec(
5379 result_stage.task_bodies.first().expect("result body"),
5380 &DfplanTaskSpec {
5381 partitions: all_partitions,
5382 map_range: None,
5383 },
5384 )
5385 .expect("coalesce rewrite");
5386 let reader: Arc<dyn ShufflePartitionReader> = Arc::new(Arc::clone(&store));
5387 let (_, stream) =
5388 execute_dfplan_body(&coalesced_body, &exec_ctx, Some(reader)).expect("coalesced exec");
5389 let coalesced: Vec<RecordBatch> = futures::TryStreamExt::try_collect(stream)
5390 .await
5391 .expect("coalesced results");
5392
5393 // Per-partition baseline through the ORIGINAL bodies.
5394 let mut baseline = Vec::new();
5395 for body in &result_stage.task_bodies {
5396 let reader: Arc<dyn ShufflePartitionReader> = Arc::new(Arc::clone(&store));
5397 let (_, stream) =
5398 execute_dfplan_body(body, &exec_ctx, Some(reader)).expect("baseline exec");
5399 let batches: Vec<RecordBatch> = futures::TryStreamExt::try_collect(stream)
5400 .await
5401 .expect("baseline results");
5402 baseline.extend(batches);
5403 }
5404
5405 let render = |batches: &[RecordBatch]| {
5406 let mut rows: Vec<String> = batches
5407 .iter()
5408 .flat_map(|b| {
5409 (0..b.num_rows()).map(move |r| {
5410 (0..b.num_columns())
5411 .map(|c| {
5412 arrow::util::display::array_value_to_string(b.column(c), r)
5413 .expect("cell")
5414 })
5415 .collect::<Vec<_>>()
5416 .join("|")
5417 })
5418 })
5419 .collect();
5420 rows.sort();
5421 rows
5422 };
5423 assert_eq!(
5424 render(&coalesced),
5425 render(&baseline),
5426 "coalesced task must produce the same union as per-partition tasks"
5427 );
5428 assert!(!coalesced.is_empty(), "group-by must produce rows");
5429 }
5430
5431 /// Skew-split correctness: splitting a Result-stage partition of a pure
5432 /// inner join into map-task ranges yields the same union as the unsplit
5433 /// task (the exit-gate mechanism for AQE skew handling), and the
5434 /// split-safety gate admits the join while rejecting an aggregation.
5435 #[tokio::test]
5436 async fn skew_split_result_tasks_match_unsplit_execution() {
5437 let tmp = tempfile::tempdir().expect("tempdir");
5438 let path = write_test_parquet(tmp.path()).await;
5439
5440 let mut config = SessionConfig::new().with_target_partitions(4);
5441 config
5442 .options_mut()
5443 .optimizer
5444 .enable_round_robin_repartition = false;
5445 config
5446 .options_mut()
5447 .optimizer
5448 .hash_join_single_partition_threshold = 0;
5449 config
5450 .options_mut()
5451 .optimizer
5452 .hash_join_single_partition_threshold_rows = 0;
5453 let plan_ctx = SessionContext::new_with_config(config);
5454 for name in ["a", "b"] {
5455 plan_ctx
5456 .register_parquet(
5457 name,
5458 path.to_str().expect("utf8 path"),
5459 datafusion::prelude::ParquetReadOptions::default(),
5460 )
5461 .await
5462 .expect("register parquet");
5463 }
5464 // Pure inner join — no blocking operator above the shuffle reads.
5465 let query = "SELECT a.id, a.category, b.amount FROM a JOIN b ON a.id = b.id";
5466 let df = plan_ctx.sql(query).await.expect("sql");
5467 let plan = df.create_physical_plan().await.expect("physical plan");
5468 let staged = build_distributed_stages(plan)
5469 .expect("build stages")
5470 .expect("partitioned join must split");
5471 let result_stage = staged.stages.last().expect("result stage");
5472 assert!(result_stage.shuffle.is_none());
5473 let result_body = result_stage.task_bodies.first().expect("result body");
5474 assert!(
5475 dfplan_body_is_split_safe(result_body),
5476 "pure inner join result stage must be split-safe"
5477 );
5478
5479 // Execute all map stages into the store.
5480 let store = Arc::new(TestShuffleStore::default());
5481 let exec_ctx = SessionContext::new();
5482 let mut probe_map_tasks = 0usize;
5483 for (stage_index, stage) in staged.stages.iter().enumerate() {
5484 let Some(shuffle) = &stage.shuffle else {
5485 continue;
5486 };
5487 if stage_index == 0 {
5488 probe_map_tasks = stage.task_count();
5489 }
5490 for (task_index, body) in stage.task_bodies.iter().enumerate() {
5491 let reader: Arc<dyn ShufflePartitionReader> = Arc::new(Arc::clone(&store));
5492 let (_, stream) =
5493 execute_dfplan_body(body, &exec_ctx, Some(reader)).expect("map exec");
5494 let batches: Vec<RecordBatch> = futures::TryStreamExt::try_collect(stream)
5495 .await
5496 .expect("map results");
5497 for batch in batches {
5498 if batch.num_rows() == 0 {
5499 continue;
5500 }
5501 for (bucket, part) in partition_batch_by_key(
5502 &batch,
5503 &shuffle.key_columns[0],
5504 shuffle.num_output_partitions,
5505 )
5506 .into_iter()
5507 .enumerate()
5508 {
5509 if part.num_rows() > 0 {
5510 store.write(stage_index, task_index, bucket, part);
5511 }
5512 }
5513 }
5514 }
5515 }
5516 assert!(
5517 probe_map_tasks >= 2,
5518 "need >=2 map tasks to split, got {probe_map_tasks}"
5519 );
5520
5521 let collect_body = |body: String| {
5522 let store = Arc::clone(&store);
5523 let exec_ctx = exec_ctx.clone();
5524 async move {
5525 let reader: Arc<dyn ShufflePartitionReader> = Arc::new(store);
5526 let (_, stream) =
5527 execute_dfplan_body(&body, &exec_ctx, Some(reader)).expect("exec");
5528 let batches: Vec<RecordBatch> = futures::TryStreamExt::try_collect(stream)
5529 .await
5530 .expect("results");
5531 batches
5532 }
5533 };
5534
5535 let render = |batches: &[RecordBatch]| {
5536 let mut rows: Vec<String> = batches
5537 .iter()
5538 .flat_map(|b| {
5539 (0..b.num_rows()).map(move |r| {
5540 (0..b.num_columns())
5541 .map(|c| {
5542 arrow::util::display::array_value_to_string(b.column(c), r)
5543 .expect("cell")
5544 })
5545 .collect::<Vec<_>>()
5546 .join("|")
5547 })
5548 })
5549 .collect();
5550 rows.sort();
5551 rows
5552 };
5553
5554 // Every result partition: unsplit baseline vs two map-range splits
5555 // of upstream stage 0 (the probe side in builder order).
5556 for (partition, body) in result_stage.task_bodies.iter().enumerate() {
5557 let baseline = collect_body(body.clone()).await;
5558 let mid = probe_map_tasks / 2;
5559 let mut split_union = Vec::new();
5560 for (start, end) in [(0, mid), (mid, probe_map_tasks)] {
5561 let split_body = dfplan_body_with_spec(
5562 body,
5563 &DfplanTaskSpec {
5564 partitions: vec![partition],
5565 map_range: Some(DfplanMapRange {
5566 upstream_stage_index: 0,
5567 start,
5568 end,
5569 }),
5570 },
5571 )
5572 .expect("split rewrite");
5573 split_union.extend(collect_body(split_body).await);
5574 }
5575 assert_eq!(
5576 render(&split_union),
5577 render(&baseline),
5578 "partition {partition}: split union must equal unsplit output"
5579 );
5580 }
5581
5582 // The safety gate must reject a plan with a blocking aggregation.
5583 let agg_ctx = planning_session_context(4);
5584 agg_ctx
5585 .register_parquet(
5586 "t",
5587 path.to_str().expect("utf8 path"),
5588 datafusion::prelude::ParquetReadOptions::default(),
5589 )
5590 .await
5591 .expect("register parquet");
5592 let agg_plan = agg_ctx
5593 .sql("SELECT category, COUNT(*) FROM t GROUP BY category")
5594 .await
5595 .expect("sql")
5596 .create_physical_plan()
5597 .await
5598 .expect("plan");
5599 let agg_staged = build_distributed_stages(agg_plan)
5600 .expect("build stages")
5601 .expect("splittable");
5602 let agg_body = agg_staged
5603 .stages
5604 .last()
5605 .expect("result stage")
5606 .task_bodies
5607 .first()
5608 .expect("body");
5609 assert!(
5610 !dfplan_body_is_split_safe(agg_body),
5611 "final aggregation must NOT be split-safe"
5612 );
5613 }
5614}
5615
5616#[cfg(test)]
5617#[allow(clippy::unwrap_used, clippy::expect_used)]
5618mod roundtrip_schema_guard_tests {
5619 use super::*;
5620 use arrow::datatypes::{DataType, Field, Schema};
5621
5622 /// A schema deliberately unlike anything the encoded plan produces.
5623 fn alien_schema() -> Schema {
5624 Schema::new(vec![Field::new(
5625 "not_a_real_column",
5626 DataType::Boolean,
5627 true,
5628 )])
5629 }
5630
5631 #[tokio::test]
5632 async fn the_guard_rejects_a_decode_whose_schema_differs() {
5633 // The property the guard was missing. It only ever checked that decode
5634 // *succeeded*, so a fragment could decode into a plan producing
5635 // different column types and ship anyway — `ShuffleReadExec` labels its
5636 // stream with the coordinator's schema, `RecordBatchStreamAdapter` does
5637 // not validate, and the disagreement surfaced much later inside an
5638 // executor as a bare Arrow error (q17: Decimal128(15,2) declared,
5639 // Decimal128(30,15) produced).
5640 let ctx = fragment_decode_session_context();
5641 ctx.sql("CREATE TABLE t(a INT) AS VALUES (1), (2)")
5642 .await
5643 .unwrap()
5644 .collect()
5645 .await
5646 .unwrap();
5647 let plan = ctx
5648 .sql("SELECT a FROM t")
5649 .await
5650 .unwrap()
5651 .create_physical_plan()
5652 .await
5653 .unwrap();
5654 let codec = KrishivPhysicalCodec::coordinator();
5655 let bytes = encode_dfplan_bytes(Arc::clone(&plan), &codec).unwrap();
5656 let task_ctx = ctx.task_ctx();
5657
5658 // Its own plan passes.
5659 verify_dfplan_roundtrip(&bytes, &codec, &task_ctx, Some(&plan))
5660 .expect("a plan must round-trip against itself");
5661
5662 // A different plan is refused, and the message names the mismatch so
5663 // the fallback is explainable rather than mysterious.
5664 let alien: Arc<dyn ExecutionPlan> = Arc::new(
5665 datafusion::physical_plan::empty::EmptyExec::new(Arc::new(alien_schema())),
5666 );
5667 let err = verify_dfplan_roundtrip(&bytes, &codec, &task_ctx, Some(&alien))
5668 .expect_err("a schema disagreement must be refused");
5669 let msg = err.to_string();
5670 assert!(
5671 msg.contains("decoded plan differs"),
5672 "unexpected message: {msg}"
5673 );
5674 }
5675
5676 /// The root-only check was not enough, and q17 is the proof.
5677 ///
5678 /// A decode can re-resolve an interior aggregate to a different type while
5679 /// a projection above it casts back, so the *root* schemas agree and the
5680 /// guard passes a fragment whose interior will not run. Comparing the tree
5681 /// is what makes the guard mean "the executor can rebuild this plan"
5682 /// rather than "the executor can rebuild this plan's last node".
5683 #[tokio::test]
5684 async fn the_guard_compares_the_whole_tree_not_just_the_root() {
5685 use arrow::datatypes::{DataType, Field, Schema};
5686 use datafusion::physical_plan::empty::EmptyExec;
5687
5688 let same_root = Arc::new(Schema::new(vec![Field::new("a", DataType::Int32, true)]));
5689 // Two plans with identical root schemas and different interiors.
5690 let original: Arc<dyn ExecutionPlan> =
5691 Arc::new(datafusion::physical_plan::limit::GlobalLimitExec::new(
5692 Arc::new(EmptyExec::new(Arc::clone(&same_root))),
5693 0,
5694 None,
5695 ));
5696 let decoded: Arc<dyn ExecutionPlan> =
5697 Arc::new(datafusion::physical_plan::limit::GlobalLimitExec::new(
5698 Arc::new(EmptyExec::new(Arc::new(Schema::new(vec![Field::new(
5699 "a",
5700 DataType::Int64,
5701 true,
5702 )])))),
5703 0,
5704 None,
5705 ));
5706 // Roots agree only if the interiors do for CoalesceBatchesExec, so
5707 // assert on the child directly: the walk must reach it and name it.
5708 let difference = first_schema_difference(&original, &decoded, "root")
5709 .expect("an interior disagreement must be reported");
5710 assert!(
5711 difference.contains("root"),
5712 "the difference must name where it is: {difference}"
5713 );
5714
5715 // Identical trees agree.
5716 assert!(first_schema_difference(&original, &original, "root").is_none());
5717 }
5718
5719 #[tokio::test]
5720 async fn passing_no_expected_schema_keeps_the_old_decode_only_behaviour() {
5721 // Callers that only care whether the bytes decode (the existing
5722 // regression tests) must keep working unchanged.
5723 let ctx = fragment_decode_session_context();
5724 ctx.sql("CREATE TABLE t2(a INT) AS VALUES (1)")
5725 .await
5726 .unwrap()
5727 .collect()
5728 .await
5729 .unwrap();
5730 let plan = ctx
5731 .sql("SELECT a FROM t2")
5732 .await
5733 .unwrap()
5734 .create_physical_plan()
5735 .await
5736 .unwrap();
5737 let codec = KrishivPhysicalCodec::coordinator();
5738 let bytes = encode_dfplan_bytes(plan, &codec).unwrap();
5739 verify_dfplan_roundtrip(&bytes, &codec, &ctx.task_ctx(), None)
5740 .expect("decode-only checking must still pass");
5741 }
5742}
5743
5744/// Staged TPC-H over a miniature fixture: the whole cut-encode-ship-execute
5745/// path, in process.
5746///
5747/// The SF100 cluster is the only place several of this module's defects have
5748/// ever appeared, and a cluster cycle costs an hour. These tests run the same
5749/// path — the same planner, the same stage cut, the same fragment bodies, the
5750/// same `ShuffleReadExec` — over a few hundred rows, so a schema disagreement
5751/// between what a stage *declares* and what it *produces* fails in seconds on
5752/// a laptop instead of in an overnight sweep.
5753#[cfg(test)]
5754#[allow(clippy::unwrap_used, clippy::expect_used)]
5755mod staged_tpch_tests {
5756 use super::*;
5757 use arrow::record_batch::RecordBatch;
5758 use datafusion::prelude::{ParquetReadOptions, SessionContext};
5759 use std::collections::HashMap;
5760 use std::sync::{Arc, Mutex};
5761
5762 /// q17 and q19 verbatim from the benchmark corpus (`krishiv-bench`), which
5763 /// is the point: a paraphrase would not reproduce the plan shape.
5764 const Q17: &str = "SELECT sum(l_extendedprice) / 7.0 AS avg_yearly FROM lineitem, part \
5765 WHERE p_partkey = l_partkey AND p_brand = 'Brand#23' AND p_container = 'MED BOX' \
5766 AND l_quantity < (SELECT 0.2 * avg(l_quantity) FROM lineitem \
5767 WHERE l_partkey = p_partkey)";
5768
5769 const Q19: &str = "SELECT sum(l_extendedprice * (1 - l_discount)) AS revenue \
5770 FROM lineitem, part \
5771 WHERE (p_partkey = l_partkey AND p_brand = 'Brand#12' \
5772 AND p_container IN ('SM CASE', 'SM BOX', 'SM PACK', 'SM PKG') \
5773 AND l_quantity >= 1 AND l_quantity <= 11 AND p_size BETWEEN 1 AND 5 \
5774 AND l_shipmode IN ('AIR', 'AIR REG') AND l_shipinstruct = 'DELIVER IN PERSON') \
5775 OR (p_partkey = l_partkey AND p_brand = 'Brand#23' \
5776 AND p_container IN ('MED BAG', 'MED BOX', 'MED PKG', 'MED PACK') \
5777 AND l_quantity >= 10 AND l_quantity <= 20 AND p_size BETWEEN 1 AND 10 \
5778 AND l_shipmode IN ('AIR', 'AIR REG') AND l_shipinstruct = 'DELIVER IN PERSON') \
5779 OR (p_partkey = l_partkey AND p_brand = 'Brand#34' \
5780 AND p_container IN ('LG CASE', 'LG BOX', 'LG PACK', 'LG PKG') \
5781 AND l_quantity >= 20 AND l_quantity <= 30 AND p_size BETWEEN 1 AND 15 \
5782 AND l_shipmode IN ('AIR', 'AIR REG') AND l_shipinstruct = 'DELIVER IN PERSON')";
5783
5784 #[derive(Debug, Default)]
5785 struct StageStore {
5786 partitions: Mutex<HashMap<(usize, usize, usize), Vec<RecordBatch>>>,
5787 }
5788
5789 impl ShufflePartitionReader for Arc<StageStore> {
5790 fn open_partition(
5791 &self,
5792 upstream_stage_index: usize,
5793 map_task_index: usize,
5794 partition: usize,
5795 ) -> futures::future::BoxFuture<'static, Result<ShuffleFragmentStream, String>> {
5796 let batches = self
5797 .partitions
5798 .lock()
5799 .expect("store lock")
5800 .get(&(upstream_stage_index, map_task_index, partition))
5801 .cloned()
5802 .unwrap_or_default();
5803 Box::pin(async move {
5804 Ok(Box::pin(futures::stream::iter(batches.into_iter().map(Ok)))
5805 as ShuffleFragmentStream)
5806 })
5807 }
5808 }
5809
5810 fn write_parquet(path: &std::path::Path, batch: &RecordBatch) {
5811 let file = std::fs::File::create(path).expect("create parquet");
5812 let mut writer =
5813 datafusion::parquet::arrow::ArrowWriter::try_new(file, batch.schema(), None)
5814 .expect("writer init");
5815 writer.write(batch).expect("write batch");
5816 writer.close().expect("close writer");
5817 }
5818
5819 /// Miniature `lineitem` and `part`, two files each so map stages get more
5820 /// than one task. Column types match the TPC-H DDL — the `Decimal128(15,2)`
5821 /// money columns especially, since the defect under test is a decimal
5822 /// precision disagreement.
5823 fn write_tpch_fixture(dir: &std::path::Path) -> (std::path::PathBuf, std::path::PathBuf) {
5824 use arrow::array::{Decimal128Array, Int32Array, Int64Array, StringArray};
5825 use arrow::datatypes::{DataType, Field, Schema};
5826
5827 let lineitem_schema = Arc::new(Schema::new(vec![
5828 Field::new("l_partkey", DataType::Int64, false),
5829 Field::new("l_quantity", DataType::Decimal128(15, 2), false),
5830 Field::new("l_extendedprice", DataType::Decimal128(15, 2), false),
5831 Field::new("l_discount", DataType::Decimal128(15, 2), false),
5832 Field::new("l_shipmode", DataType::Utf8, false),
5833 Field::new("l_shipinstruct", DataType::Utf8, false),
5834 ]));
5835 let part_schema = Arc::new(Schema::new(vec![
5836 Field::new("p_partkey", DataType::Int64, false),
5837 Field::new("p_brand", DataType::Utf8, false),
5838 Field::new("p_container", DataType::Utf8, false),
5839 Field::new("p_size", DataType::Int32, false),
5840 ]));
5841 let money = |values: Vec<i128>| -> Arc<dyn arrow::array::Array> {
5842 Arc::new(
5843 Decimal128Array::from(values)
5844 .with_precision_and_scale(15, 2)
5845 .expect("decimal(15,2)"),
5846 )
5847 };
5848
5849 let lineitem_dir = dir.join("lineitem");
5850 std::fs::create_dir_all(&lineitem_dir).expect("lineitem dir");
5851 for file_index in 0..2i64 {
5852 let keys: Vec<i64> = (0..200).map(|i| (file_index * 200 + i) % 60).collect();
5853 let batch = RecordBatch::try_new(
5854 Arc::clone(&lineitem_schema),
5855 vec![
5856 Arc::new(Int64Array::from(keys.clone())),
5857 money(keys.iter().map(|k| i128::from(k % 30 + 1) * 100).collect()),
5858 money(keys.iter().map(|k| i128::from(k + 1) * 1_000).collect()),
5859 money(keys.iter().map(|k| i128::from(k % 10)).collect()),
5860 Arc::new(StringArray::from(
5861 keys.iter()
5862 .map(|k| if k % 2 == 0 { "AIR" } else { "RAIL" })
5863 .collect::<Vec<_>>(),
5864 )),
5865 Arc::new(StringArray::from(
5866 keys.iter()
5867 .map(|k| {
5868 if k % 3 == 0 {
5869 "DELIVER IN PERSON"
5870 } else {
5871 "TAKE BACK RETURN"
5872 }
5873 })
5874 .collect::<Vec<_>>(),
5875 )),
5876 ],
5877 )
5878 .expect("lineitem batch");
5879 write_parquet(
5880 &lineitem_dir.join(format!("l-{file_index}.parquet")),
5881 &batch,
5882 );
5883 }
5884
5885 let part_dir = dir.join("part");
5886 std::fs::create_dir_all(&part_dir).expect("part dir");
5887 for file_index in 0..2i64 {
5888 let keys: Vec<i64> = (0..30).map(|i| file_index * 30 + i).collect();
5889 let batch = RecordBatch::try_new(
5890 Arc::clone(&part_schema),
5891 vec![
5892 Arc::new(Int64Array::from(keys.clone())),
5893 Arc::new(StringArray::from(
5894 keys.iter()
5895 .map(|k| match k % 3 {
5896 0 => "Brand#12",
5897 1 => "Brand#23",
5898 _ => "Brand#34",
5899 })
5900 .collect::<Vec<_>>(),
5901 )),
5902 Arc::new(StringArray::from(
5903 keys.iter()
5904 .map(|k| match k % 4 {
5905 0 => "SM BOX",
5906 1 => "MED BOX",
5907 2 => "LG BOX",
5908 _ => "JUMBO BOX",
5909 })
5910 .collect::<Vec<_>>(),
5911 )),
5912 Arc::new(Int32Array::from(
5913 keys.iter().map(|k| (k % 15 + 1) as i32).collect::<Vec<_>>(),
5914 )),
5915 ],
5916 )
5917 .expect("part batch");
5918 write_parquet(&part_dir.join(format!("p-{file_index}.parquet")), &batch);
5919 }
5920 (lineitem_dir, part_dir)
5921 }
5922
5923 /// q22 verbatim: a correlated NOT EXISTS plus a scalar-subquery threshold,
5924 /// over `customer`/`orders`.
5925 const Q22: &str = "SELECT cntrycode, count(*) AS numcust, sum(c_acctbal) AS totacctbal FROM ( \
5926 SELECT substr(c_phone, 1, 2) AS cntrycode, c_acctbal FROM customer \
5927 WHERE substr(c_phone, 1, 2) IN ('13','31','23','29','30','18','17') \
5928 AND c_acctbal > (SELECT avg(c_acctbal) FROM customer \
5929 WHERE c_acctbal > 0.00 \
5930 AND substr(c_phone, 1, 2) IN ('13','31','23','29','30','18','17')) \
5931 AND NOT EXISTS (SELECT * FROM orders WHERE o_custkey = c_custkey)) AS custsale \
5932 GROUP BY cntrycode ORDER BY cntrycode";
5933
5934 /// Miniature `customer` and `orders`, two files each.
5935 fn write_q22_fixture(dir: &std::path::Path) -> (std::path::PathBuf, std::path::PathBuf) {
5936 use arrow::array::{Decimal128Array, Int64Array, StringArray};
5937 use arrow::datatypes::{DataType, Field, Schema};
5938
5939 let customer_schema = Arc::new(Schema::new(vec![
5940 Field::new("c_custkey", DataType::Int64, false),
5941 Field::new("c_phone", DataType::Utf8, false),
5942 Field::new("c_acctbal", DataType::Decimal128(15, 2), false),
5943 ]));
5944 let orders_schema = Arc::new(Schema::new(vec![
5945 Field::new("o_orderkey", DataType::Int64, false),
5946 Field::new("o_custkey", DataType::Int64, false),
5947 ]));
5948 let codes = ["13", "31", "23", "29", "30", "18", "17", "44"];
5949
5950 let customer_dir = dir.join("customer");
5951 std::fs::create_dir_all(&customer_dir).expect("customer dir");
5952 for file_index in 0..2i64 {
5953 let keys: Vec<i64> = (0..120).map(|i| file_index * 120 + i).collect();
5954 let batch = RecordBatch::try_new(
5955 Arc::clone(&customer_schema),
5956 vec![
5957 Arc::new(Int64Array::from(keys.clone())),
5958 Arc::new(StringArray::from(
5959 keys.iter()
5960 .map(|k| format!("{}-555-0100", codes[(*k as usize) % codes.len()]))
5961 .collect::<Vec<_>>(),
5962 )),
5963 Arc::new(
5964 Decimal128Array::from(
5965 keys.iter()
5966 .map(|k| i128::from(k % 900) * 100)
5967 .collect::<Vec<_>>(),
5968 )
5969 .with_precision_and_scale(15, 2)
5970 .expect("decimal(15,2)"),
5971 ),
5972 ],
5973 )
5974 .expect("customer batch");
5975 write_parquet(
5976 &customer_dir.join(format!("c-{file_index}.parquet")),
5977 &batch,
5978 );
5979 }
5980
5981 let orders_dir = dir.join("orders");
5982 std::fs::create_dir_all(&orders_dir).expect("orders dir");
5983 for file_index in 0..2i64 {
5984 let keys: Vec<i64> = (0..80).map(|i| file_index * 80 + i).collect();
5985 let batch = RecordBatch::try_new(
5986 Arc::clone(&orders_schema),
5987 vec![
5988 Arc::new(Int64Array::from(keys.clone())),
5989 // Only some customers have orders, so NOT EXISTS keeps rows.
5990 Arc::new(Int64Array::from(
5991 keys.iter().map(|k| k * 3 % 240).collect::<Vec<_>>(),
5992 )),
5993 ],
5994 )
5995 .expect("orders batch");
5996 write_parquet(&orders_dir.join(format!("o-{file_index}.parquet")), &batch);
5997 }
5998 (customer_dir, orders_dir)
5999 }
6000
6001 async fn q22_context(dir: &std::path::Path) -> SessionContext {
6002 q22_context_with_broadcast(dir, None).await
6003 }
6004
6005 async fn q22_context_with_broadcast(
6006 dir: &std::path::Path,
6007 broadcast_bytes: Option<usize>,
6008 ) -> SessionContext {
6009 let (customer, orders) = write_q22_fixture(dir);
6010 let ctx = planning_session_context_with_options(4, None, broadcast_bytes);
6011 for (name, path) in [("customer", customer), ("orders", orders)] {
6012 ctx.register_parquet(
6013 name,
6014 path.to_str().expect("utf8 path"),
6015 ParquetReadOptions::default(),
6016 )
6017 .await
6018 .expect("register parquet");
6019 }
6020 ctx
6021 }
6022
6023 /// The q22 defect and its repair, both pinned in one test.
6024 ///
6025 /// `c_acctbal > (SELECT avg(c_acctbal) …)` leaves a `ScalarSubqueryExpr` in
6026 /// a filter below the exchange, while the `ScalarSubqueryExec` that
6027 /// populates it — which DataFusion puts at the very ROOT of the plan —
6028 /// stays behind in the result stage. The map fragment then encodes happily
6029 /// and refuses to decode, and the builder reads that as "decline to stage",
6030 /// running all of q22 as ONE task.
6031 ///
6032 /// Asserting BOTH halves is the point. Without the first assertion the test
6033 /// would keep passing if the severing ever stopped happening, and the
6034 /// repair would be a no-op nobody noticed.
6035 #[tokio::test]
6036 async fn a_severed_scalar_subquery_stage_does_not_decode_until_the_wrapper_is_restored() {
6037 let tmp = tempfile::tempdir().expect("tempdir");
6038 let ctx = q22_context(tmp.path()).await;
6039 let plan = ctx
6040 .sql(Q22)
6041 .await
6042 .expect("sql")
6043 .create_physical_plan()
6044 .await
6045 .expect("physical plan");
6046
6047 let mut drafts: Vec<StageDraft> = Vec::new();
6048 let root = cut_exchanges(Arc::clone(&plan), &mut drafts)
6049 .unwrap_or_else(|Unsupported(reason)| panic!("q22 stage split: {reason}"));
6050 drafts.push(StageDraft {
6051 plan: root,
6052 shuffle: None,
6053 subqueries: None,
6054 });
6055 assert!(
6056 drafts.iter().any(|d| d.subqueries.is_some()),
6057 "q22 must cut at least one stage out from beneath the ScalarSubqueryExec, \
6058 or there is nothing for the repair to act on"
6059 );
6060
6061 let codec = KrishivPhysicalCodec::coordinator();
6062 let decode_ctx = fragment_decode_session_context().task_ctx();
6063 let mut saw_severed_stage = false;
6064 for draft in &drafts {
6065 let Some(context) = &draft.subqueries else {
6066 continue;
6067 };
6068 let bytes =
6069 encode_dfplan_bytes(Arc::clone(&draft.plan), &codec).expect("q22 stage encodes");
6070 let Err(error) =
6071 verify_dfplan_roundtrip(&bytes, &codec, &decode_ctx, Some(&draft.plan))
6072 else {
6073 // This stage carried no `ScalarSubqueryExpr`; nothing severed.
6074 continue;
6075 };
6076 assert!(
6077 error
6078 .to_string()
6079 .contains("ScalarSubqueryExpr can only be deserialized"),
6080 "expected the severed-wrapper decode failure, got: {error}"
6081 );
6082 saw_severed_stage = true;
6083
6084 let repaired = wrap_in_scalar_subquery_exec(Arc::clone(&draft.plan), context);
6085 let bytes =
6086 encode_dfplan_bytes(Arc::clone(&repaired), &codec).expect("repaired stage encodes");
6087 verify_dfplan_roundtrip(&bytes, &codec, &decode_ctx, Some(&repaired))
6088 .expect("restoring the wrapper must make the fragment decodable");
6089 }
6090 assert!(
6091 saw_severed_stage,
6092 "precondition: a q22 stage must actually fail to decode bare, or this \
6093 test proves nothing about the repair"
6094 );
6095 }
6096
6097 /// Bar 2 for q22: it must genuinely use the cluster, not merely return the
6098 /// right answer on one executor. A staged plan that produced one task per
6099 /// stage would satisfy `Some(_)` and still be a single-task query.
6100 #[tokio::test]
6101 async fn q22_distributes_instead_of_running_as_a_single_task() {
6102 let tmp = tempfile::tempdir().expect("tempdir");
6103 let ctx = q22_context(tmp.path()).await;
6104 let plan = ctx
6105 .sql(Q22)
6106 .await
6107 .expect("sql")
6108 .create_physical_plan()
6109 .await
6110 .expect("physical plan");
6111
6112 let staged = build_distributed_stages(plan)
6113 .expect("build stages")
6114 .expect("q22 must stage: a severed scalar-subquery wrapper is repaired, not declined");
6115 assert!(
6116 staged.stages.len() >= 2,
6117 "expected a map stage and a result stage, got {}",
6118 staged.stages.len()
6119 );
6120 assert!(
6121 staged.stages.iter().any(|s| s.task_count() > 1),
6122 "some stage must run more than one task, or 'distributed' means nothing: {:?}",
6123 staged
6124 .stages
6125 .iter()
6126 .map(DistributedStage::task_count)
6127 .collect::<Vec<_>>()
6128 );
6129 }
6130
6131 /// The silent wrong-answer bug q22 exposed, pinned deterministically.
6132 ///
6133 /// `PartitionMode::CollectLeft` emits its unmatched BUILD rows only after
6134 /// the last probe partition reports in. A distributed task executes ONE
6135 /// partition, so that rendezvous never happens and those rows are dropped —
6136 /// no error, no schema mismatch, just a wrong answer. q22's `NOT EXISTS`
6137 /// returned zero rows per task.
6138 ///
6139 /// Built by hand rather than planned from SQL, deliberately: DataFusion
6140 /// usually SWAPS the inputs so the smaller side builds, turning `LeftAnti`
6141 /// into `RightAnti` — which streams from the probe side and is perfectly
6142 /// safe to split. That swap is why this shape is rare, why it survived
6143 /// every sweep unnoticed, and why a test that just runs a `NOT EXISTS`
6144 /// query proves nothing: it would silently exercise the safe plan. The
6145 /// end-to-end proof over a real severed plan is
6146 /// `staged_q22_matches_direct_execution`.
6147 #[tokio::test]
6148 async fn an_unsplittable_broadcast_join_is_detected_and_converted() {
6149 use datafusion::logical_expr::JoinType;
6150 use datafusion::physical_plan::joins::{HashJoinExec, PartitionMode};
6151
6152 let tmp = tempfile::tempdir().expect("tempdir");
6153 let ctx = q22_context(tmp.path()).await;
6154 let scan = |sql: &'static str| {
6155 let ctx = ctx.clone();
6156 async move {
6157 ctx.sql(sql)
6158 .await
6159 .expect("sql")
6160 .create_physical_plan()
6161 .await
6162 .expect("physical plan")
6163 }
6164 };
6165 let build = scan("SELECT c_custkey FROM customer").await;
6166 let probe = scan("SELECT o_custkey FROM orders").await;
6167 assert!(
6168 probe.output_partitioning().partition_count() > 1,
6169 "precondition: the probe side must have several partitions, or there \
6170 is no rendezvous to miss"
6171 );
6172
6173 let on = vec![(
6174 datafusion::physical_plan::expressions::col("c_custkey", &build.schema())
6175 .expect("build key"),
6176 datafusion::physical_plan::expressions::col("o_custkey", &probe.schema())
6177 .expect("probe key"),
6178 )];
6179 let unsafe_join: Arc<dyn ExecutionPlan> = Arc::new(
6180 HashJoinExec::try_new(
6181 Arc::new(CoalescePartitionsExec::new(build)),
6182 probe,
6183 on,
6184 None,
6185 &JoinType::LeftAnti,
6186 None,
6187 PartitionMode::CollectLeft,
6188 datafusion::common::NullEquality::NullEqualsNothing,
6189 false,
6190 )
6191 .expect("hand-built broadcast anti-join"),
6192 );
6193
6194 let join_ref = unsafe_join
6195 .downcast_ref::<HashJoinExec>()
6196 .expect("hash join");
6197 assert!(
6198 is_unsplittable_broadcast_join(join_ref),
6199 "a CollectLeft LeftAnti join over a multi-partition probe must be \
6200 recognised as unsplittable"
6201 );
6202 assert!(
6203 find_unsupported_stage_node(&unsafe_join).is_some(),
6204 "and the stage guard must refuse it, so it can never ship unconverted"
6205 );
6206
6207 let converted = redistribute_unsplittable_broadcast_joins(Arc::clone(&unsafe_join))
6208 .expect("conversion must succeed");
6209 let converted_join = converted
6210 .downcast_ref::<HashJoinExec>()
6211 .expect("still a hash join");
6212 assert_eq!(
6213 *converted_join.partition_mode(),
6214 PartitionMode::Partitioned,
6215 "conversion must switch to the mode whose probe counter is per-task"
6216 );
6217 assert!(
6218 !is_unsplittable_broadcast_join(converted_join),
6219 "the converted join must no longer be unsplittable"
6220 );
6221 assert_eq!(
6222 *converted_join.join_type(),
6223 JoinType::LeftAnti,
6224 "conversion must not change the join's meaning"
6225 );
6226 assert_eq!(
6227 converted.schema(),
6228 unsafe_join.schema(),
6229 "conversion must preserve the join's output schema"
6230 );
6231 }
6232
6233 /// Hand-build a `CollectLeft` join over `build`, coalesced as the planner
6234 /// would coalesce it, probing a multi-partition `orders` scan.
6235 ///
6236 /// Shared by the broadcast-policy tests below so they differ only in the
6237 /// build side, which is the variable under test.
6238 async fn collect_left_over(
6239 ctx: &SessionContext,
6240 build_sql: &str,
6241 build_key: &str,
6242 null_aware: bool,
6243 join_type: datafusion::logical_expr::JoinType,
6244 ) -> Arc<dyn ExecutionPlan> {
6245 use datafusion::physical_plan::joins::{HashJoinExec, PartitionMode};
6246
6247 let plan = |sql: String| {
6248 let ctx = ctx.clone();
6249 async move {
6250 ctx.sql(&sql)
6251 .await
6252 .expect("sql")
6253 .create_physical_plan()
6254 .await
6255 .expect("physical plan")
6256 }
6257 };
6258 let build = plan(build_sql.to_owned()).await;
6259 let probe = plan(String::from("SELECT o_custkey FROM orders")).await;
6260 let on = vec![(
6261 datafusion::physical_plan::expressions::col(build_key, &build.schema())
6262 .expect("build key"),
6263 datafusion::physical_plan::expressions::col("o_custkey", &probe.schema())
6264 .expect("probe key"),
6265 )];
6266 Arc::new(
6267 HashJoinExec::try_new(
6268 Arc::new(CoalescePartitionsExec::new(build)),
6269 probe,
6270 on,
6271 None,
6272 &join_type,
6273 None,
6274 PartitionMode::CollectLeft,
6275 datafusion::common::NullEquality::NullEqualsNothing,
6276 null_aware,
6277 )
6278 .expect("hand-built broadcast join"),
6279 )
6280 }
6281
6282 /// The q21 defect: an estimate of ZERO must not read as "small enough to
6283 /// broadcast".
6284 ///
6285 /// At SF100 DataFusion estimates q21's `LeftAnti` self-join at
6286 /// `593462145 - 593462145 = 0` rows, and three `CollectLeft` joins stacked
6287 /// above it each broadcast on the strength of that — gathering tens of
6288 /// millions of rows to ONE partition three times over. `0 < ceiling` is the
6289 /// most convincing "broadcast me" a build side can produce, which is
6290 /// exactly backwards.
6291 #[tokio::test]
6292 async fn a_zero_estimate_is_not_proof_that_a_build_side_is_small() {
6293 use datafusion::physical_plan::joins::{HashJoinExec, PartitionMode};
6294
6295 let tmp = tempfile::tempdir().expect("tempdir");
6296 let ctx = q22_context(tmp.path()).await;
6297 // `WHERE false` gives the planner an exact zero — the same input the
6298 // anti-join's arithmetic produces, without needing SF100 to reach it.
6299 let join = collect_left_over(
6300 &ctx,
6301 "SELECT c_custkey FROM customer WHERE 1 = 0",
6302 "c_custkey",
6303 false,
6304 datafusion::logical_expr::JoinType::Inner,
6305 )
6306 .await;
6307 let join_ref = join.downcast_ref::<HashJoinExec>().expect("hash join");
6308
6309 assert!(
6310 broadcast_build_estimate_is_empty(join_ref),
6311 "a zero estimate is the estimator giving up, not a measurement"
6312 );
6313 assert!(
6314 is_degenerate_broadcast_join(join_ref),
6315 "so the join must be recognised as one that should not broadcast"
6316 );
6317 // The correctness gate must stay untouched: an Inner join drops no
6318 // unmatched build rows, so refusing to STAGE it would turn a merely
6319 // slow plan into one that declines to distribute at all.
6320 assert!(
6321 !is_unsplittable_broadcast_join(join_ref),
6322 "this is a throughput problem, not a correctness one"
6323 );
6324 assert!(
6325 find_unsupported_stage_node(&join).is_none(),
6326 "and the stage guard must not refuse it"
6327 );
6328
6329 let converted =
6330 redistribute_unsplittable_broadcast_joins(Arc::clone(&join)).expect("conversion");
6331 let converted_join = converted
6332 .downcast_ref::<HashJoinExec>()
6333 .expect("still a hash join");
6334 assert_eq!(
6335 *converted_join.partition_mode(),
6336 PartitionMode::Partitioned,
6337 "the build side must be hash-partitioned instead of gathered"
6338 );
6339 assert_eq!(
6340 converted.schema(),
6341 join.schema(),
6342 "conversion must preserve the join's output schema"
6343 );
6344 }
6345
6346 /// The opposite regression, and the reason this rule is not simply "never
6347 /// broadcast".
6348 ///
6349 /// Broadcasting a genuinely small dimension side is what keeps q8/q9 from
6350 /// hash-partitioning the raw 600M-row `lineitem` scan across a ~11 MiB/s
6351 /// pod network. A scan of a small table has EXACT parquet statistics, so it
6352 /// is provably small and must survive this rule untouched.
6353 #[tokio::test]
6354 async fn a_provably_small_build_side_is_still_broadcast() {
6355 use datafusion::physical_plan::joins::{HashJoinExec, PartitionMode};
6356
6357 let tmp = tempfile::tempdir().expect("tempdir");
6358 let ctx = q22_context(tmp.path()).await;
6359 let join = collect_left_over(
6360 &ctx,
6361 "SELECT c_custkey FROM customer",
6362 "c_custkey",
6363 false,
6364 datafusion::logical_expr::JoinType::Inner,
6365 )
6366 .await;
6367 let join_ref = join.downcast_ref::<HashJoinExec>().expect("hash join");
6368
6369 assert!(
6370 !broadcast_build_estimate_is_empty(join_ref),
6371 "a non-empty parquet scan must report a positive estimate"
6372 );
6373 assert!(
6374 !is_degenerate_broadcast_join(join_ref),
6375 "so it must keep its broadcast"
6376 );
6377 let after =
6378 redistribute_unsplittable_broadcast_joins(Arc::clone(&join)).expect("conversion");
6379 assert_eq!(
6380 *after
6381 .downcast_ref::<HashJoinExec>()
6382 .expect("still a hash join")
6383 .partition_mode(),
6384 PartitionMode::CollectLeft,
6385 "the rule must leave a legitimately small broadcast alone"
6386 );
6387 }
6388
6389 /// The regression this rule caused once, pinned so it cannot come back.
6390 ///
6391 /// An earlier version demanded a positive estimate *below a ceiling*. That
6392 /// converted q8's and q9's `CollectLeft` build sides — estimated at
6393 /// `rows=~4000000, bytes=absent`, above the 1M row ceiling but entirely
6394 /// plausible — and on the cluster **q8 went 92 s -> 375 s and q9 226 s ->
6395 /// 576 s**, because the alternative to broadcasting a few million rows is
6396 /// hash-partitioning the 600M-row `lineitem` scan across an ~11 MiB/s pod
6397 /// network.
6398 ///
6399 /// The ceiling is DataFusion's call, made with the same numbers this rule
6400 /// can see. A large but positive estimate must therefore be left alone; only
6401 /// "the planner thinks this is empty" is overridden.
6402 #[tokio::test]
6403 async fn a_large_but_positive_estimate_keeps_its_broadcast() {
6404 use datafusion::physical_plan::joins::{HashJoinExec, PartitionMode};
6405
6406 let tmp = tempfile::tempdir().expect("tempdir");
6407 let ctx = q22_context(tmp.path()).await;
6408 // A cross join squares the row estimate, which is how a build side
6409 // reaches a number far above any ceiling while staying honest — the
6410 // shape of q8/q9's estimate, reachable without SF100.
6411 let join = collect_left_over(
6412 &ctx,
6413 "SELECT a.c_custkey FROM customer a CROSS JOIN customer b",
6414 "c_custkey",
6415 false,
6416 datafusion::logical_expr::JoinType::Inner,
6417 )
6418 .await;
6419 let join_ref = join.downcast_ref::<HashJoinExec>().expect("hash join");
6420
6421 let stats = join_ref
6422 .left()
6423 .partition_statistics(None)
6424 .expect("statistics");
6425 assert!(
6426 matches!(
6427 stats.num_rows,
6428 datafusion::common::stats::Precision::Exact(n)
6429 | datafusion::common::stats::Precision::Inexact(n) if n > 0
6430 ),
6431 "precondition: the build side must estimate a positive row count, \
6432 got {:?}",
6433 stats.num_rows
6434 );
6435 assert!(
6436 !broadcast_build_estimate_is_empty(join_ref),
6437 "a positive estimate is a measurement, however large"
6438 );
6439 assert!(
6440 !is_degenerate_broadcast_join(join_ref),
6441 "and must not be converted — overriding DataFusion's ceiling cost \
6442 q8 4x and q9 2.5x"
6443 );
6444 let after =
6445 redistribute_unsplittable_broadcast_joins(Arc::clone(&join)).expect("conversion");
6446 assert_eq!(
6447 *after
6448 .downcast_ref::<HashJoinExec>()
6449 .expect("still a hash join")
6450 .partition_mode(),
6451 PartitionMode::CollectLeft,
6452 "the plan must come back untouched"
6453 );
6454 }
6455
6456 /// A null-aware anti join is only correct as `CollectLeft`.
6457 ///
6458 /// It tracks probe-side state across the whole build side, and DataFusion
6459 /// rejects any other partition mode for it at construction — so however
6460 /// badly estimated its build side is, converting it would trade a slow
6461 /// query for one that does not run.
6462 #[tokio::test]
6463 async fn a_null_aware_anti_join_is_never_converted() {
6464 use datafusion::physical_plan::joins::HashJoinExec;
6465
6466 let tmp = tempfile::tempdir().expect("tempdir");
6467 let ctx = q22_context(tmp.path()).await;
6468 let join = collect_left_over(
6469 &ctx,
6470 "SELECT c_custkey FROM customer WHERE 1 = 0",
6471 "c_custkey",
6472 true,
6473 datafusion::logical_expr::JoinType::LeftAnti,
6474 )
6475 .await;
6476 let join_ref = join.downcast_ref::<HashJoinExec>().expect("hash join");
6477
6478 assert!(
6479 broadcast_build_estimate_is_empty(join_ref),
6480 "precondition: its build-side estimate is degenerate, so only the \
6481 null-aware check can be what spares it"
6482 );
6483 assert!(
6484 !is_degenerate_broadcast_join(join_ref),
6485 "a null-aware anti join must never be converted for throughput"
6486 );
6487 }
6488
6489 /// And the repaired plan must still compute q22's actual answer. The
6490 /// wrapper is re-evaluated per stage, so every task resolves the subquery
6491 /// independently — this is what proves they all resolve it to the same
6492 /// value the single-node plan uses.
6493 #[tokio::test]
6494 async fn staged_q22_matches_direct_execution() {
6495 let tmp = tempfile::tempdir().expect("tempdir");
6496 let ctx = q22_context(tmp.path()).await;
6497 let expected = render(&direct(&ctx, Q22).await);
6498 let actual = run_staged(&ctx, Q22)
6499 .await
6500 .unwrap_or_else(|e| panic!("q22: staged execution failed: {e}"));
6501 assert_eq!(
6502 render(&actual),
6503 expected,
6504 "q22: staged result differs from single-node execution"
6505 );
6506 assert!(!expected.is_empty(), "the q22 fixture must produce rows");
6507 }
6508
6509 /// `broadcast_bytes: Some(0)` reproduces the cluster's join shape: neither
6510 /// side is small enough to collect, so both hash-shuffle and the reduce
6511 /// stage gets **two** `ShuffleReadExec` leaves over two upstream stages.
6512 /// Every other test here runs the broadcast shape, because a fixture that
6513 /// fits in a process is always under the 32 MiB ceiling.
6514 async fn tpch_context_with_broadcast(
6515 dir: &std::path::Path,
6516 join_threshold: Option<u64>,
6517 broadcast_bytes: Option<usize>,
6518 ) -> SessionContext {
6519 let (lineitem, part) = write_tpch_fixture(dir);
6520 let ctx = planning_session_context_with_options(4, join_threshold, broadcast_bytes);
6521 for (name, path) in [("lineitem", lineitem), ("part", part)] {
6522 ctx.register_parquet(
6523 name,
6524 path.to_str().expect("utf8 path"),
6525 ParquetReadOptions::default(),
6526 )
6527 .await
6528 .expect("register parquet");
6529 }
6530 ctx
6531 }
6532
6533 /// Consistent test-side routing; any consistent hash is correct here.
6534 fn route(batch: &RecordBatch, key_column: &str, num_partitions: usize) -> Vec<RecordBatch> {
6535 use std::hash::{Hash as _, Hasher as _};
6536 let key_idx = batch.schema().index_of(key_column).expect("key column");
6537 let column = batch.column(key_idx);
6538 let mut selections: Vec<Vec<u32>> = vec![Vec::new(); num_partitions];
6539 for row in 0..batch.num_rows() {
6540 let value = arrow::util::display::array_value_to_string(column, row).expect("value");
6541 let mut hasher = std::collections::hash_map::DefaultHasher::new();
6542 value.hash(&mut hasher);
6543 let bucket = (hasher.finish() as usize) % num_partitions;
6544 selections[bucket].push(row as u32);
6545 }
6546 selections
6547 .into_iter()
6548 .map(|rows| {
6549 let indices = arrow::array::UInt32Array::from(rows);
6550 arrow::compute::take_record_batch(batch, &indices).expect("take")
6551 })
6552 .collect()
6553 }
6554
6555 /// Run every stage in dependency order, exactly as the cluster does.
6556 async fn run_staged(ctx: &SessionContext, sql: &str) -> Result<Vec<RecordBatch>, String> {
6557 let df = ctx.sql(sql).await.map_err(|e| e.to_string())?;
6558 let plan = df.create_physical_plan().await.map_err(|e| e.to_string())?;
6559 let staged = build_distributed_stages(plan)
6560 .map_err(|e| e.to_string())?
6561 .ok_or_else(|| String::from("declined to stage"))?;
6562
6563 let store = Arc::new(StageStore::default());
6564 let exec_ctx = fragment_decode_session_context();
6565 let mut result = Vec::new();
6566 for (stage_index, stage) in staged.stages.iter().enumerate() {
6567 for (task_index, body) in stage.task_bodies.iter().enumerate() {
6568 let reader: Arc<dyn ShufflePartitionReader> = Arc::new(Arc::clone(&store));
6569 let (declared, mut stream) = execute_dfplan_body(body, &exec_ctx, Some(reader))
6570 .map_err(|e| format!("stage {stage_index} task {task_index} start: {e}"))?;
6571 while let Some(batch) = futures::StreamExt::next(&mut stream).await {
6572 let batch =
6573 batch.map_err(|e| format!("stage {stage_index} task {task_index}: {e}"))?;
6574 // The invariant the cluster depends on and this harness
6575 // would otherwise hide: the store here hands the *same*
6576 // batches back, so a stage whose declared schema disagrees
6577 // with its produced batches sails through in process and
6578 // only dies on the wire, where the reduce side concatenates
6579 // real IPC data against the declared schema and Arrow says
6580 // "column types must match schema types".
6581 if batch.schema() != declared {
6582 return Err(format!(
6583 "stage {stage_index} task {task_index} declares {declared:?} but \
6584 produced {:?}; ShuffleReadExec labels the reduce side with the \
6585 declared schema, so this disagreement becomes a reduce-side Arrow \
6586 error on a real cluster",
6587 batch.schema()
6588 ));
6589 }
6590 if batch.num_rows() == 0 {
6591 continue;
6592 }
6593 match &stage.shuffle {
6594 None => result.push(batch),
6595 Some(shuffle) => match shuffle.key_columns.first() {
6596 Some(key) => {
6597 for (bucket, part) in
6598 route(&batch, key, shuffle.num_output_partitions)
6599 .into_iter()
6600 .enumerate()
6601 {
6602 if part.num_rows() > 0 {
6603 store
6604 .partitions
6605 .lock()
6606 .expect("store lock")
6607 .entry((stage_index, task_index, bucket))
6608 .or_default()
6609 .push(part);
6610 }
6611 }
6612 }
6613 // A keyless shuffle is a gather: everything to 0.
6614 None => store
6615 .partitions
6616 .lock()
6617 .expect("store lock")
6618 .entry((stage_index, task_index, 0))
6619 .or_default()
6620 .push(batch),
6621 },
6622 }
6623 }
6624 }
6625 }
6626 Ok(result)
6627 }
6628
6629 fn render(batches: &[RecordBatch]) -> Vec<String> {
6630 let mut rows: Vec<String> = batches
6631 .iter()
6632 .flat_map(|b| {
6633 (0..b.num_rows()).map(move |r| {
6634 (0..b.num_columns())
6635 .map(|c| {
6636 arrow::util::display::array_value_to_string(b.column(c), r)
6637 .expect("cell")
6638 })
6639 .collect::<Vec<_>>()
6640 .join("|")
6641 })
6642 })
6643 .collect();
6644 rows.sort();
6645 rows
6646 }
6647
6648 async fn direct(ctx: &SessionContext, sql: &str) -> Vec<RecordBatch> {
6649 ctx.sql(sql)
6650 .await
6651 .expect("sql")
6652 .collect()
6653 .await
6654 .expect("direct execution")
6655 }
6656
6657 /// The staged answer must equal the single-node answer, with and without
6658 /// the spillable-join conversion active.
6659 ///
6660 /// `Some(0)` forces every join whose build size is known to convert to
6661 /// sort-merge — the state a memory-capped executor is in, and the state
6662 /// this build box never reaches on its own. The rule claims to preserve
6663 /// the join's output schema exactly (`schema_check()` returns true), so
6664 /// converting *more* joins than production would must still be correct;
6665 /// if it is not, the claim is false.
6666 async fn staged_matches_direct(sql: &str, join_threshold: Option<u64>, label: &str) {
6667 staged_matches_direct_with_broadcast(sql, join_threshold, None, label).await;
6668 }
6669
6670 async fn staged_matches_direct_with_broadcast(
6671 sql: &str,
6672 join_threshold: Option<u64>,
6673 broadcast_bytes: Option<usize>,
6674 label: &str,
6675 ) {
6676 let tmp = tempfile::tempdir().expect("tempdir");
6677 let ctx = tpch_context_with_broadcast(tmp.path(), join_threshold, broadcast_bytes).await;
6678 let expected = render(&direct(&ctx, sql).await);
6679 let actual = run_staged(&ctx, sql)
6680 .await
6681 .unwrap_or_else(|e| panic!("{label}: staged execution failed: {e}"));
6682 assert_eq!(
6683 render(&actual),
6684 expected,
6685 "{label}: staged result differs from single-node execution"
6686 );
6687 }
6688
6689 #[tokio::test]
6690 async fn staged_q17_matches_direct_execution() {
6691 staged_matches_direct(Q17, None, "q17/unconverted").await;
6692 }
6693
6694 #[tokio::test]
6695 async fn staged_q17_matches_direct_execution_with_converted_joins() {
6696 staged_matches_direct(Q17, Some(0), "q17/converted").await;
6697 }
6698
6699 #[tokio::test]
6700 async fn staged_q19_matches_direct_execution() {
6701 staged_matches_direct(Q19, None, "q19/unconverted").await;
6702 }
6703
6704 #[tokio::test]
6705 async fn staged_q19_matches_direct_execution_with_converted_joins() {
6706 staged_matches_direct(Q19, Some(0), "q19/converted").await;
6707 }
6708
6709 /// The shape the cluster actually runs: no broadcast, so both join sides
6710 /// hash-shuffle and the reduce stage reads two upstream stages.
6711 ///
6712 /// q17 and q19 pass every broadcast-shaped test above and still fail at
6713 /// SF100 with a bare Arrow type error, so the defect lives in what the
6714 /// broadcast shape never builds.
6715 #[tokio::test]
6716 async fn staged_q17_matches_direct_execution_without_broadcast() {
6717 staged_matches_direct_with_broadcast(Q17, None, Some(0), "q17/no-broadcast").await;
6718 }
6719
6720 #[tokio::test]
6721 async fn staged_q19_matches_direct_execution_without_broadcast() {
6722 staged_matches_direct_with_broadcast(Q19, None, Some(0), "q19/no-broadcast").await;
6723 }
6724
6725 /// The cell the matrix was missing — and the only one the cluster is in.
6726 ///
6727 /// The conversion tests above all run the *broadcast* shape, and the
6728 /// no-broadcast tests all run *unconverted* joins. SF100 does both at once:
6729 /// no build side is under the 32 MiB ceiling, so both sides hash-shuffle,
6730 /// **and** the build sides are far over the spill threshold, so
6731 /// `SpillableJoinSelection` rewrites them to sort-merge. Two settings that
6732 /// are each covered alone and never together.
6733 ///
6734 /// That combination is what `reapply_projection` runs in: a projected join
6735 /// whose converted form is a `SortMergeJoinExec` (which has no projection of
6736 /// its own) sitting under a shuffle, where the reduce side concatenates real
6737 /// IPC data against the declared schema.
6738 #[tokio::test]
6739 async fn staged_q17_matches_direct_execution_converted_and_without_broadcast() {
6740 staged_matches_direct_with_broadcast(Q17, Some(0), Some(0), "q17/converted+no-broadcast")
6741 .await;
6742 }
6743
6744 #[tokio::test]
6745 async fn staged_q19_matches_direct_execution_converted_and_without_broadcast() {
6746 staged_matches_direct_with_broadcast(Q19, Some(0), Some(0), "q19/converted+no-broadcast")
6747 .await;
6748 }
6749
6750 #[tokio::test]
6751 async fn staged_q22_matches_direct_execution_without_broadcast() {
6752 let tmp = tempfile::tempdir().expect("tempdir");
6753 let ctx = q22_context_with_broadcast(tmp.path(), Some(0)).await;
6754 let expected = render(&direct(&ctx, Q22).await);
6755 let actual = run_staged(&ctx, Q22)
6756 .await
6757 .unwrap_or_else(|e| panic!("q22/no-broadcast: staged execution failed: {e}"));
6758 assert_eq!(
6759 render(&actual),
6760 expected,
6761 "q22/no-broadcast: staged result differs from single-node execution"
6762 );
6763 assert!(!expected.is_empty(), "the q22 fixture must produce rows");
6764 }
6765
6766 /// `avg` over a decimal, cut so the Final aggregate lands in a different
6767 /// stage from its Partial — q17's shape, reduced to the one operator.
6768 ///
6769 /// `datafusion-proto` carries no output type for an aggregate: the decoder
6770 /// resolves the UDAF by name and `AggregateExprBuilder::build()` re-derives
6771 /// the return type from the resolved function and its *input* types. A
6772 /// Final aggregate's inputs are the Partial's **state** columns, not the
6773 /// original column, so if the rebuild reads them as ordinary inputs it
6774 /// produces a wider decimal than the coordinator planned — which is exactly
6775 /// what q17 reports from SF100:
6776 ///
6777 /// expected Decimal128(15, 2) but found Decimal128(30, 15)
6778 ///
6779 /// Both the ungrouped (gather-cut) and grouped (hash-exchange-cut) forms
6780 /// are covered: they take different arms of `cut_exchanges`.
6781 #[tokio::test]
6782 async fn a_final_avg_over_a_decimal_survives_the_fragment_round_trip() {
6783 for sql in [
6784 "SELECT avg(l_quantity) FROM lineitem",
6785 "SELECT l_partkey, avg(l_quantity) FROM lineitem GROUP BY l_partkey",
6786 "SELECT sum(l_extendedprice) / 7.0 FROM lineitem",
6787 ] {
6788 let tmp = tempfile::tempdir().expect("tempdir");
6789 let ctx = tpch_context_with_broadcast(tmp.path(), None, None).await;
6790 let expected = render(&direct(&ctx, sql).await);
6791 let actual = run_staged(&ctx, sql)
6792 .await
6793 .unwrap_or_else(|e| panic!("{sql}: staged execution failed: {e}"));
6794 assert_eq!(
6795 render(&actual),
6796 expected,
6797 "{sql}: staged result differs from single-node execution"
6798 );
6799 }
6800 }
6801
6802 /// A reduce stage really does read two distinct upstream stages once
6803 /// broadcasting is off — the precondition the three tests above depend on.
6804 /// Without this, a planner change that quietly restored a broadcast join
6805 /// would turn them into duplicates of the tests they were written to
6806 /// complement, and nothing would say so.
6807 #[tokio::test]
6808 async fn without_broadcast_a_reduce_stage_reads_two_upstream_stages() {
6809 let tmp = tempfile::tempdir().expect("tempdir");
6810 let ctx = tpch_context_with_broadcast(tmp.path(), None, Some(0)).await;
6811 let plan = ctx
6812 .sql(Q19)
6813 .await
6814 .expect("sql")
6815 .create_physical_plan()
6816 .await
6817 .expect("physical plan");
6818 let staged = build_distributed_stages(plan)
6819 .expect("staging must not error")
6820 .expect("q19 must stage");
6821 let widest = staged
6822 .stages
6823 .iter()
6824 .map(|stage| stage.upstream_stage_indexes.len())
6825 .max()
6826 .unwrap_or(0);
6827 assert!(
6828 widest >= 2,
6829 "expected a stage reading 2+ upstream stages, widest was {widest}; \
6830 the no-broadcast tests are not exercising the cluster's join shape"
6831 );
6832 }
6833}
6834
6835#[cfg(test)]
6836#[allow(clippy::unwrap_used, clippy::expect_used)]
6837mod codec_completeness_tests {
6838 /// Every custom `ExecutionPlan` in this crate must be either encodable by
6839 /// [`KrishivPhysicalCodec`] or explicitly declared execution-local.
6840 ///
6841 /// # The failure this prevents
6842 ///
6843 /// `datafusion-proto` cannot encode a node the extension codec does not
6844 /// know. The scheduler's response to a stage plan it cannot encode is not an
6845 /// error — it is to abandon staging and run the whole query as a **single
6846 /// task**:
6847 ///
6848 /// ```text
6849 /// stage plan cannot be encoded and decoded; running this query as a
6850 /// SINGLE TASK ... Unsupported plan and extension codec failed
6851 /// ```
6852 ///
6853 /// So adding an operator without a codec entry does not break loudly. It
6854 /// quietly un-distributes every query the operator touches while continuing
6855 /// to report success. `GraceHashJoinExec` did exactly that to TPC-H q10,
6856 /// q17, q19 and q21 — hours of cluster time reading as passes.
6857 ///
6858 /// A source scan rather than a type-level check because Rust cannot
6859 /// enumerate trait impls at runtime; this mirrors
6860 /// `krishiv_common::env_registry`'s
6861 /// `every_flag_read_in_source_is_declared`, which exists for the same
6862 /// reason.
6863 #[test]
6864 fn every_custom_execution_plan_is_encodable_or_declared_local() {
6865 // Nodes that may appear in a plan the coordinator encodes. Adding one
6866 // here without a `try_encode`/`try_decode` arm re-opens the bug.
6867 // `runtime_filters::the_injected_stages_round_trip_through_the_codec` is
6868 // what makes listing the two filter nodes here a fact rather than a
6869 // promise: it encodes and decodes a plan containing both.
6870 const ENCODABLE: &[&str] = &[
6871 "RuntimeFilterBuildExec",
6872 "RuntimeFilterProbeExec",
6873 "ShuffleReadExec",
6874 ];
6875 // Nodes that are constructed only AFTER decode and never serialized.
6876 // `GraceHashJoinExec` is chosen per-executor from live memory pressure
6877 // (`apply_local_spill_strategy`); `OnceStreamExec` wraps an already-open
6878 // spill-file stream, which has no meaning on another machine.
6879 const EXECUTION_LOCAL: &[&str] = &["GraceHashJoinExec", "OnceStreamExec"];
6880
6881 let mut found = Vec::new();
6882 let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
6883 let mut stack = vec![dir];
6884 while let Some(path) = stack.pop() {
6885 for entry in std::fs::read_dir(&path).expect("read src") {
6886 let entry = entry.expect("dir entry").path();
6887 if entry.is_dir() {
6888 stack.push(entry);
6889 continue;
6890 }
6891 if entry.extension().is_none_or(|e| e != "rs") {
6892 continue;
6893 }
6894 let text = std::fs::read_to_string(&entry).expect("read file");
6895 for line in text.lines() {
6896 if let Some(rest) = line.trim().strip_prefix("impl ExecutionPlan for ") {
6897 let name = rest
6898 .trim_end_matches(" {")
6899 .split(['<', ' '])
6900 .next()
6901 .unwrap_or(rest)
6902 .to_string();
6903 found.push(name);
6904 }
6905 }
6906 }
6907 }
6908 found.sort();
6909 found.dedup();
6910 assert!(
6911 !found.is_empty(),
6912 "the scan found no ExecutionPlan impls at all"
6913 );
6914
6915 let undeclared: Vec<&String> = found
6916 .iter()
6917 .filter(|n| !ENCODABLE.contains(&n.as_str()) && !EXECUTION_LOCAL.contains(&n.as_str()))
6918 .collect();
6919 assert!(
6920 undeclared.is_empty(),
6921 "custom ExecutionPlan(s) {undeclared:?} are neither encodable nor declared \
6922 execution-local. If such a node can reach a stage plan, the coordinator will \
6923 silently run the query as a SINGLE TASK. Add a codec arm, or confine it to \
6924 post-decode and list it in EXECUTION_LOCAL."
6925 );
6926 }
6927 /// An AQE rewrite rebuilds every reduce task body through
6928 /// `dfplan_body_with_spec`. If that drops the Python-UDF directive prefix,
6929 /// the rebuilt task ships a plan referencing a UDF the executor was never
6930 /// told to reconstruct, and it dies with "PhysicalExtensionCodec is not
6931 /// provided for scalar function <name>" — while its sibling map tasks,
6932 /// whose bodies are never rebuilt, run fine.
6933 #[test]
6934 fn rebuilding_a_body_keeps_the_python_udf_directive() {
6935 use super::{
6936 DfplanTaskSpec, dfplan_body_partition_spec, dfplan_body_with_spec, is_dfplan_body,
6937 };
6938 let directive = "/* krishiv-register-python-udf:addk:int64:int64:QUJD */";
6939 let body = format!("{directive}\ndfplan:v1:0:QUJD");
6940 let spec = DfplanTaskSpec {
6941 partitions: vec![3, 4],
6942 map_range: None,
6943 };
6944 let rebuilt = dfplan_body_with_spec(&body, &spec).expect("rebuild");
6945 assert!(
6946 rebuilt.starts_with(directive),
6947 "the rebuilt body must still carry the UDF directive: {rebuilt}"
6948 );
6949 assert!(
6950 is_dfplan_body(&rebuilt),
6951 "and must still parse as a dfplan body: {rebuilt}"
6952 );
6953 assert_eq!(
6954 dfplan_body_partition_spec(&rebuilt)
6955 .expect("spec")
6956 .partitions,
6957 vec![3, 4],
6958 "the new partition spec must be the one asked for"
6959 );
6960 }
6961
6962 // ── Stage reuse (ReuseExchange) ────────────────────────────────────────
6963
6964 mod stage_reuse {
6965 use super::super::*;
6966
6967 fn scan_schema() -> SchemaRef {
6968 Arc::new(arrow::datatypes::Schema::new(vec![
6969 arrow::datatypes::Field::new("k", arrow::datatypes::DataType::Int64, false),
6970 arrow::datatypes::Field::new("v", arrow::datatypes::DataType::Int64, false),
6971 ]))
6972 }
6973
6974 /// A leaf stage: an in-memory scan, no shuffle read inside it.
6975 fn leaf_draft(rows: usize, key: &str, parts: usize) -> StageDraft {
6976 use datafusion::catalog::memory::MemorySourceConfig;
6977 use datafusion::datasource::source::DataSourceExec;
6978 let schema = scan_schema();
6979 let batches: Vec<Vec<arrow::record_batch::RecordBatch>> = vec![vec![
6980 arrow::record_batch::RecordBatch::try_new(
6981 Arc::clone(&schema),
6982 vec![
6983 Arc::new(arrow::array::Int64Array::from(
6984 (0..rows as i64).collect::<Vec<_>>(),
6985 )),
6986 Arc::new(arrow::array::Int64Array::from(
6987 (0..rows as i64).collect::<Vec<_>>(),
6988 )),
6989 ],
6990 )
6991 .expect("batch"),
6992 ]];
6993 let source = MemorySourceConfig::try_new(&batches, Arc::clone(&schema), None)
6994 .expect("memory source");
6995 StageDraft {
6996 plan: Arc::new(DataSourceExec::new(Arc::new(source))),
6997 shuffle: Some(StageShuffleOutput {
6998 key_columns: vec![String::from(key)],
6999 num_output_partitions: parts,
7000 }),
7001 subqueries: None,
7002 }
7003 }
7004
7005 fn reader(stage: usize) -> Arc<dyn ExecutionPlan> {
7006 Arc::new(ShuffleReadExec::new(stage, 4, 4, scan_schema(), None))
7007 }
7008
7009 /// Two identical leaf stages collapse into one, and the consumer that
7010 /// pointed at the removed stage is repointed at the survivor. This is
7011 /// q18: `lineitem[l_orderkey, l_quantity]` scanned twice, unfiltered.
7012 #[test]
7013 fn identical_leaf_stages_collapse_and_readers_are_repointed() {
7014 let mut drafts = vec![leaf_draft(4, "k", 4), leaf_draft(4, "k", 4)];
7015 // Root reads BOTH stages; after the collapse both reads must
7016 // resolve to the surviving stage 0.
7017 let mut root: Arc<dyn ExecutionPlan> =
7018 datafusion::physical_plan::union::UnionExec::try_new(vec![reader(0), reader(1)])
7019 .expect("union");
7020
7021 let removed = dedupe_identical_stages_unconditionally(&mut root, &mut drafts);
7022
7023 assert_eq!(
7024 removed, 1,
7025 "one of the two identical stages must be removed"
7026 );
7027 assert_eq!(drafts.len(), 1, "one stage must survive");
7028 let upstreams = collect_upstream_stage_indexes(&root);
7029 assert_eq!(
7030 upstreams,
7031 vec![0],
7032 "both readers must point at the surviving stage, got {upstreams:?}"
7033 );
7034 }
7035
7036 /// Stages that compute the same rows but partition them differently are
7037 /// NOT interchangeable — the consumer reads by partition index.
7038 #[test]
7039 fn different_shuffle_contracts_do_not_collapse() {
7040 let mut drafts = vec![leaf_draft(4, "k", 4), leaf_draft(4, "k", 8)];
7041 let mut root: Arc<dyn ExecutionPlan> =
7042 datafusion::physical_plan::union::UnionExec::try_new(vec![reader(0), reader(1)])
7043 .expect("union");
7044 assert_eq!(
7045 dedupe_identical_stages_unconditionally(&mut root, &mut drafts),
7046 0,
7047 "a different output-partition count is a different stage"
7048 );
7049 assert_eq!(drafts.len(), 2);
7050 }
7051
7052 /// Same rows, different hash key: also not interchangeable.
7053 #[test]
7054 fn different_shuffle_keys_do_not_collapse() {
7055 let mut drafts = vec![leaf_draft(4, "k", 4), leaf_draft(4, "v", 4)];
7056 let mut root: Arc<dyn ExecutionPlan> =
7057 datafusion::physical_plan::union::UnionExec::try_new(vec![reader(0), reader(1)])
7058 .expect("union");
7059 assert_eq!(
7060 dedupe_identical_stages_unconditionally(&mut root, &mut drafts),
7061 0,
7062 "a different partitioning key is a different stage"
7063 );
7064 }
7065
7066 /// Stages that differ in content must never be merged — the guard that
7067 /// keeps this from silently returning wrong answers.
7068 #[test]
7069 fn different_content_does_not_collapse() {
7070 let mut drafts = vec![leaf_draft(4, "k", 4), leaf_draft(9, "k", 4)];
7071 let mut root: Arc<dyn ExecutionPlan> =
7072 datafusion::physical_plan::union::UnionExec::try_new(vec![reader(0), reader(1)])
7073 .expect("union");
7074 assert_eq!(
7075 dedupe_identical_stages_unconditionally(&mut root, &mut drafts),
7076 0,
7077 "stages producing different rows must stay separate"
7078 );
7079 assert_eq!(drafts.len(), 2);
7080 }
7081
7082 /// Non-leaf stages are out of scope: a stage containing a shuffle read
7083 /// carries stage indexes of its own, and collapsing it could invalidate
7084 /// a reference inside it.
7085 #[test]
7086 fn non_leaf_stages_are_left_alone() {
7087 let mk = || StageDraft {
7088 plan: reader(7),
7089 shuffle: Some(StageShuffleOutput {
7090 key_columns: vec![String::from("k")],
7091 num_output_partitions: 4,
7092 }),
7093 subqueries: None,
7094 };
7095 let mut drafts = vec![mk(), mk()];
7096 let mut root: Arc<dyn ExecutionPlan> =
7097 datafusion::physical_plan::union::UnionExec::try_new(vec![reader(0), reader(1)])
7098 .expect("union");
7099 assert_eq!(
7100 dedupe_identical_stages_unconditionally(&mut root, &mut drafts),
7101 0,
7102 "stages that read a shuffle are not eligible for leaf reuse"
7103 );
7104 }
7105
7106 /// Three identical stages collapse to one, and every reader lands on it.
7107 /// This is q21's shape, where lineitem is scanned three times.
7108 #[test]
7109 fn three_identical_stages_collapse_to_one() {
7110 let mut drafts = vec![
7111 leaf_draft(4, "k", 4),
7112 leaf_draft(4, "k", 4),
7113 leaf_draft(4, "k", 4),
7114 ];
7115 let mut root: Arc<dyn ExecutionPlan> =
7116 datafusion::physical_plan::union::UnionExec::try_new(vec![
7117 reader(0),
7118 reader(1),
7119 reader(2),
7120 ])
7121 .expect("union");
7122 assert_eq!(
7123 dedupe_identical_stages_unconditionally(&mut root, &mut drafts),
7124 2
7125 );
7126 assert_eq!(drafts.len(), 1);
7127 assert_eq!(collect_upstream_stage_indexes(&root), vec![0]);
7128 }
7129
7130 /// Surviving stages keep their relative order and their readers are
7131 /// renumbered — an off-by-one here silently feeds a consumer the wrong
7132 /// stage's data, which is a wrong answer, not a slow query.
7133 #[test]
7134 fn survivor_indexes_are_compacted_correctly() {
7135 // 0: A, 1: B, 2: A(dup of 0), 3: C
7136 let mut drafts = vec![
7137 leaf_draft(4, "k", 4),
7138 leaf_draft(7, "k", 4),
7139 leaf_draft(4, "k", 4),
7140 leaf_draft(9, "k", 4),
7141 ];
7142 let mut root: Arc<dyn ExecutionPlan> =
7143 datafusion::physical_plan::union::UnionExec::try_new(vec![
7144 reader(1),
7145 reader(2),
7146 reader(3),
7147 ])
7148 .expect("union");
7149 assert_eq!(
7150 dedupe_identical_stages_unconditionally(&mut root, &mut drafts),
7151 1
7152 );
7153 assert_eq!(drafts.len(), 3, "A, B, C survive");
7154 // B was 1 -> 1, the dup of A was 2 -> 0, C was 3 -> 2.
7155 assert_eq!(collect_upstream_stage_indexes(&root), vec![0, 1, 2]);
7156 }
7157
7158 /// Reuse replaces two evaluations with one, which is only sound for a
7159 /// deterministic subtree.
7160 #[test]
7161 fn volatile_markers_block_reuse() {
7162 assert!(
7163 VOLATILE_MARKERS.contains(&"random("),
7164 "random() must block reuse"
7165 );
7166 assert!(VOLATILE_MARKERS.contains(&"now("), "now() must block reuse");
7167 assert!(
7168 VOLATILE_MARKERS.contains(&"uuid("),
7169 "uuid() must block reuse"
7170 );
7171 }
7172
7173 /// The flag gates it: default off, so a plan is untouched until the
7174 /// rule has been measured.
7175 #[test]
7176 fn reuse_is_off_by_default() {
7177 let mut drafts = vec![leaf_draft(4, "k", 4), leaf_draft(4, "k", 4)];
7178 let mut root: Arc<dyn ExecutionPlan> =
7179 datafusion::physical_plan::union::UnionExec::try_new(vec![reader(0), reader(1)])
7180 .expect("union");
7181 if std::env::var(STAGE_REUSE_ENV).is_err() {
7182 assert_eq!(
7183 dedupe_identical_stages(&mut root, &mut drafts),
7184 0,
7185 "stage reuse must be off unless {STAGE_REUSE_ENV} is set"
7186 );
7187 }
7188 }
7189 }
7190
7191 // ── Cross-stage runtime filters ────────────────────────────────────────
7192
7193 mod runtime_filters {
7194 use super::super::*;
7195 use datafusion::common::{JoinType, NullEquality};
7196 use datafusion::physical_expr::expressions::Column;
7197 use datafusion::physical_plan::joins::{HashJoinExec, PartitionMode};
7198
7199 /// A sort-merge join across two stages now yields a candidate, exactly
7200 /// as the equivalent hash join does.
7201 ///
7202 /// This is the shape TPC-H q21 reaches the cutter in — see
7203 /// `a_sort_merge_join_is_counted_as_unreadable_not_as_no_join` for the
7204 /// measurement that produced it. Which algorithm the planner picked is
7205 /// not a property of the filter: a bloom over the join keys drops probe
7206 /// rows that cannot match either way.
7207 #[test]
7208 fn a_sort_merge_join_across_stages_is_a_candidate() {
7209 use datafusion::physical_expr::{LexOrdering, PhysicalSortExpr};
7210 use datafusion::physical_plan::joins::SortMergeJoinExec;
7211
7212 let build = read(0, Some(1_000), "k", arrow::datatypes::DataType::Int64);
7213 let probe = read(1, Some(10_000_000), "k", arrow::datatypes::DataType::Int64);
7214 let on: Vec<(
7215 Arc<dyn datafusion::physical_expr::PhysicalExpr>,
7216 Arc<dyn datafusion::physical_expr::PhysicalExpr>,
7217 )> = vec![(Arc::new(Column::new("k", 0)), Arc::new(Column::new("k", 0)))];
7218 let sort_options = LexOrdering::new(vec![PhysicalSortExpr::new_default(Arc::new(
7219 Column::new("k", 0),
7220 ))])
7221 .expect("ordering")
7222 .iter()
7223 .map(|e| e.options)
7224 .collect();
7225 let smj = SortMergeJoinExec::try_new(
7226 build,
7227 probe,
7228 on,
7229 None,
7230 JoinType::Inner,
7231 sort_options,
7232 NullEquality::NullEqualsNothing,
7233 )
7234 .expect("sort-merge join over two shuffle reads");
7235
7236 let plan: Arc<dyn ExecutionPlan> = Arc::new(smj);
7237 let mut candidates = Vec::new();
7238 let mut rejects = RuntimeFilterRejects::default();
7239 collect_runtime_filter_candidates(&plan, &mut candidates, &mut rejects);
7240
7241 assert_eq!(
7242 rejects.joins, 1,
7243 "the sort-merge join must now be inspected like any other equijoin"
7244 );
7245 assert_eq!(
7246 rejects.joins_of_unsupported_kind, 0,
7247 "and must no longer be written off as unreadable"
7248 );
7249 assert_eq!(
7250 candidates.len(),
7251 1,
7252 "a selective cross-stage join is a candidate"
7253 );
7254 assert_eq!(candidates[0].build_stage, 0);
7255 assert_eq!(candidates[0].probe_stage, 1);
7256 }
7257
7258 /// A nested-loop join stays uncounted-as-inspected: it has no equijoin
7259 /// pairs at all, so there is no key to build a filter over. That is a
7260 /// category error, not a gap to close.
7261 #[test]
7262 fn a_nested_loop_join_remains_unreadable() {
7263 use datafusion::physical_plan::joins::NestedLoopJoinExec;
7264
7265 let left = read(0, Some(1_000), "k", arrow::datatypes::DataType::Int64);
7266 let right = read(1, Some(10_000_000), "k", arrow::datatypes::DataType::Int64);
7267 let nlj = NestedLoopJoinExec::try_new(left, right, None, &JoinType::Inner, None)
7268 .expect("nested-loop join");
7269
7270 let plan: Arc<dyn ExecutionPlan> = Arc::new(nlj);
7271 let mut candidates = Vec::new();
7272 let mut rejects = RuntimeFilterRejects::default();
7273 collect_runtime_filter_candidates(&plan, &mut candidates, &mut rejects);
7274
7275 assert_eq!(rejects.joins_of_unsupported_kind, 1);
7276 assert_eq!(rejects.joins, 0);
7277 assert!(candidates.is_empty());
7278 }
7279
7280 fn schema(name: &str, key: arrow::datatypes::DataType) -> SchemaRef {
7281 Arc::new(arrow::datatypes::Schema::new(vec![
7282 arrow::datatypes::Field::new(name, key, false),
7283 arrow::datatypes::Field::new("payload", arrow::datatypes::DataType::Utf8, false),
7284 ]))
7285 }
7286
7287 /// A stage draft whose plan is a bare shuffle read of `stage`, standing
7288 /// in for whatever subtree really produced it.
7289 fn draft(stage: usize, rows: Option<usize>, key: &str) -> StageDraft {
7290 StageDraft {
7291 plan: Arc::new(
7292 ShuffleReadExec::new(
7293 stage,
7294 4,
7295 4,
7296 schema(key, arrow::datatypes::DataType::Int64),
7297 None,
7298 )
7299 .with_upstream_estimate(rows, None),
7300 ),
7301 shuffle: Some(StageShuffleOutput {
7302 key_columns: vec![String::from(key)],
7303 num_output_partitions: 4,
7304 }),
7305 subqueries: None,
7306 }
7307 }
7308
7309 fn read(
7310 stage: usize,
7311 rows: Option<usize>,
7312 key: &str,
7313 key_type: arrow::datatypes::DataType,
7314 ) -> Arc<dyn ExecutionPlan> {
7315 Arc::new(
7316 ShuffleReadExec::new(stage, 4, 4, schema(key, key_type), None)
7317 .with_upstream_estimate(rows, None),
7318 )
7319 }
7320
7321 fn join_of(
7322 build: Arc<dyn ExecutionPlan>,
7323 probe: Arc<dyn ExecutionPlan>,
7324 join_type: JoinType,
7325 ) -> datafusion::error::Result<Arc<dyn ExecutionPlan>> {
7326 Ok(Arc::new(HashJoinExec::try_new(
7327 build,
7328 probe,
7329 vec![(
7330 Arc::new(Column::new("bkey", 0)),
7331 Arc::new(Column::new("pkey", 0)),
7332 )],
7333 None,
7334 &join_type,
7335 None,
7336 PartitionMode::Partitioned,
7337 NullEquality::NullEqualsNothing,
7338 false,
7339 )?))
7340 }
7341
7342 /// The q10 shape: a small build stage, a probe stage 100x bigger, joined
7343 /// across a stage boundary.
7344 fn q10_shaped() -> datafusion::error::Result<(Arc<dyn ExecutionPlan>, Vec<StageDraft>)> {
7345 let root = join_of(
7346 read(
7347 0,
7348 Some(1_000_000),
7349 "bkey",
7350 arrow::datatypes::DataType::Int64,
7351 ),
7352 read(
7353 1,
7354 Some(100_000_000),
7355 "pkey",
7356 arrow::datatypes::DataType::Int64,
7357 ),
7358 JoinType::Inner,
7359 )?;
7360 Ok((
7361 root,
7362 vec![
7363 draft(0, Some(1_000_000), "bkey"),
7364 draft(1, Some(100_000_000), "pkey"),
7365 ],
7366 ))
7367 }
7368
7369 #[test]
7370 fn a_filter_stage_is_injected_and_the_probe_stage_waits_on_it() {
7371 let (root, mut drafts) = q10_shaped().expect("plan");
7372 assert_eq!(
7373 inject_runtime_filters_unconditionally(&root, &mut drafts),
7374 1
7375 );
7376 assert_eq!(drafts.len(), 3, "one filter stage must have been appended");
7377
7378 let filter = &drafts[2];
7379 let shuffle = filter.shuffle.as_ref().expect("filter stage shuffles");
7380 assert!(
7381 shuffle.key_columns.is_empty() && shuffle.num_output_partitions == 1,
7382 "the filter must gather to ONE keyless partition; any other shape means \
7383 every probe task fetches N partials instead of one filter"
7384 );
7385 assert_eq!(
7386 filter.plan.output_partitioning().partition_count(),
7387 1,
7388 "the filter stage must be a single task, or the broadcast it feeds \
7389 multiplies by the task count"
7390 );
7391
7392 // The inverted edge: the PROBE stage now depends on the filter stage.
7393 assert!(
7394 collect_upstream_stage_indexes(&drafts[1].plan).contains(&2),
7395 "the probe stage must declare the filter stage upstream, or the \
7396 scheduler will run it before the filter exists"
7397 );
7398 }
7399
7400 /// The probe stage's output schema is what its shuffle key columns are
7401 /// resolved against by name. Changing it would misroute every row.
7402 #[test]
7403 fn the_probe_stages_output_schema_is_untouched() {
7404 let (root, mut drafts) = q10_shaped().expect("plan");
7405 let before = drafts[1].plan.schema();
7406 inject_runtime_filters_unconditionally(&root, &mut drafts);
7407 assert_eq!(
7408 before.fields(),
7409 drafts[1].plan.schema().fields(),
7410 "wrapping the probe stage must not change its columns"
7411 );
7412 }
7413
7414 #[test]
7415 fn the_filter_stage_emits_one_binary_column() {
7416 let (root, mut drafts) = q10_shaped().expect("plan");
7417 inject_runtime_filters_unconditionally(&root, &mut drafts);
7418 assert_eq!(
7419 drafts[2].plan.schema().fields().len(),
7420 1,
7421 "a filter stage carries only the serialized bloom"
7422 );
7423 }
7424
7425 #[test]
7426 fn a_non_inner_join_gets_no_filter() {
7427 for join_type in [
7428 JoinType::Full,
7429 JoinType::Right,
7430 JoinType::RightAnti,
7431 JoinType::LeftAnti,
7432 ] {
7433 let root = join_of(
7434 read(
7435 0,
7436 Some(1_000_000),
7437 "bkey",
7438 arrow::datatypes::DataType::Int64,
7439 ),
7440 read(
7441 1,
7442 Some(100_000_000),
7443 "pkey",
7444 arrow::datatypes::DataType::Int64,
7445 ),
7446 join_type,
7447 )
7448 .expect("join");
7449 let mut drafts = vec![
7450 draft(0, Some(1_000_000), "bkey"),
7451 draft(1, Some(100_000_000), "pkey"),
7452 ];
7453 assert_eq!(
7454 inject_runtime_filters_unconditionally(&root, &mut drafts),
7455 0,
7456 "{join_type:?} can preserve unmatched PROBE rows; dropping them is a \
7457 wrong answer, not a slow one"
7458 );
7459 }
7460 }
7461
7462 #[test]
7463 fn a_join_inside_one_stage_gets_no_filter() {
7464 let root = join_of(
7465 read(
7466 0,
7467 Some(1_000_000),
7468 "bkey",
7469 arrow::datatypes::DataType::Int64,
7470 ),
7471 read(
7472 0,
7473 Some(100_000_000),
7474 "pkey",
7475 arrow::datatypes::DataType::Int64,
7476 ),
7477 JoinType::Inner,
7478 )
7479 .expect("join");
7480 let mut drafts = vec![draft(0, Some(1_000_000), "bkey")];
7481 assert_eq!(
7482 inject_runtime_filters_unconditionally(&root, &mut drafts),
7483 0,
7484 "a same-stage join already gets DataFusion's own dynamic filter"
7485 );
7486 }
7487
7488 #[test]
7489 fn an_absent_estimate_refuses_rather_than_guesses() {
7490 for (build_rows, probe_rows) in [(None, Some(100_000_000)), (Some(1_000_000), None)] {
7491 let root = join_of(
7492 read(0, build_rows, "bkey", arrow::datatypes::DataType::Int64),
7493 read(1, probe_rows, "pkey", arrow::datatypes::DataType::Int64),
7494 JoinType::Inner,
7495 )
7496 .expect("join");
7497 let mut drafts = vec![draft(0, build_rows, "bkey"), draft(1, probe_rows, "pkey")];
7498 assert_eq!(
7499 inject_runtime_filters_unconditionally(&root, &mut drafts),
7500 0,
7501 "Precision::Absent means 'no idea', never 'small' — guessing here is \
7502 the SpillableJoinSelection lesson"
7503 );
7504 }
7505 }
7506
7507 #[test]
7508 fn a_probe_barely_bigger_than_the_build_is_not_worth_a_stage() {
7509 let root = join_of(
7510 read(
7511 0,
7512 Some(1_000_000),
7513 "bkey",
7514 arrow::datatypes::DataType::Int64,
7515 ),
7516 read(
7517 1,
7518 Some(2_000_000),
7519 "pkey",
7520 arrow::datatypes::DataType::Int64,
7521 ),
7522 JoinType::Inner,
7523 )
7524 .expect("join");
7525 let mut drafts = vec![
7526 draft(0, Some(1_000_000), "bkey"),
7527 draft(1, Some(2_000_000), "pkey"),
7528 ];
7529 assert_eq!(
7530 inject_runtime_filters_unconditionally(&root, &mut drafts),
7531 0,
7532 "2x is not enough to repay an extra scan plus a broadcast"
7533 );
7534 }
7535
7536 #[test]
7537 fn an_oversized_filter_is_refused() {
7538 // Enough distinct keys that the planned filter hits the 16 MB cap,
7539 // where the false-positive rate has degraded towards "matches
7540 // everything" and the broadcast costs more than the scan it saves.
7541 let huge = 5_000_000_000usize;
7542 let root = join_of(
7543 read(0, Some(huge), "bkey", arrow::datatypes::DataType::Int64),
7544 read(1, Some(huge * 8), "pkey", arrow::datatypes::DataType::Int64),
7545 JoinType::Inner,
7546 )
7547 .expect("join");
7548 let mut drafts = vec![
7549 draft(0, Some(huge), "bkey"),
7550 draft(1, Some(huge * 8), "pkey"),
7551 ];
7552 assert_eq!(
7553 inject_runtime_filters_unconditionally(&root, &mut drafts),
7554 0
7555 );
7556 }
7557
7558 #[test]
7559 fn an_unsupported_key_type_is_refused_not_guessed() {
7560 let root = join_of(
7561 read(
7562 0,
7563 Some(1_000_000),
7564 "bkey",
7565 arrow::datatypes::DataType::Float64,
7566 ),
7567 read(
7568 1,
7569 Some(100_000_000),
7570 "pkey",
7571 arrow::datatypes::DataType::Float64,
7572 ),
7573 JoinType::Inner,
7574 )
7575 .expect("join");
7576 let mut drafts = vec![
7577 draft(0, Some(1_000_000), "bkey"),
7578 draft(1, Some(100_000_000), "pkey"),
7579 ];
7580 assert_eq!(
7581 inject_runtime_filters_unconditionally(&root, &mut drafts),
7582 0,
7583 "-0.0 == 0.0 compares equal but encodes differently, so a float bloom \
7584 would produce false negatives"
7585 );
7586 }
7587
7588 /// Guard 4: the new edge must never close a loop. If the build stage
7589 /// already reads the probe stage, `probe -> filter -> ... -> probe` is a
7590 /// cycle, and the scheduler's answer to a cyclic job is to reject it.
7591 #[test]
7592 fn a_filter_that_would_close_a_cycle_is_not_injected() {
7593 let root = join_of(
7594 read(
7595 0,
7596 Some(1_000_000),
7597 "bkey",
7598 arrow::datatypes::DataType::Int64,
7599 ),
7600 read(
7601 1,
7602 Some(100_000_000),
7603 "pkey",
7604 arrow::datatypes::DataType::Int64,
7605 ),
7606 JoinType::Inner,
7607 )
7608 .expect("join");
7609 let mut drafts = vec![
7610 draft(0, Some(1_000_000), "bkey"),
7611 draft(1, Some(100_000_000), "pkey"),
7612 ];
7613 // Make stage 0 (build) read stage 1 (probe).
7614 drafts[0].plan = Arc::new(
7615 ShuffleReadExec::new(
7616 1,
7617 4,
7618 4,
7619 schema("bkey", arrow::datatypes::DataType::Int64),
7620 None,
7621 )
7622 .with_upstream_estimate(Some(1_000_000), None),
7623 );
7624 assert!(
7625 stage_depends_on(&drafts, 0, 1),
7626 "precondition: build reads probe"
7627 );
7628 assert_eq!(
7629 inject_runtime_filters_unconditionally(&root, &mut drafts),
7630 0
7631 );
7632 }
7633
7634 /// With the feature off, the pass must still change nothing *and* still
7635 /// be able to count — the diagnostic is what tells an operator whether
7636 /// turning the flag on is worth trying.
7637 ///
7638 /// Before the dry run existed, `inject_runtime_filters` returned before
7639 /// computing its rejection breakdown, so every run since the counters
7640 /// landed produced zero `runtime-filter: pass complete` lines and the
7641 /// question stayed unanswerable without first enabling the thing being
7642 /// evaluated.
7643 #[test]
7644 fn the_dry_run_reports_without_rewriting_when_the_flag_is_off() {
7645 let root: Arc<dyn ExecutionPlan> = Arc::new(ShuffleReadExec::new(
7646 0,
7647 1,
7648 1,
7649 schema("k", arrow::datatypes::DataType::Int64),
7650 None,
7651 ));
7652 let mut drafts = vec![draft(0, Some(1), "a"), draft(1, Some(1), "b")];
7653 let before = drafts.len();
7654 // `enabled()` reads the environment, which this test does not touch:
7655 // the default is off, which is the case under audit.
7656 assert!(
7657 !crate::runtime_filter_exec::enabled(),
7658 "precondition: the feature ships dark"
7659 );
7660 assert_eq!(
7661 inject_runtime_filters(&root, &mut drafts),
7662 0,
7663 "a disabled pass must inject nothing"
7664 );
7665 assert_eq!(drafts.len(), before, "a disabled pass must not add a stage");
7666 }
7667
7668 #[test]
7669 fn stage_dependency_reachability_is_transitive_and_terminates_on_cycles() {
7670 let mut drafts = vec![
7671 draft(0, Some(1), "a"),
7672 draft(1, Some(1), "b"),
7673 draft(2, Some(1), "c"),
7674 ];
7675 // 2 -> 1 -> 0
7676 drafts[1].plan = Arc::new(ShuffleReadExec::new(
7677 0,
7678 1,
7679 1,
7680 schema("b", arrow::datatypes::DataType::Int64),
7681 None,
7682 ));
7683 drafts[2].plan = Arc::new(ShuffleReadExec::new(
7684 1,
7685 1,
7686 1,
7687 schema("c", arrow::datatypes::DataType::Int64),
7688 None,
7689 ));
7690 assert!(
7691 stage_depends_on(&drafts, 2, 0),
7692 "reachability must be transitive"
7693 );
7694 assert!(!stage_depends_on(&drafts, 0, 2), "and directional");
7695 }
7696
7697 /// A stage severed from a `ScalarSubqueryExec` is parameterised by a
7698 /// subquery result; cloning its subtree without the wrapper yields a
7699 /// fragment that cannot decode.
7700 #[test]
7701 fn a_subquery_parameterised_stage_is_never_cloned_into_a_filter() {
7702 let (root, mut drafts) = q10_shaped().expect("plan");
7703 drafts[0].subqueries = Some(StageSubqueryContext {
7704 links: Vec::new(),
7705 results: Default::default(),
7706 });
7707 assert_eq!(
7708 inject_runtime_filters_unconditionally(&root, &mut drafts),
7709 0
7710 );
7711 }
7712
7713 #[test]
7714 fn the_feature_is_off_unless_the_flag_says_otherwise() {
7715 let (root, mut drafts) = q10_shaped().expect("plan");
7716 assert_eq!(
7717 inject_runtime_filters(&root, &mut drafts),
7718 0,
7719 "the flag-checking entry point must decline by default: this rule \
7720 rewrites the stage DAG and ships dark until a clean 22-query sweep"
7721 );
7722 assert_eq!(drafts.len(), 2, "and it must not have touched the drafts");
7723 }
7724
7725 /// Every node in an injected plan must survive the proto round trip.
7726 /// A node the codec cannot encode does not fail loudly — the coordinator
7727 /// silently runs the whole query as a SINGLE TASK.
7728 #[test]
7729 fn the_injected_stages_round_trip_through_the_codec() {
7730 let (root, mut drafts) = q10_shaped().expect("plan");
7731 assert_eq!(
7732 inject_runtime_filters_unconditionally(&root, &mut drafts),
7733 1
7734 );
7735
7736 let codec = KrishivPhysicalCodec::coordinator();
7737 let session = fragment_decode_session_context();
7738 let ctx = session.task_ctx();
7739 for (index, draft) in drafts.iter().enumerate() {
7740 let bytes = encode_dfplan_bytes(Arc::clone(&draft.plan), &codec)
7741 .unwrap_or_else(|e| panic!("stage {index} did not encode: {e}"));
7742 verify_dfplan_roundtrip(&bytes, &codec, &ctx, Some(&draft.plan))
7743 .unwrap_or_else(|e| panic!("stage {index} did not decode: {e}"));
7744 }
7745 }
7746 }
7747}
7748
7749#[cfg(test)]
7750#[allow(clippy::unwrap_used, clippy::expect_used)]
7751mod registration_parity_tests {
7752 use super::*;
7753
7754 /// Write one small parquet file and return the path to register.
7755 async fn parquet_at(dir: &std::path::Path) -> String {
7756 let path = dir.join("t.parquet");
7757 let path = path.to_str().expect("temp path is utf-8").to_owned();
7758 let ctx = SessionContext::new();
7759 ctx.sql(&format!(
7760 "COPY (SELECT * FROM (VALUES (1, 'a'), (2, 'b'), (3, 'c')) t(k, v)) \
7761 TO '{path}' STORED AS PARQUET"
7762 ))
7763 .await
7764 .unwrap()
7765 .collect()
7766 .await
7767 .unwrap();
7768 path
7769 }
7770
7771 /// Register the same file both ways and hand back the two providers.
7772 async fn both_ways(
7773 path: &str,
7774 ) -> (
7775 Arc<dyn datafusion::datasource::TableProvider>,
7776 Arc<dyn datafusion::datasource::TableProvider>,
7777 ) {
7778 let ctx = planning_session_context(4);
7779 register_parquet_table(&ctx, &ParquetTableSpec::new("plain", path))
7780 .await
7781 .unwrap();
7782 register_parquet_table(
7783 &ctx,
7784 &ParquetTableSpec::new("keyed", path).with_primary_key(["k"]),
7785 )
7786 .await
7787 .unwrap();
7788 (
7789 ctx.table_provider("plain").await.unwrap(),
7790 ctx.table_provider("keyed").await.unwrap(),
7791 )
7792 }
7793
7794 fn listing_options(
7795 provider: &Arc<dyn datafusion::datasource::TableProvider>,
7796 ) -> datafusion::datasource::listing::ListingOptions {
7797 // `TableProvider: Any` — upcast to downcast, as elsewhere in this file
7798 // (DF 54 has no `as_any` on the trait).
7799 let any = provider.as_ref() as &dyn std::any::Any;
7800 any.downcast_ref::<datafusion::datasource::listing::ListingTable>()
7801 .expect("parquet registration produces a ListingTable")
7802 .options()
7803 .clone()
7804 }
7805
7806 /// An object-store directory must reach `ListingTableUrl` as a prefix.
7807 ///
7808 /// `ListingTableUrl::parse` decides file-vs-directory by statting, which an
7809 /// object store cannot answer, so `s3://b/sf100/lineitem` was read as a
7810 /// file and rejected for not ending in `.parquet` — a message that blames
7811 /// the extension for a path that is simply a directory. The same
7812 /// registration worked locally and failed remotely.
7813 #[test]
7814 fn an_extensionless_object_store_path_is_treated_as_a_directory() {
7815 assert_eq!(
7816 super::directory_aware_url("s3://b/sf100/lineitem"),
7817 "s3://b/sf100/lineitem/"
7818 );
7819 // Already a prefix: unchanged, no doubled slash.
7820 assert_eq!(
7821 super::directory_aware_url("s3://b/sf100/lineitem/"),
7822 "s3://b/sf100/lineitem/"
7823 );
7824 // A named file keeps file semantics.
7825 assert_eq!(
7826 super::directory_aware_url("s3://b/sf100/nation.parquet"),
7827 "s3://b/sf100/nation.parquet"
7828 );
7829 // Local paths are untouched: statting them is better information than
7830 // any guess this function could make.
7831 assert_eq!(
7832 super::directory_aware_url("/data/sf100/lineitem"),
7833 "/data/sf100/lineitem"
7834 );
7835 assert_eq!(super::directory_aware_url("relative/dir"), "relative/dir");
7836 }
7837
7838 /// The invariant the two branches of [`register_parquet_table`] exist under:
7839 /// declaring a key changes the *constraints* and nothing else.
7840 ///
7841 /// It was violated silently. The keyed branch built its own
7842 /// `ListingOptions::new(..)`, whose documented defaults are `collect_stat:
7843 /// false` and `target_partitions: 1` — while `register_parquet` routes
7844 /// through `ReadOptions::to_listing_options`, which ends in
7845 /// `.with_session_config_options(config)` and takes both from the session.
7846 ///
7847 /// So every table with a declared primary key had **statistics collection
7848 /// off**. On the SF100 cluster that made `SpillableJoinSelection` report
7849 /// `unmeasurable == hash_joins` in all 414 passes and convert zero joins,
7850 /// leaving q21's oversized build side with nothing to catch it.
7851 ///
7852 /// Comparing the whole `ListingOptions` rather than the two fields that
7853 /// were wrong is deliberate: the next field DataFusion adds to
7854 /// `with_session_config_options` fails this test instead of quietly
7855 /// re-opening the same hole.
7856 #[tokio::test]
7857 async fn declaring_a_primary_key_changes_only_the_constraints() {
7858 let dir = tempfile::tempdir().unwrap();
7859 let path = parquet_at(dir.path()).await;
7860 let (plain, keyed) = both_ways(&path).await;
7861
7862 let (plain_options, keyed_options) = (listing_options(&plain), listing_options(&keyed));
7863 assert_eq!(
7864 format!("{plain_options:?}"),
7865 format!("{keyed_options:?}"),
7866 "a declared key must not change how the table is read"
7867 );
7868 assert!(
7869 keyed_options.collect_stat,
7870 "statistics collection must stay on: every size-based rule goes \
7871 blind without it"
7872 );
7873
7874 // The one difference that is supposed to exist.
7875 assert!(plain.constraints().is_none_or(|c| c.is_empty()));
7876 assert!(
7877 keyed.constraints().is_some_and(|c| !c.is_empty()),
7878 "the declared key must reach the optimizer as a constraint"
7879 );
7880 }
7881
7882 /// The behavioural half: a keyed table must still report row counts.
7883 ///
7884 /// The options comparison above pins the mechanism; this pins the outcome
7885 /// the mechanism exists for, so the test still fails if a future change
7886 /// keeps `collect_stat` set but loses statistics some other way.
7887 #[tokio::test]
7888 async fn a_keyed_table_still_reports_row_counts() {
7889 use datafusion::common::stats::Precision;
7890 let dir = tempfile::tempdir().unwrap();
7891 let path = parquet_at(dir.path()).await;
7892 let (_, keyed) = both_ways(&path).await;
7893
7894 let ctx = planning_session_context(4);
7895 ctx.register_table("keyed", Arc::clone(&keyed)).unwrap();
7896 let stats = ctx
7897 .sql("SELECT k, v FROM keyed")
7898 .await
7899 .unwrap()
7900 .create_physical_plan()
7901 .await
7902 .unwrap()
7903 .partition_statistics(None)
7904 .unwrap();
7905 assert!(
7906 matches!(stats.num_rows, Precision::Exact(3) | Precision::Inexact(3)),
7907 "expected a row count for a keyed table, got {:?} — this is the \
7908 shape that made every join unmeasurable at SF100",
7909 stats.num_rows
7910 );
7911 }
7912}