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