Skip to main content

krishiv_plan/
lib.rs

1#![forbid(unsafe_code)]
2
3//! Logical and physical plan types for Krishiv.
4//!
5//! R1 bootstrap keeps these types deliberately small. Later R1 work will bridge
6//! them to DataFusion logical and physical plans without exposing DataFusion as
7//! the long-term public Krishiv API.
8
9use std::fmt;
10
11pub mod cep;
12pub mod expression;
13pub mod governance;
14mod graph;
15mod lowering;
16pub mod optimizer;
17pub mod task_fragment;
18pub mod udf;
19pub mod window;
20pub use expression::{
21    AggregateFunction as ExprAggregateFunction, BinaryOperator as ExprBinaryOperator,
22    EXPRESSION_FORMAT_VERSION, Expr, ExprDataType, ExprField, IntervalUnit, NullOrdering,
23    ScalarValue, SortDirection, TimeUnit,
24};
25pub use graph::lower_to_physical;
26pub use task_fragment::{
27    TASK_FRAGMENT_VERSION, TypedTaskFragment, encode_typed_task_fragment,
28    execution_kind_from_fragment, task_body_for_profile, validate_job_fragments,
29};
30
31/// Errors returned by plan encoding, decoding, and validation operations.
32#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
33pub enum PlanError {
34    /// Failed to parse a plan fragment or expression.
35    #[error("plan parse error: {0}")]
36    Parse(String),
37    /// Failed to encode a plan fragment to wire format.
38    #[error("plan encode error: {0}")]
39    Encode(String),
40    /// Plan validation failed (e.g. missing required fields).
41    #[error("plan validation error: {0}")]
42    Validation(String),
43}
44
45/// Data type for a plan schema field.
46#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
47pub enum FieldType {
48    Boolean,
49    Int32,
50    Int64,
51    Float64,
52    Utf8,
53    Binary,
54    Timestamp,
55    /// Semi-structured JSON-like data (Spark VARIANT equivalent).
56    Variant,
57}
58
59/// One field in a plan schema.
60#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
61pub struct SchemaField {
62    name: String,
63    field_type: FieldType,
64    nullable: bool,
65}
66
67impl SchemaField {
68    /// Create a non-nullable schema field.
69    pub fn new(name: impl Into<String>, field_type: FieldType) -> Self {
70        Self {
71            name: name.into(),
72            field_type,
73            nullable: false,
74        }
75    }
76
77    /// Set nullability.
78    #[must_use]
79    pub fn with_nullable(mut self, nullable: bool) -> Self {
80        self.nullable = nullable;
81        self
82    }
83
84    /// Field name.
85    pub fn name(&self) -> &str {
86        &self.name
87    }
88
89    /// Field type.
90    pub fn field_type(&self) -> &FieldType {
91        &self.field_type
92    }
93
94    /// Whether this field is nullable.
95    pub fn nullable(&self) -> bool {
96        self.nullable
97    }
98}
99
100/// Output schema for a plan node.
101#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
102pub struct PlanSchema {
103    fields: Vec<SchemaField>,
104}
105
106impl PlanSchema {
107    /// Create a schema from a list of fields.
108    pub fn new(fields: Vec<SchemaField>) -> Self {
109        Self { fields }
110    }
111
112    /// Schema fields.
113    pub fn fields(&self) -> &[SchemaField] {
114        &self.fields
115    }
116
117    /// Whether this schema has no fields.
118    pub fn is_empty(&self) -> bool {
119        self.fields.is_empty()
120    }
121}
122
123/// Join variant used in `NodeOp::Join`.
124///
125/// T2 (Spark parity): `LeftSemi`/`RightSemi`/`LeftAnti`/`RightAnti` were
126/// previously collapsed to `Inner` when the public `krishiv_api::JoinType`
127/// was lowered to the plan layer. They are now first-class variants.
128#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
129pub enum JoinType {
130    Inner,
131    Left,
132    Right,
133    Full,
134    /// Left semi-join — rows from the left input that have at least one match
135    /// in the right input.
136    Semi,
137    /// Anti-join — rows from the left input that have no match in the right.
138    /// (Originally symmetric; preserved for back-compat.)
139    Anti,
140    /// Left semi-join variant (Spark parity). Equivalent to `Semi` for the
141    /// left input; distinguished to match the public API and DataFusion's
142    /// 7-variant join enum.
143    LeftSemi,
144    /// Right semi-join variant (Spark parity). Mirror of `LeftSemi`.
145    RightSemi,
146    /// Left anti-join variant (Spark parity).
147    LeftAnti,
148    /// Right anti-join variant (Spark parity).
149    RightAnti,
150    /// Cartesian product — no join predicate (E2.3).
151    Cross,
152    /// Nested-loop join; used for non-equi predicates (E2.3).
153    NestedLoop,
154}
155
156/// Typed operator classification for a plan node.
157#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
158pub enum NodeOp {
159    /// Table or file scan, with optional pushed-down filter predicates.
160    Scan { table: String, filters: Vec<String> },
161    /// Row filter with a predicate expression string.
162    Filter { predicate: String },
163    /// Column projection.
164    Project { columns: Vec<String> },
165    /// Aggregation with optional group keys.
166    Aggregate { group_keys: Vec<String> },
167    /// Join of two inputs.
168    Join { join_type: JoinType },
169    /// Data exchange / shuffle between partitions.
170    Exchange { partitioning: Partitioning },
171    /// Output sink.
172    Sink { format: String },
173    /// AQE coalesce: merge many small partitions into fewer larger ones.
174    ///
175    /// Inserted by the AQE `CoalesceRule` when runtime statistics show that
176    /// partition count can be reduced to improve downstream task efficiency.
177    CoalescePartitions {
178        /// Number of output partitions after coalescing.
179        target_partitions: usize,
180    },
181    /// Create a live table backed by a streaming query.
182    CreateLiveTable { name: String, query: String },
183    /// Refresh materialized state for a live table.
184    RefreshLiveTable { name: String },
185    /// Drop a live table.
186    DropLiveTable { name: String },
187    /// Key stream by column before windowing.
188    KeyBy { key_column: String },
189    /// Event-time watermark on a keyed stream.
190    Watermark {
191        event_time_column: String,
192        lag_ms: u64,
193    },
194    /// Windowed streaming operator (tumbling, sliding, or session window).
195    Window {
196        spec: Box<window::WindowExecutionSpec>,
197    },
198    /// Bounded or unbounded stream source.
199    StreamSource { source_id: String, bounded: bool },
200    /// Operator state TTL for streaming nodes.
201    StateTtl { ttl_ms: u64 },
202    /// E2.2: Globally-sorted output produced by a three-stage pipeline:
203    /// local sort → range-partition shuffle → merge-sort.  The executor
204    /// treats this as a batch pipeline that produces a single sorted partition.
205    GlobalSort {
206        /// Ordered list of `(column, ascending)` sort keys.
207        keys: Vec<(String, bool)>,
208    },
209    /// E2.2 / E2.4: Sort-merge join using pre-sorted, range-partitioned inputs.
210    SortMergeJoin {
211        join_type: JoinType,
212        /// Column names used as equi-join keys (must match sort order).
213        left_keys: Vec<String>,
214        right_keys: Vec<String>,
215    },
216    /// E3.2: Time-windowed join: buffer both streams in the window interval,
217    /// emit matched pairs when the window closes.
218    WindowJoin {
219        join_type: JoinType,
220        /// Column names used as equi-join keys.
221        left_keys: Vec<String>,
222        right_keys: Vec<String>,
223        /// Event-time column used to determine window membership.
224        time_column: String,
225        /// Window duration in milliseconds.
226        window_ms: u64,
227    },
228    /// E5.2: Expand an array-typed column into one row per element.
229    ///
230    /// Equivalent to `UNNEST(array_column)` in SQL or a LATERAL join over an
231    /// array.  The `output_column` name is used for the expanded element.
232    /// If `with_ordinality` is `true` an extra `ordinality` column (`u64`) is
233    /// appended with the 1-based position of each element.
234    Unnest {
235        array_column: String,
236        output_column: String,
237        with_ordinality: bool,
238    },
239    /// CEP sequential pattern match on a keyed stream.
240    ///
241    /// `stage_column` names the column whose string value identifies which
242    /// pattern stage each row belongs to.  The executor groups rows by
243    /// `key_column`, routes each row to `PartitionedCepMatcher::process_event`
244    /// with the row's stage name, and emits concatenated match batches.
245    Cep {
246        key_column: String,
247        event_time_column: String,
248        stage_column: String,
249    },
250    /// AQE skew mitigation: split a hot partition into N sub-partitions by
251    /// appending a `salt` column to the join key. The build side is
252    /// replicated `factor` times so that each salted sub-partition of the
253    /// probe side joins against the full build side in parallel. The
254    /// `unsalt` node strips the salt column from the post-join output.
255    ///
256    /// Equivalent to Spark AQE's `OptimizeSkewedJoin` rule.
257    SkewJoin {
258        /// The join key columns on both sides (must match).
259        keys: Vec<String>,
260        /// Number of sub-partitions the hot side is split into.
261        factor: u32,
262        /// Original join type — kept so the executor can dispatch correctly.
263        join_type: JoinType,
264    },
265    /// Operator not covered by the above variants.
266    Other { description: String },
267}
268
269/// Whether a plan represents bounded batch work, unbounded streaming, or IVM.
270#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
271pub enum ExecutionKind {
272    /// Bounded work that eventually completes.
273    Batch,
274    /// Unbounded work that runs until cancelled.
275    Streaming,
276    /// Tick-driven incremental view maintenance (DeltaBatch mode).
277    ///
278    /// Plans of this kind are managed through the IVM HTTP API on the
279    /// coordinator. Each tick consumes source deltas, runs SQL views, and
280    /// publishes incremental `DeltaBatch` outputs.
281    DeltaBatch,
282}
283
284impl fmt::Display for ExecutionKind {
285    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
286        match self {
287            Self::Batch => f.write_str("batch"),
288            Self::Streaming => f.write_str("streaming"),
289            Self::DeltaBatch => f.write_str("delta-batch"),
290        }
291    }
292}
293
294/// Partitioning strategy for a plan node's output.
295#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
296pub enum Partitioning {
297    /// No partitioning — data is not distributed across partitions.
298    Unpartitioned,
299    /// Hash-based partitioning on named key columns.
300    Hash {
301        /// Column names used as hash keys.
302        keys: Vec<String>,
303        /// Number of output buckets.
304        buckets: u32,
305    },
306    /// Round-robin distribution across N buckets.
307    RoundRobin {
308        /// Number of output buckets.
309        buckets: u32,
310    },
311    /// Broadcast — replicate to all downstream partitions.
312    Broadcast,
313    /// E2.4: Range-based partitioning using sampled sort key boundaries.
314    ///
315    /// Rows whose sort key falls in `[boundaries[i-1], boundaries[i])` go to
316    /// partition `i`.  Used by `GlobalSort` / `SortMergeJoin` pipelines.
317    Range {
318        /// Sort key columns (each `(column, ascending)`).
319        keys: Vec<(String, bool)>,
320        /// Sampled boundary values (serialised as JSON strings).
321        /// There are `buckets - 1` boundaries for `buckets` output partitions.
322        boundaries: Vec<String>,
323        /// Number of output partitions.
324        buckets: u32,
325    },
326}
327
328impl fmt::Display for Partitioning {
329    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
330        match self {
331            Self::Unpartitioned => f.write_str("unpartitioned"),
332            Self::Hash { keys, buckets } => {
333                write!(f, "hash({}, buckets={})", keys.join(", "), buckets)
334            }
335            Self::RoundRobin { buckets } => write!(f, "round-robin(buckets={})", buckets),
336            Self::Broadcast => f.write_str("broadcast"),
337            Self::Range { keys, buckets, .. } => {
338                let key_str = keys
339                    .iter()
340                    .map(|(c, asc)| format!("{} {}", c, if *asc { "ASC" } else { "DESC" }))
341                    .collect::<Vec<_>>()
342                    .join(", ");
343                write!(f, "range({key_str}, buckets={buckets})")
344            }
345        }
346    }
347}
348
349/// A small bootstrap plan node used by both logical and physical plans.
350#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
351pub struct PlanNode {
352    id: String,
353    label: String,
354    kind: ExecutionKind,
355    inputs: Vec<String>,
356    /// Output partitioning strategy for this node.
357    partitioning: Partitioning,
358    /// Whether this node is eligible for broadcast join optimisation.
359    broadcast_eligible: bool,
360    /// Estimated output row count, if known.
361    estimated_rows: Option<u64>,
362    /// Typed operator classification.
363    op: Option<NodeOp>,
364    /// Output schema produced by this node.
365    output_schema: PlanSchema,
366}
367
368impl PlanNode {
369    /// Create a node with no inputs and default annotations.
370    pub fn new(id: impl Into<String>, label: impl Into<String>, kind: ExecutionKind) -> Self {
371        Self {
372            id: id.into(),
373            label: label.into(),
374            kind,
375            inputs: Vec::new(),
376            partitioning: Partitioning::Unpartitioned,
377            broadcast_eligible: false,
378            estimated_rows: None,
379            op: None,
380            output_schema: PlanSchema::default(),
381        }
382    }
383
384    /// Attach input node ids to this node.
385    #[must_use]
386    pub fn with_inputs(mut self, inputs: impl IntoIterator<Item = impl Into<String>>) -> Self {
387        self.inputs = inputs.into_iter().map(Into::into).collect();
388        self
389    }
390
391    /// Replace the human-readable node label.
392    #[must_use]
393    pub fn with_label(mut self, label: impl Into<String>) -> Self {
394        self.label = label.into();
395        self
396    }
397
398    /// Set the output partitioning strategy for this node.
399    #[must_use]
400    pub fn with_partitioning(mut self, partitioning: Partitioning) -> Self {
401        self.partitioning = partitioning;
402        self
403    }
404
405    /// Set whether this node is eligible for broadcast join optimisation.
406    #[must_use]
407    pub fn with_broadcast_eligible(mut self, broadcast_eligible: bool) -> Self {
408        self.broadcast_eligible = broadcast_eligible;
409        self
410    }
411
412    /// Attach an exchange (repartition) node to the plan.
413    ///
414    /// This is a convenience wrapper around `with_partitioning` that creates a
415    /// `Hash` partitioning on the given key columns with `num_partitions`
416    /// buckets.  Used by the `DataFrame::repartition()` API.
417    #[must_use]
418    pub fn with_exchange(
419        self,
420        key_columns: impl IntoIterator<Item = impl Into<String>>,
421        num_partitions: u32,
422    ) -> Self {
423        self.with_partitioning(Partitioning::Hash {
424            keys: key_columns.into_iter().map(Into::into).collect(),
425            buckets: num_partitions,
426        })
427    }
428
429    /// Set the estimated output row count for this node.
430    #[must_use]
431    pub fn with_estimated_rows(mut self, estimated_rows: Option<u64>) -> Self {
432        self.estimated_rows = estimated_rows;
433        self
434    }
435
436    /// Set the typed operator classification for this node.
437    #[must_use]
438    pub fn with_op(mut self, op: NodeOp) -> Self {
439        self.op = Some(op);
440        self
441    }
442
443    /// Set the output schema for this node.
444    #[must_use]
445    pub fn with_output_schema(mut self, schema: PlanSchema) -> Self {
446        self.output_schema = schema;
447        self
448    }
449
450    /// Stable node id inside a plan.
451    pub fn id(&self) -> &str {
452        &self.id
453    }
454
455    /// Human-readable node label.
456    pub fn label(&self) -> &str {
457        &self.label
458    }
459
460    /// Execution kind for this node.
461    pub fn kind(&self) -> ExecutionKind {
462        self.kind
463    }
464
465    /// Input node ids.
466    pub fn inputs(&self) -> &[String] {
467        &self.inputs
468    }
469
470    /// Output partitioning strategy.
471    pub fn partitioning(&self) -> &Partitioning {
472        &self.partitioning
473    }
474
475    /// Mutate the output partitioning strategy in-place.
476    pub fn set_partitioning(&mut self, partitioning: Partitioning) {
477        self.partitioning = partitioning;
478    }
479
480    /// Whether this node is eligible for broadcast join optimisation.
481    pub fn broadcast_eligible(&self) -> bool {
482        self.broadcast_eligible
483    }
484
485    /// Estimated output row count.
486    pub fn estimated_rows(&self) -> Option<u64> {
487        self.estimated_rows
488    }
489
490    /// Typed operator classification, if set.
491    pub fn op(&self) -> Option<&NodeOp> {
492        self.op.as_ref()
493    }
494
495    /// Output schema for this node.
496    pub fn output_schema(&self) -> &PlanSchema {
497        &self.output_schema
498    }
499}
500
501/// Maximum number of nodes allowed in a single plan.
502///
503/// Prevents adversarial or accidental plans from causing stack overflows in
504/// recursive plan walkers or excessive memory allocation (S7).
505pub const MAX_PLAN_NODES: usize = 10_000;
506
507/// Shared core fields for logical and physical plans.
508#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
509pub(crate) struct PlanCore {
510    pub(crate) name: String,
511    pub(crate) kind: ExecutionKind,
512    pub(crate) nodes: Vec<PlanNode>,
513    /// Override for shuffle partition count (`SET shuffle.partitions = N`).
514    /// When `Some`, `AutoPartitionRule` uses this as the target bucket count
515    /// instead of computing from data size.
516    shuffle_partitions: Option<u32>,
517}
518
519impl PlanCore {
520    fn new(name: impl Into<String>, kind: ExecutionKind) -> Self {
521        Self {
522            name: name.into(),
523            kind,
524            nodes: Vec::new(),
525            shuffle_partitions: None,
526        }
527    }
528
529    fn add_node(&mut self, node: PlanNode) {
530        self.nodes.push(node);
531    }
532
533    fn with_node(mut self, node: PlanNode) -> Self {
534        self.add_node(node);
535        self
536    }
537
538    fn name(&self) -> &str {
539        &self.name
540    }
541
542    fn kind(&self) -> ExecutionKind {
543        self.kind
544    }
545
546    fn nodes(&self) -> &[PlanNode] {
547        &self.nodes
548    }
549
550    fn nodes_mut(&mut self) -> &mut [PlanNode] {
551        &mut self.nodes
552    }
553
554    fn shuffle_partitions(&self) -> Option<u32> {
555        self.shuffle_partitions
556    }
557
558    fn with_shuffle_partitions(mut self, n: Option<u32>) -> Self {
559        self.shuffle_partitions = n;
560        self
561    }
562}
563
564/// Krishiv logical plan wrapper.
565#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
566pub struct LogicalPlan {
567    pub(crate) core: PlanCore,
568}
569
570impl LogicalPlan {
571    /// Create an empty logical plan.
572    pub fn new(name: impl Into<String>, kind: ExecutionKind) -> Self {
573        Self {
574            core: PlanCore::new(name, kind),
575        }
576    }
577
578    /// Add a node to the plan.
579    pub fn add_node(&mut self, node: PlanNode) {
580        self.core.add_node(node);
581    }
582
583    /// Add a node and return the updated plan.
584    #[must_use]
585    pub fn with_node(mut self, node: PlanNode) -> Self {
586        self.core = self.core.with_node(node);
587        self
588    }
589
590    /// Plan name.
591    pub fn name(&self) -> &str {
592        self.core.name()
593    }
594
595    /// Plan execution kind.
596    pub fn kind(&self) -> ExecutionKind {
597        self.core.kind()
598    }
599
600    /// Plan nodes.
601    pub fn nodes(&self) -> &[PlanNode] {
602        self.core.nodes()
603    }
604
605    /// Validate node identifiers, input references, and graph acyclicity.
606    pub fn validate(&self) -> Result<(), PlanError> {
607        graph::validate_plan("logical", self.name(), self.nodes())
608    }
609
610    /// Compact textual description for early `EXPLAIN` output.
611    pub fn describe(&self) -> String {
612        describe_plan(
613            "logical",
614            self.core.name(),
615            self.core.kind(),
616            self.core.nodes(),
617        )
618    }
619
620    /// Return the shuffle partition override, if set.
621    pub fn shuffle_partitions(&self) -> Option<u32> {
622        self.core.shuffle_partitions()
623    }
624
625    /// Set the shuffle partition override for this plan.
626    #[must_use]
627    pub fn with_shuffle_partitions(mut self, n: Option<u32>) -> Self {
628        self.core = self.core.with_shuffle_partitions(n);
629        self
630    }
631}
632
633/// Krishiv physical plan wrapper.
634#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
635pub struct PhysicalPlan {
636    pub(crate) core: PlanCore,
637    /// Post-AQE coalesced partition count set by `CoalesceRule::apply`.
638    /// `None` means coalescing has not been applied.
639    coalesced_partition_count: Option<usize>,
640}
641
642impl PhysicalPlan {
643    /// Create an empty physical plan.
644    pub fn new(name: impl Into<String>, kind: ExecutionKind) -> Self {
645        Self {
646            core: PlanCore::new(name, kind),
647            coalesced_partition_count: None,
648        }
649    }
650
651    /// Return the post-AQE coalesced partition count, if set by `CoalesceRule`.
652    pub fn coalesced_partition_count(&self) -> Option<usize> {
653        self.coalesced_partition_count
654    }
655
656    /// Set the coalesced partition count (called by `CoalesceRule::apply`).
657    #[must_use]
658    pub fn with_coalesced_partition_count(mut self, count: usize) -> Self {
659        self.coalesced_partition_count = Some(count);
660        self
661    }
662
663    /// Add a node to the plan.
664    pub fn add_node(&mut self, node: PlanNode) {
665        self.core.add_node(node);
666    }
667
668    /// Add a node and return the updated plan.
669    #[must_use]
670    pub fn with_node(mut self, node: PlanNode) -> Self {
671        self.core = self.core.with_node(node);
672        self
673    }
674
675    /// Plan name.
676    pub fn name(&self) -> &str {
677        self.core.name()
678    }
679
680    /// Plan execution kind.
681    pub fn kind(&self) -> ExecutionKind {
682        self.core.kind()
683    }
684
685    /// Plan nodes (read-only access).
686    pub fn nodes(&self) -> &[PlanNode] {
687        self.core.nodes()
688    }
689
690    /// Plan nodes (mutable access).
691    ///
692    /// Used by AQE rules such as `AutoPartitionRule` to adjust partition counts
693    /// on `Exchange` nodes without rebuilding the entire plan graph.
694    pub fn nodes_mut(&mut self) -> &mut [PlanNode] {
695        self.core.nodes_mut()
696    }
697
698    /// Return the shuffle partition override, if set.
699    pub fn shuffle_partitions(&self) -> Option<u32> {
700        self.core.shuffle_partitions()
701    }
702
703    /// Set the shuffle partition override for this plan.
704    #[must_use]
705    pub fn with_shuffle_partitions(mut self, n: Option<u32>) -> Self {
706        self.core = self.core.with_shuffle_partitions(n);
707        self
708    }
709
710    /// Validate node identifiers, input references, and graph acyclicity.
711    pub fn validate(&self) -> Result<(), PlanError> {
712        graph::validate_plan("physical", self.name(), self.nodes())
713    }
714
715    /// Compact textual description for early `EXPLAIN` output.
716    pub fn describe(&self) -> String {
717        describe_plan(
718            "physical",
719            self.core.name(),
720            self.core.kind(),
721            self.core.nodes(),
722        )
723    }
724}
725
726fn describe_plan(plan_type: &str, name: &str, kind: ExecutionKind, nodes: &[PlanNode]) -> String {
727    let mut output = format!("{plan_type} plan: {name}\nkind: {kind}\nnodes:");
728    if nodes.is_empty() {
729        output.push_str(" <empty>");
730        return output;
731    }
732
733    for node in nodes {
734        output.push_str(&format!(
735            "\n- {} [{}] {}",
736            node.id(),
737            node.kind(),
738            node.label()
739        ));
740        if !node.inputs().is_empty() {
741            output.push_str(&format!(" <- {}", node.inputs().join(", ")));
742        }
743        if node.partitioning() != &Partitioning::Unpartitioned {
744            output.push_str(&format!(" [partitioning: {}]", node.partitioning()));
745        }
746        if node.broadcast_eligible() {
747            output.push_str(" [broadcast-eligible]");
748        }
749        if let Some(rows) = node.estimated_rows() {
750            output.push_str(&format!(" [est-rows: {rows}]"));
751        }
752    }
753
754    output
755}
756
757// ── Plan diffing ──────────────────────────────────────────────────────────────
758
759/// Summary of structural differences between two physical plans.
760///
761/// Used by operators to understand what changed between two versions of the same
762/// job's physical plan (e.g. after an adaptive repartitioning decision in R7/R9).
763#[derive(Debug, Clone, Default, PartialEq, Eq)]
764pub struct PlanDiff {
765    /// Node ids present in `after` but not in `before`.
766    pub added: Vec<String>,
767    /// Node ids present in `before` but not in `after`.
768    pub removed: Vec<String>,
769    /// Node ids present in both plans but with different labels or operators.
770    pub changed: Vec<String>,
771}
772
773impl PlanDiff {
774    /// Whether the two plans are structurally identical.
775    pub fn is_empty(&self) -> bool {
776        self.added.is_empty() && self.removed.is_empty() && self.changed.is_empty()
777    }
778}
779
780/// Compute the structural diff between two physical plans.
781///
782/// Nodes are matched by id. A node is "changed" if any of its label, operator,
783/// inputs, partitioning, estimated row count, or output schema differs between
784/// `before` and `after`.
785#[must_use]
786pub fn diff_plans(before: &PhysicalPlan, after: &PhysicalPlan) -> PlanDiff {
787    use std::collections::HashMap;
788
789    let before_map: HashMap<&str, &PlanNode> = before.nodes().iter().map(|n| (n.id(), n)).collect();
790    let after_map: HashMap<&str, &PlanNode> = after.nodes().iter().map(|n| (n.id(), n)).collect();
791
792    let mut added = Vec::new();
793    let mut removed = Vec::new();
794    let mut changed = Vec::new();
795
796    for (id, after_node) in &after_map {
797        match before_map.get(id) {
798            None => added.push((*id).to_owned()),
799            Some(before_node) => {
800                let structurally_different = before_node.label() != after_node.label()
801                    || before_node.op() != after_node.op()
802                    || before_node.inputs() != after_node.inputs()
803                    || before_node.partitioning() != after_node.partitioning()
804                    || before_node.estimated_rows() != after_node.estimated_rows()
805                    || before_node.output_schema() != after_node.output_schema();
806                if structurally_different {
807                    changed.push((*id).to_owned());
808                }
809            }
810        }
811    }
812    for id in before_map.keys() {
813        if !after_map.contains_key(id) {
814            removed.push((*id).to_owned());
815        }
816    }
817
818    added.sort();
819    removed.sort();
820    changed.sort();
821
822    PlanDiff {
823        added,
824        removed,
825        changed,
826    }
827}
828
829#[cfg(test)]
830mod gap_tests;
831
832#[cfg(test)]
833mod tests {
834    use super::{
835        ExecutionKind, FieldType, JoinType, LogicalPlan, NodeOp, Partitioning, PhysicalPlan,
836        PlanNode, PlanSchema, SchemaField,
837    };
838
839    #[test]
840    fn describes_logical_plan_with_nodes() {
841        let plan = LogicalPlan::new("demo", ExecutionKind::Batch).with_node(PlanNode::new(
842            "scan",
843            "scan parquet",
844            ExecutionKind::Batch,
845        ));
846
847        let description = plan.describe();
848
849        assert!(description.contains("logical plan: demo"));
850        assert!(description.contains("scan parquet"));
851    }
852
853    /// T2: every public API `JoinType` must have a first-class plan counterpart
854    /// (T2 — was previously collapsed to `Inner` for the four semi/anti
855    /// variants).
856    #[test]
857    fn join_type_variants_are_distinct() {
858        let all = [
859            JoinType::Inner,
860            JoinType::Left,
861            JoinType::Right,
862            JoinType::Full,
863            JoinType::Semi,
864            JoinType::Anti,
865            JoinType::LeftSemi,
866            JoinType::RightSemi,
867            JoinType::LeftAnti,
868            JoinType::RightAnti,
869            JoinType::Cross,
870            JoinType::NestedLoop,
871        ];
872        // The four variants added in T2 must be distinct from `Inner`,
873        // `Semi`, and `Anti` so a JSON round-trip preserves the join kind.
874        assert_ne!(JoinType::LeftSemi, JoinType::Inner);
875        assert_ne!(JoinType::RightSemi, JoinType::Inner);
876        assert_ne!(JoinType::LeftAnti, JoinType::Inner);
877        assert_ne!(JoinType::RightAnti, JoinType::Inner);
878        assert_ne!(JoinType::LeftSemi, JoinType::Semi);
879        assert_ne!(JoinType::LeftAnti, JoinType::Anti);
880        // All variants must be unique among themselves.
881        for (i, a) in all.iter().enumerate() {
882            for b in &all[i + 1..] {
883                assert_ne!(a, b, "{a:?} and {b:?} must be distinct");
884            }
885        }
886    }
887
888    #[test]
889    fn plan_node_default_annotations() {
890        let node = PlanNode::new("n1", "label", ExecutionKind::Batch);
891        assert_eq!(node.partitioning(), &Partitioning::Unpartitioned);
892        assert!(!node.broadcast_eligible());
893        assert_eq!(node.estimated_rows(), None);
894    }
895
896    #[test]
897    fn plan_node_builder_methods() {
898        let node = PlanNode::new("n1", "label", ExecutionKind::Batch)
899            .with_partitioning(Partitioning::Hash {
900                keys: vec!["region".to_string()],
901                buckets: 8,
902            })
903            .with_broadcast_eligible(true)
904            .with_estimated_rows(Some(1_000));
905
906        assert_eq!(
907            node.partitioning(),
908            &Partitioning::Hash {
909                keys: vec!["region".to_string()],
910                buckets: 8,
911            }
912        );
913        assert!(node.broadcast_eligible());
914        assert_eq!(node.estimated_rows(), Some(1_000));
915    }
916
917    #[test]
918    fn plan_node_round_robin_partitioning() {
919        let node = PlanNode::new("n1", "label", ExecutionKind::Batch)
920            .with_partitioning(Partitioning::RoundRobin { buckets: 4 });
921        assert_eq!(
922            node.partitioning(),
923            &Partitioning::RoundRobin { buckets: 4 }
924        );
925    }
926
927    #[test]
928    fn plan_node_broadcast_partitioning() {
929        let node = PlanNode::new("n1", "label", ExecutionKind::Batch)
930            .with_partitioning(Partitioning::Broadcast);
931        assert_eq!(node.partitioning(), &Partitioning::Broadcast);
932    }
933
934    #[test]
935    fn describe_shows_partitioning_when_not_unpartitioned() {
936        let plan = LogicalPlan::new("q", ExecutionKind::Batch).with_node(
937            PlanNode::new("agg", "aggregate", ExecutionKind::Batch).with_partitioning(
938                Partitioning::Hash {
939                    keys: vec!["city".to_string()],
940                    buckets: 16,
941                },
942            ),
943        );
944        let desc = plan.describe();
945        assert!(desc.contains("partitioning: hash(city, buckets=16)"));
946    }
947
948    #[test]
949    fn describe_does_not_show_partitioning_when_unpartitioned() {
950        let plan = LogicalPlan::new("q", ExecutionKind::Batch).with_node(PlanNode::new(
951            "scan",
952            "scan",
953            ExecutionKind::Batch,
954        ));
955        let desc = plan.describe();
956        assert!(!desc.contains("partitioning:"));
957    }
958
959    #[test]
960    fn physical_plan_with_broadcast_node() {
961        let plan = PhysicalPlan::new("p", ExecutionKind::Batch).with_node(
962            PlanNode::new("dim", "dim scan", ExecutionKind::Batch)
963                .with_partitioning(Partitioning::Broadcast)
964                .with_broadcast_eligible(true)
965                .with_estimated_rows(Some(500)),
966        );
967        let node = &plan.nodes()[0];
968        assert_eq!(node.partitioning(), &Partitioning::Broadcast);
969        assert!(node.broadcast_eligible());
970        assert_eq!(node.estimated_rows(), Some(500));
971
972        let desc = plan.describe();
973        assert!(desc.contains("broadcast"));
974    }
975
976    #[test]
977    fn plan_node_with_typed_op() {
978        let node =
979            PlanNode::new("scan", "scan parquet", ExecutionKind::Batch).with_op(NodeOp::Scan {
980                table: String::from("orders"),
981                filters: vec![],
982            });
983        assert!(matches!(node.op(), Some(NodeOp::Scan { table, .. }) if table == "orders"));
984    }
985
986    #[test]
987    fn plan_node_schema_propagation() {
988        let schema = PlanSchema::new(vec![
989            SchemaField::new("id", FieldType::Int64),
990            SchemaField::new("name", FieldType::Utf8).with_nullable(true),
991        ]);
992        let node = PlanNode::new("proj", "project", ExecutionKind::Batch)
993            .with_op(NodeOp::Project {
994                columns: vec![String::from("id"), String::from("name")],
995            })
996            .with_output_schema(schema);
997        assert_eq!(node.output_schema().fields().len(), 2);
998        assert_eq!(node.output_schema().fields()[0].name(), "id");
999        assert_eq!(
1000            node.output_schema().fields()[0].field_type(),
1001            &FieldType::Int64
1002        );
1003        assert!(!node.output_schema().fields()[0].nullable());
1004        assert!(node.output_schema().fields()[1].nullable());
1005    }
1006
1007    #[test]
1008    fn plan_schema_empty_by_default() {
1009        let node = PlanNode::new("n1", "label", ExecutionKind::Batch);
1010        assert!(node.output_schema().is_empty());
1011    }
1012
1013    #[test]
1014    fn node_op_variants_round_trip() {
1015        let ops: Vec<NodeOp> = vec![
1016            NodeOp::Scan {
1017                table: String::from("t1"),
1018                filters: vec![],
1019            },
1020            NodeOp::Filter {
1021                predicate: String::new(),
1022            },
1023            NodeOp::Project {
1024                columns: vec![String::from("a")],
1025            },
1026            NodeOp::Aggregate {
1027                group_keys: vec![String::from("region")],
1028            },
1029            NodeOp::Join {
1030                join_type: JoinType::Inner,
1031            },
1032            NodeOp::Exchange {
1033                partitioning: Partitioning::Broadcast,
1034            },
1035            NodeOp::Sink {
1036                format: String::from("parquet"),
1037            },
1038            NodeOp::CoalescePartitions {
1039                target_partitions: 4,
1040            },
1041            NodeOp::Other {
1042                description: String::from("custom"),
1043            },
1044        ];
1045        for op in &ops {
1046            let cloned = op.clone();
1047            assert_eq!(&cloned, op);
1048            // Verify Debug works.
1049            let _ = format!("{cloned:?}");
1050        }
1051    }
1052
1053    #[test]
1054    fn partitioning_display() {
1055        assert_eq!(Partitioning::Unpartitioned.to_string(), "unpartitioned");
1056        assert_eq!(
1057            Partitioning::Hash {
1058                keys: vec!["a".to_string(), "b".to_string()],
1059                buckets: 4
1060            }
1061            .to_string(),
1062            "hash(a, b, buckets=4)"
1063        );
1064        assert_eq!(
1065            Partitioning::RoundRobin { buckets: 2 }.to_string(),
1066            "round-robin(buckets=2)"
1067        );
1068        assert_eq!(Partitioning::Broadcast.to_string(), "broadcast");
1069    }
1070
1071    // ── PlanDiff ──────────────────────────────────────────────────────────
1072
1073    fn make_plan(nodes: &[(&str, &str)]) -> PhysicalPlan {
1074        let mut plan = PhysicalPlan::new("test", ExecutionKind::Batch);
1075        for (id, label) in nodes {
1076            plan.add_node(PlanNode::new(*id, *label, ExecutionKind::Batch));
1077        }
1078        plan
1079    }
1080
1081    #[test]
1082    fn diff_plans_identical_is_empty() {
1083        let p = make_plan(&[("scan", "Scan"), ("agg", "Aggregate")]);
1084        let diff = super::diff_plans(&p, &p);
1085        assert!(diff.is_empty());
1086    }
1087
1088    #[test]
1089    fn diff_plans_added_node() {
1090        let before = make_plan(&[("scan", "Scan")]);
1091        let after = make_plan(&[("scan", "Scan"), ("filter", "Filter")]);
1092        let diff = super::diff_plans(&before, &after);
1093        assert_eq!(diff.added, vec!["filter"]);
1094        assert!(diff.removed.is_empty());
1095        assert!(diff.changed.is_empty());
1096    }
1097
1098    #[test]
1099    fn diff_plans_removed_node() {
1100        let before = make_plan(&[("scan", "Scan"), ("filter", "Filter")]);
1101        let after = make_plan(&[("scan", "Scan")]);
1102        let diff = super::diff_plans(&before, &after);
1103        assert!(diff.added.is_empty());
1104        assert_eq!(diff.removed, vec!["filter"]);
1105        assert!(diff.changed.is_empty());
1106    }
1107
1108    #[test]
1109    fn diff_plans_changed_label() {
1110        let before = make_plan(&[("n1", "OldLabel")]);
1111        let after = make_plan(&[("n1", "NewLabel")]);
1112        let diff = super::diff_plans(&before, &after);
1113        assert!(diff.added.is_empty());
1114        assert!(diff.removed.is_empty());
1115        assert_eq!(diff.changed, vec!["n1"]);
1116    }
1117
1118    #[test]
1119    fn diff_plans_detects_changed_partitioning() {
1120        let mut before = PhysicalPlan::new("test", ExecutionKind::Batch);
1121        before.add_node(
1122            PlanNode::new("n1", "label", ExecutionKind::Batch)
1123                .with_partitioning(Partitioning::Unpartitioned),
1124        );
1125        let mut after = PhysicalPlan::new("test", ExecutionKind::Batch);
1126        after.add_node(
1127            PlanNode::new("n1", "label", ExecutionKind::Batch)
1128                .with_partitioning(Partitioning::Broadcast),
1129        );
1130        let diff = super::diff_plans(&before, &after);
1131        assert_eq!(diff.changed, vec!["n1"]);
1132    }
1133
1134    #[test]
1135    fn diff_plans_detects_changed_estimated_rows() {
1136        let mut before = PhysicalPlan::new("test", ExecutionKind::Batch);
1137        before.add_node(
1138            PlanNode::new("n1", "label", ExecutionKind::Batch).with_estimated_rows(Some(100)),
1139        );
1140        let mut after = PhysicalPlan::new("test", ExecutionKind::Batch);
1141        after.add_node(
1142            PlanNode::new("n1", "label", ExecutionKind::Batch).with_estimated_rows(Some(200)),
1143        );
1144        let diff = super::diff_plans(&before, &after);
1145        assert_eq!(diff.changed, vec!["n1"]);
1146    }
1147
1148    #[test]
1149    fn diff_plans_detects_changed_inputs() {
1150        let mut before = PhysicalPlan::new("test", ExecutionKind::Batch);
1151        before.add_node(PlanNode::new("src", "source", ExecutionKind::Batch));
1152        before.add_node(PlanNode::new("n1", "label", ExecutionKind::Batch).with_inputs(["src"]));
1153        let mut after = PhysicalPlan::new("test", ExecutionKind::Batch);
1154        after.add_node(PlanNode::new("src", "source", ExecutionKind::Batch));
1155        after.add_node(PlanNode::new("n1", "label", ExecutionKind::Batch)); // no inputs
1156        let diff = super::diff_plans(&before, &after);
1157        assert_eq!(diff.changed, vec!["n1"]);
1158    }
1159
1160    #[test]
1161    fn graph_rejects_duplicate_input_edges() {
1162        let plan = LogicalPlan::new("dup-edges", ExecutionKind::Batch)
1163            .with_node(PlanNode::new("src", "source", ExecutionKind::Batch))
1164            .with_node(
1165                PlanNode::new("n1", "node", ExecutionKind::Batch).with_inputs(["src", "src"]),
1166            );
1167        let err = plan.validate().expect_err("duplicate inputs must fail");
1168        assert!(
1169            err.to_string().contains("duplicate input"),
1170            "unexpected: {err}"
1171        );
1172    }
1173}