Skip to main content

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}