From 138d4183282d6bc6a34e0f5afe699c3d2cff476d Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Sat, 3 Oct 2026 19:41:57 +0000 Subject: [PATCH 1/6] feat(ir): restore execution timing assignment for physical plans The logical export is phase-free. Physical planning still needs the lifecycle timing expansion; bring it back unchanged except for carrying coverage. Co-Authored-By: Claude Opus 5.5 --- crates/types/src/ir/mod.rs | 6 + crates/types/src/ir/timing.rs | 905 ++++++++++++++++++ .../src/post_asap/execution_data_state.rs | 12 + 3 files changed, 923 insertions(+) create mode 100644 crates/types/src/ir/timing.rs diff --git a/crates/types/src/ir/mod.rs b/crates/types/src/ir/mod.rs index b3659d531..be29f0f9c 100644 --- a/crates/types/src/ir/mod.rs +++ b/crates/types/src/ir/mod.rs @@ -19,5 +19,11 @@ pub mod canonicalize; pub mod cse; pub mod flat; pub mod schema_support; +/// Execution timing for physical plans: a lifecycle assignment expanded onto every node. +pub mod timing; +pub use timing::{ + apply_lifecycle_timings, data_state, planned_data_state, split_shared_by_phase, + validate_default, LifecycleAssignment, TimingMemo, +}; /// Semantic observation coverage, separate from field layout and physical timing. pub mod summary_coverage; diff --git a/crates/types/src/ir/timing.rs b/crates/types/src/ir/timing.rs new file mode 100644 index 000000000..e19513ba3 --- /dev/null +++ b/crates/types/src/ir/timing.rs @@ -0,0 +1,905 @@ +//! Execution timing: written into every node from a lifecycle assignment, +//! then validated against each operator's kind and its consuming edges. +//! +//! The logical DAG carries no timing. Summary materialization chooses a +//! lifecycle per summary state; [`LifecycleAssignment`] records that choice +//! (ingestion-time maintenance or query-time recomputation per `SummaryAgg`) +//! and [`apply_lifecycle_timings`] expands it into a timing on every node: +//! +//! - a node of fixed kind takes its kind's timing (`SummaryEstimate` and +//! `EvaluatePopulation` run at query time, `MaintainPopulation` at ingestion +//! time); +//! - a `SummaryAgg` takes the assignment's timing (default: ingestion time), +//! unless something below it can only exist at query time; +//! - every other node runs when its consumer runs: everything that feeds a +//! maintained state runs at ingestion time, everything above a evaluation at +//! query time. +//! +//! A node reached from two consumers that need different timings cannot be +//! executed once for both; [`split_shared_by_phase`] copies such a sub-DAG +//! for one side before the assignment is applied, and the pass itself +//! rejects a conflict it still finds. +//! +//! ## Edge rules (checked after the write) +//! +//! | Consumer | Accepts from an input | +//! |---|---| +//! | `SummaryAgg.child` | Rows, or exact-accumulator state, never a query-time value when the state is maintained | +//! | `SummaryEstimate.summary_input` | Summary state at either phase | +//! | `FinalizeExactAccumulator.child` | Exact-accumulator state | +//! | `EvaluatePopulation.child` | A `MaintainPopulation` at ingestion time | +//! | `MaintainPopulation.child` | Ingestion-time rows matching the population's input | +//! | any `NonASAP` consumer | Rows (or exact-accumulator state for a projection-like operator) at the consumer's own timing; ingestion work never reads a query-time value | + +use std::collections::HashMap; +use std::rc::Rc; + +use super::asap::ASAPOp; +use super::node::{Operator, OperatorNode}; +use super::non_asap::NonASAPOp; +use crate::ir::operator_properties::BinaryOpKind; +use crate::post_asap::execution_data_state::{ + DataPrimitive, ExecutionDataState, ExecutionDataStateError, ExecutionTiming, +}; +use crate::pre_asap::schema::{DataType, FieldDataType, Schema}; + +/// The per-state lifecycle choice summary materialization made: for each +/// `SummaryAgg` node (by identity), whether its state is maintained at +/// ingestion time or recomputed at query time. A state absent from the map +/// takes the default, ingestion-time maintenance. +#[derive(Debug, Clone, Default)] +pub struct LifecycleAssignment { + summary_timings: HashMap<*const OperatorNode, ExecutionTiming>, +} + +impl LifecycleAssignment { + /// The assignment under which every summary state is maintained at + /// ingestion time — the timings every plan carried before lifecycles + /// became a planning choice. + pub fn default_maintained() -> Self { + Self::default() + } + + pub fn set(&mut self, summary: &Rc, timing: ExecutionTiming) { + self.summary_timings.insert(Rc::as_ptr(summary), timing); + } + + pub fn summary_timing(&self, summary: &Rc) -> ExecutionTiming { + self.summary_timings + .get(&Rc::as_ptr(summary)) + .copied() + .unwrap_or(ExecutionTiming::IngestionTime) + } +} + +/// Memo of one [`apply_lifecycle_timings`] pass: `input node → timed node`, +/// shared by every root of a workload so a node shared by two roots stays +/// one `Rc`. Re-reaching a node with a different timing is a conflict. +#[derive(Default)] +pub struct TimingMemo { + done: HashMap<*const OperatorNode, Rc>, +} + +impl TimingMemo { + pub fn new() -> Self { + Self::default() + } + + /// The timed node produced for `input`, if the pass has reached it. + pub fn timed(&self, input: &Rc) -> Option<&Rc> { + self.done.get(&Rc::as_ptr(input)) + } +} + +/// The data state a timed node's output carries. +pub fn data_state(node: &OperatorNode) -> Option { + Some(ExecutionDataState { + timing: node.timing?, + primitive: match &node.operator { + Operator::ASAP(op) if op.produced_state().is_some() => DataPrimitive::SummaryState, + Operator::ASAP(ASAPOp::MaintainPopulation { .. }) => DataPrimitive::SummaryState, + _ => DataPrimitive::Raw, + }, + }) +} + +/// Whether the sub-DAG below `node` contains a node that can only run at +/// query time (a evaluation), which forces every consumer above it to query +/// time as well. +fn forces_query_time(node: &OperatorNode, seen: &mut HashMap<*const OperatorNode, bool>) -> bool { + let key = node as *const OperatorNode; + if let Some(&cached) = seen.get(&key) { + return cached; + } + let forced = match &node.operator { + _ if node.timing == Some(ExecutionTiming::QueryTime) => true, + Operator::ASAP(ASAPOp::SummaryEstimate { .. }) + | Operator::ASAP(ASAPOp::EvaluatePopulation { .. }) => true, + Operator::NonASAP(NonASAPOp::BinaryOp { lhs, rhs, .. }) + if node + .schema + .fields + .iter() + .any(|f| f.name == crate::pre_asap::schema::PROMQL_SERIES_IDENTITY) + && per_series_rows(lhs).is_none_or(|rows| per_series_rows(rhs) != Some(rows)) => + { + true + } + _ => node + .children() + .iter() + .any(|child| forces_query_time(child, seen)), + }; + seen.insert(key, forced); + forced +} + +/// Write the timings of `assignment` into every node reachable from `root`, +/// top-down, then validate every edge. Returns the timed copy of `root`; +/// `memo` carries the sharing across the roots of one workload. +pub fn apply_lifecycle_timings( + root: &Rc, + assignment: &LifecycleAssignment, + memo: &mut TimingMemo, +) -> Result, ExecutionDataStateError> { + let mut forced = HashMap::new(); + let timed = write( + root, + ExecutionTiming::QueryTime, + assignment, + memo, + &mut forced, + )?; + if timed.timing == Some(ExecutionTiming::IngestionTime) + && data_state(&timed).map(|s| s.primitive) == Some(DataPrimitive::Raw) + { + return Err(ExecutionDataStateError::MaintenanceRowsAtRoot); + } + validate(&timed, &mut HashMap::new())?; + Ok(timed) +} + +/// Validate the sub-DAG below `root` under the default (every summary +/// maintained) assignment, with `root` consumed at `root_timing`. For +/// planning-time legality checks of a candidate before it is assembled into +/// a workload DAG; nothing is kept. +pub fn validate_default( + root: &Rc, + root_timing: ExecutionTiming, +) -> Result<(), ExecutionDataStateError> { + let assignment = LifecycleAssignment::default_maintained(); + let mut memo = TimingMemo::new(); + let mut forced = HashMap::new(); + let timed = write(root, root_timing, &assignment, &mut memo, &mut forced)?; + validate(&timed, &mut HashMap::new()) +} + +/// The data state `node` produces under the default assignment when its +/// consumer runs at `consumer` — the planning-time answer to "what does this +/// candidate's output look like" before any assignment is applied. +pub fn planned_data_state( + node: &Rc, + consumer: ExecutionTiming, +) -> ExecutionDataState { + let mut forced = HashMap::new(); + let timing = own_timing( + node, + consumer, + &LifecycleAssignment::default_maintained(), + &mut forced, + ); + ExecutionDataState { + timing, + primitive: match &node.operator { + Operator::ASAP(op) if op.produced_state().is_some() => DataPrimitive::SummaryState, + Operator::ASAP(ASAPOp::MaintainPopulation { .. }) => DataPrimitive::SummaryState, + _ => DataPrimitive::Raw, + }, + } +} + +/// The timing `node` takes when its consumer runs at `consumer`. +fn own_timing( + node: &Rc, + consumer: ExecutionTiming, + assignment: &LifecycleAssignment, + forced: &mut HashMap<*const OperatorNode, bool>, +) -> ExecutionTiming { + // A placement fixed when the candidate was built (an exact-state read + // boundary that must run at query time, or one that feeds maintenance) + // is honored; a conflicting consumer is rejected by validation. + if let Some(placed) = node.timing { + return placed; + } + match &node.operator { + Operator::ASAP(ASAPOp::SummaryEstimate { .. }) + | Operator::ASAP(ASAPOp::EvaluatePopulation { .. }) => ExecutionTiming::QueryTime, + Operator::ASAP(ASAPOp::MaintainPopulation { .. }) => ExecutionTiming::IngestionTime, + Operator::ASAP(ASAPOp::SummaryAgg { child, .. }) => { + if forces_query_time(child, forced) { + ExecutionTiming::QueryTime + } else { + assignment.summary_timing(node) + } + } + _ => consumer, + } +} + +fn write( + node: &Rc, + consumer: ExecutionTiming, + assignment: &LifecycleAssignment, + memo: &mut TimingMemo, + forced: &mut HashMap<*const OperatorNode, bool>, +) -> Result, ExecutionDataStateError> { + let timing = own_timing(node, consumer, assignment, forced); + if let Some(done) = memo.done.get(&Rc::as_ptr(node)) { + let previous = done.timing.expect("memoized node is timed"); + if previous != timing { + return Err(ExecutionDataStateError::ConflictingTiming { + first: ExecutionDataState { + timing: previous, + primitive: data_state(done).map_or(DataPrimitive::Raw, |s| s.primitive), + }, + second: ExecutionDataState { + timing, + primitive: data_state(done).map_or(DataPrimitive::Raw, |s| s.primitive), + }, + }); + } + return Ok(Rc::clone(done)); + } + let mut error = None; + let operator = + node.operator.map_children( + |child| match write(child, timing, assignment, memo, forced) { + Ok(timed) => timed, + Err(e) => { + error.get_or_insert(e); + Rc::clone(child) + } + }, + ); + if let Some(e) = error { + return Err(e); + } + let timed = Rc::new(OperatorNode { + operator, + result_kind: node.result_kind, + schema: node.schema.clone(), + guarantee: node.guarantee.clone(), + timing: Some(timing), + // Timing copies the same logical sub-DAG; its observations are unchanged. + coverage: node.coverage.clone(), + }); + memo.done.insert(Rc::as_ptr(node), Rc::clone(&timed)); + Ok(timed) +} + +fn state_of(node: &OperatorNode) -> ExecutionDataState { + data_state(node).expect("timed node") +} + +/// Check every edge below `node` against the module-level rules. +fn validate( + node: &Rc, + seen: &mut HashMap<*const OperatorNode, ()>, +) -> Result<(), ExecutionDataStateError> { + if seen.insert(Rc::as_ptr(node), ()).is_some() { + return Ok(()); + } + let timing = node.timing.expect("timed node"); + match &node.operator { + Operator::ASAP(op) => validate_asap(node, op, timing)?, + Operator::NonASAP(op) => validate_non_asap(node, op, timing)?, + } + for child in node.children() { + validate(child, seen)?; + } + Ok(()) +} + +fn is_exact_accumulator_state(schema: &Schema) -> Result<(), ExecutionDataStateError> { + for field in &schema.fields { + match &field.dtype { + FieldDataType::Plain(_) | FieldDataType::ExactAggregate(..) => {} + other => { + return Err(ExecutionDataStateError::UnsupportedStateComposition { + family: format!("{other:?}"), + }) + } + } + } + Ok(()) +} + +fn validate_asap( + node: &OperatorNode, + op: &ASAPOp, + timing: ExecutionTiming, +) -> Result<(), ExecutionDataStateError> { + match op { + ASAPOp::SummaryAgg { child, .. } => { + let avail = state_of(child); + match avail { + ExecutionDataState::INGESTION_ROWS | ExecutionDataState::QUERY_ROWS => {} + s if s.primitive == DataPrimitive::SummaryState => { + is_exact_accumulator_state(&child.schema)? + } + other => { + return Err(ExecutionDataStateError::EvaluationUnderMaintenance { + edge: "SummaryAgg.child", + child: other, + }) + } + } + if timing == ExecutionTiming::IngestionTime + && avail.timing == ExecutionTiming::QueryTime + { + return Err(ExecutionDataStateError::EvaluationUnderMaintenance { + edge: "SummaryAgg.child", + child: avail, + }); + } + Ok(()) + } + ASAPOp::SummaryEstimate { summary_input, .. } => { + let s = state_of(summary_input); + if s.primitive != DataPrimitive::SummaryState { + return Err(ExecutionDataStateError::IllegalChildDataState { + edge: "SummaryEstimate.summary_input", + child: s, + }); + } + if timing != ExecutionTiming::QueryTime { + return Err(ExecutionDataStateError::IllegalChildDataState { + edge: "SummaryEstimate", + child: state_of(node), + }); + } + Ok(()) + } + ASAPOp::FinalizeExactAccumulator { child } => { + let s = state_of(child); + if s.primitive != DataPrimitive::SummaryState + || is_exact_accumulator_state(&child.schema).is_err() + || (timing == ExecutionTiming::IngestionTime && s.timing != timing) + { + return Err(ExecutionDataStateError::IllegalChildDataState { + edge: "FinalizeExactAccumulator.child", + child: s, + }); + } + Ok(()) + } + ASAPOp::MaintainPopulation { child, population } => { + let valid = population.matches_node(child) + && state_of(child) + == ExecutionDataState { + timing, + primitive: DataPrimitive::Raw, + }; + if !valid { + return Err(ExecutionDataStateError::InvalidMaintainedPopulation); + } + Ok(()) + } + ASAPOp::EvaluatePopulation { child, evaluation } => { + let valid = timing == ExecutionTiming::QueryTime + && matches!( + &child.operator, + Operator::ASAP(ASAPOp::MaintainPopulation { population, .. }) + if population.supports(evaluation) + && child.timing.is_some() + ); + if !valid { + return Err(ExecutionDataStateError::InvalidMaintainedPopulation); + } + Ok(()) + } + ASAPOp::SummaryMerge { .. } + | ASAPOp::SummarySubtract { .. } + | ASAPOp::SummaryDelete { .. } + | ASAPOp::SummaryJoin { .. } + | ASAPOp::Extension { .. } => Err(ExecutionDataStateError::UnimplementedOperator { + operator: op.kind_name(), + }), + } +} + +fn check_plain_or_exact_values(input: &Schema) -> Result<(), ExecutionDataStateError> { + for field in &input.fields { + if !matches!( + field.dtype, + FieldDataType::Plain(_) | FieldDataType::ExactAggregate(..) + ) { + return Err(ExecutionDataStateError::NonPlainOperand { + column: field.name.clone(), + dtype: format!("{:?}", field.dtype), + }); + } + } + Ok(()) +} + +fn check_all_plain(input: &Schema) -> Result<(), ExecutionDataStateError> { + for field in &input.fields { + if !field.is_plain() { + return Err(ExecutionDataStateError::NonPlainOperand { + column: field.name.clone(), + dtype: format!("{:?}", field.dtype), + }); + } + } + Ok(()) +} + +fn validate_non_asap( + node: &OperatorNode, + op: &NonASAPOp, + timing: ExecutionTiming, +) -> Result<(), ExecutionDataStateError> { + // Every input is rows at this node's own timing. Ingestion work never + // reads a query-time value; exact-accumulator state may pass through + // the projection-like operators unchanged. + for child in op.children() { + let s = state_of(child); + let passes_state = matches!( + op, + NonASAPOp::Project { .. } + | NonASAPOp::Filter { .. } + | NonASAPOp::Sort { .. } + | NonASAPOp::Limit { .. } + ) && s.primitive == DataPrimitive::SummaryState + && is_exact_accumulator_state(&child.schema).is_ok(); + if s.timing != timing || (s.primitive != DataPrimitive::Raw && !passes_state) { + return Err(ExecutionDataStateError::IllegalChildDataState { + edge: op.kind_name(), + child: s, + }); + } + } + match op { + NonASAPOp::Project { child, .. } + | NonASAPOp::Filter { child, .. } + | NonASAPOp::Sort { child, .. } + | NonASAPOp::Limit { child, .. } => check_plain_or_exact_values(&child.schema)?, + NonASAPOp::Aggregate { + reduction, + measures, + child, + .. + } => { + let mut referenced: Vec = reduction + .group_keys() + .map(|keys| keys.keys().to_vec()) + .unwrap_or_default(); + for m in measures { + referenced.extend(m.input_cols()); + } + let implicit = measures.iter().any(|m| m.input_cols().is_empty()); + for (i, field) in child.schema.fields.iter().enumerate() { + if (implicit || referenced.contains(&i)) && !field.is_plain() { + return Err(ExecutionDataStateError::NonPlainOperand { + column: field.name.clone(), + dtype: format!("{:?}", field.dtype), + }); + } + } + } + NonASAPOp::BinaryOp { + operator, lhs, rhs, .. + } => { + let is_div = matches!( + operator.kind, + BinaryOpKind::Arithmetic(crate::pre_asap::ArithmeticOpKind::Div) + ); + if (operator.checked_relative_division && operator.checked_finite_division) + || ((operator.checked_relative_division || operator.checked_finite_division) + && (timing != ExecutionTiming::QueryTime || !is_div)) + { + return Err(ExecutionDataStateError::InvalidCheckedDivision); + } + if timing == ExecutionTiming::IngestionTime { + let plain_float_or_ts = |schema: &Schema| { + schema.fields.iter().all(|field| { + !field.nullable + && (matches!( + field.dtype, + FieldDataType::Plain(DataType::Float64 | DataType::Timestamp) + ) || (field.name + == crate::pre_asap::schema::PROMQL_SERIES_IDENTITY + && field.dtype == FieldDataType::Plain(DataType::Utf8))) + }) + }; + let float_count = node + .schema + .fields + .iter() + .filter(|f| matches!(f.dtype, FieldDataType::Plain(DataType::Float64))) + .count(); + if operator.vector_match.is_some() + || !matches!(operator.kind, BinaryOpKind::Arithmetic(_)) + || lhs.schema != rhs.schema + || lhs.schema != node.schema + || node + .schema + .fields + .iter() + .filter(|f| f.name == crate::pre_asap::schema::PROMQL_SERIES_IDENTITY) + .count() + > 1 + || (node + .schema + .fields + .iter() + .any(|f| f.name == crate::pre_asap::schema::PROMQL_SERIES_IDENTITY) + && per_series_rows(lhs) + .is_none_or(|rows| per_series_rows(rhs) != Some(rows))) + || !plain_float_or_ts(&node.schema) + || float_count != 1 + { + return Err(ExecutionDataStateError::InvalidMaintenanceBinary); + } + } + } + _ => { + for child in op.children() { + check_all_plain(&child.schema)?; + } + } + } + Ok(()) +} + +/// Copy, for one consumer, every sub-DAG that `assignment` would reach with +/// two different timings, so that a workload whose CSE shared a `Scan` +/// between an ingestion-time summary and a query-time computation can still +/// be timed. Only the conflicting sub-DAGs are copied; a sub-DAG reached with +/// one timing stays one `Rc`. Returns the (possibly rewritten) root. +pub fn split_shared_by_phase( + root: &Rc, + assignment: &LifecycleAssignment, +) -> Rc { + // First pass: the set of timings each node is reached with. + let mut reached: HashMap<*const OperatorNode, Vec> = HashMap::new(); + let mut forced = HashMap::new(); + fn collect( + node: &Rc, + consumer: ExecutionTiming, + assignment: &LifecycleAssignment, + reached: &mut HashMap<*const OperatorNode, Vec>, + forced: &mut HashMap<*const OperatorNode, bool>, + ) { + let timing = own_timing(node, consumer, assignment, forced); + let entry = reached.entry(Rc::as_ptr(node)).or_default(); + if entry.contains(&timing) { + return; + } + entry.push(timing); + for child in node.children() { + collect(child, timing, assignment, reached, forced); + } + } + collect( + root, + ExecutionTiming::QueryTime, + assignment, + &mut reached, + &mut forced, + ); + if reached.values().all(|timings| timings.len() <= 1) { + return Rc::clone(root); + } + // Second pass: rebuild, giving each (node, timing) pair its own copy. + let mut copies: HashMap<(*const OperatorNode, ExecutionTiming), Rc> = + HashMap::new(); + fn rebuild( + node: &Rc, + consumer: ExecutionTiming, + assignment: &LifecycleAssignment, + reached: &HashMap<*const OperatorNode, Vec>, + copies: &mut HashMap<(*const OperatorNode, ExecutionTiming), Rc>, + forced: &mut HashMap<*const OperatorNode, bool>, + ) -> Rc { + let timing = own_timing(node, consumer, assignment, forced); + let key = (Rc::as_ptr(node), timing); + if let Some(done) = copies.get(&key) { + return Rc::clone(done); + } + let conflicted = reached + .get(&Rc::as_ptr(node)) + .is_some_and(|timings| timings.len() > 1); + let mut changed = conflicted; + let operator = node.operator.map_children(|child| { + let rebuilt = rebuild(child, timing, assignment, reached, copies, forced); + changed |= !Rc::ptr_eq(&rebuilt, child); + rebuilt + }); + let out = if changed { + Rc::new(OperatorNode { + operator, + result_kind: node.result_kind, + schema: node.schema.clone(), + guarantee: node.guarantee.clone(), + timing: node.timing, + coverage: node.coverage.clone(), + }) + } else { + Rc::clone(node) + }; + copies.insert(key, Rc::clone(&out)); + out + } + rebuild( + root, + ExecutionTiming::QueryTime, + assignment, + &reached, + &mut copies, + &mut forced, + ) +} + +/// Maintenance arithmetic needs the same per-series population on both sides. +fn per_series_rows(node: &OperatorNode) -> Option<&OperatorNode> { + use crate::post_asap::ExactKind; + match &node.operator { + Operator::ASAP(ASAPOp::FinalizeExactAccumulator { child }) => match &child.operator { + Operator::ASAP(ASAPOp::SummaryAgg { + child, + family: FieldDataType::ExactAggregate(ExactKind::Sum | ExactKind::Count, _), + reduction: crate::pre_asap::Reduction::PerEntity, + filter: None, + .. + }) => Some(child), + _ => None, + }, + Operator::NonASAP(NonASAPOp::BinaryOp { lhs, rhs, .. }) => { + let rows = per_series_rows(lhs)?; + (per_series_rows(rhs) == Some(rows)).then_some(rows) + } + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ir::non_asap::NonASAPOp; + use crate::ir::operator_properties::{Reduction, Source}; + use crate::post_asap::sketch::{ + ExactKind, ExactParams, GroupingStrategy, SketchAlgorithm, SketchKind, SketchParams, + SketchStatistic, SummaryUpdate, + }; + use crate::pre_asap::agg_intent::AggIntent; + use crate::pre_asap::expr_ir::ColumnRef; + use crate::pre_asap::schema::Field; + + fn scan_with(fields: Vec) -> Rc { + OperatorNode::new_shared(crate::ir::Operator::NonASAP(NonASAPOp::Scan { + source: Source::TimeSeries { metric: "m".into() }, + predicates: vec![], + schema: Schema::with_time_index(fields, 0, vec![]), + })) + .unwrap() + } + + fn scan() -> Rc { + scan_with(vec![ + Field::plain("ts", DataType::Timestamp, false), + Field::plain("value", DataType::Float64, false), + Field::plain("zone", DataType::Utf8, true), + ]) + } + + fn kll() -> FieldDataType { + FieldDataType::Sketch( + SketchKind::new(SketchAlgorithm::Kll, SketchParams::Kll { k: 200 }), + GroupingStrategy::default(), + ) + } + + fn exact_sum() -> FieldDataType { + FieldDataType::ExactAggregate(ExactKind::Sum, ExactParams::Sum) + } + + fn agg(child: Rc, family: FieldDataType) -> Rc { + std::rc::Rc::new( + OperatorNode::with_schema( + crate::ir::Operator::ASAP(ASAPOp::SummaryAgg { + child, + family: family.clone(), + input: SummaryUpdate::column(ColumnRef::SampleValue), + reduction: Reduction::by(vec![]), + grouping: GroupingStrategy::default(), + filter: None, + }), + Schema::lifted(vec![Field::new("state", family, false)], None), + ) + .with_guarantee(None), + ) + } + + fn estimate(child: Rc) -> Rc { + std::rc::Rc::new( + OperatorNode::with_schema( + crate::ir::Operator::ASAP(ASAPOp::SummaryEstimate { + summary_input: child, + query: SketchStatistic::Quantile { q: 0.99 }, + }), + Schema::lifted( + vec![Field::plain("quantile_0_99", DataType::Float64, false)], + None, + ), + ) + .with_guarantee(None), + ) + } + + fn aggregate(measure: AggIntent, child: Rc) -> Rc { + OperatorNode::new_shared(crate::ir::Operator::NonASAP(NonASAPOp::Aggregate { + reduction: Reduction::by(vec![]), + measures: vec![measure], + output_names: vec![], + filters: vec![], + having: None, + child, + })) + .unwrap() + } + + fn max(child: Rc) -> Rc { + aggregate(AggIntent::Max { col: None }, child) + } + + /// `node` with its timing fixed in advance, as a candidate builder does + /// for an operator that must feed maintenance. + fn placed_at_ingestion(node: Rc) -> Rc { + Rc::new( + (*node) + .clone() + .with_timing(Some(ExecutionTiming::IngestionTime)), + ) + } + + fn apply(root: &Rc) -> Result, ExecutionDataStateError> { + apply_lifecycle_timings( + root, + &LifecycleAssignment::default_maintained(), + &mut TimingMemo::new(), + ) + } + + fn child(node: &Rc) -> Rc { + Rc::clone(node.children()[0]) + } + + #[test] + fn summary_agg_input_runs_at_ingestion_time() { + let root = apply(&agg(scan(), kll())).unwrap(); + assert_eq!( + data_state(&root), + Some(ExecutionDataState::INGESTION_SUMMARY) + ); + assert_eq!( + data_state(&child(&root)), + Some(ExecutionDataState::INGESTION_ROWS) + ); + } + + #[test] + fn exact_accumulator_state_may_feed_another_summary_agg() { + let inner = agg(scan(), exact_sum()); + assert!(apply(&estimate(agg(inner, kll()))).is_ok()); + } + + #[test] + fn evaluation_can_feed_summary_construction_at_query_time() { + let inner = estimate(agg(scan(), kll())); + let root = apply(&estimate(agg(inner, kll()))).unwrap(); + assert_eq!(child(&root).timing, Some(ExecutionTiming::QueryTime)); + } + + /// Any non-ASAP operator over a evaluation runs at query time. + #[test] + fn query_time_operation_over_evaluation_is_legal_and_root_is_evaluation() { + let evaluation = || estimate(agg(scan(), kll())); + let sorted = OperatorNode::new_shared(crate::ir::Operator::NonASAP(NonASAPOp::Sort { + keys: vec![], + partition_by: Default::default(), + child: evaluation(), + })) + .unwrap(); + for root in [max(evaluation()), sorted] { + let root = apply(&root).unwrap(); + assert_eq!(data_state(&root), Some(ExecutionDataState::QUERY_ROWS)); + } + } + + #[test] + fn query_time_values_can_feed_query_time_summary_construction() { + let post = max(estimate(agg(scan(), kll()))); + let root = apply(&estimate(agg(post, kll()))).unwrap(); + assert_eq!(child(&root).timing, Some(ExecutionTiming::QueryTime)); + } + + #[test] + fn function_under_summary_agg_is_legal_but_not_at_root() { + let operation = placed_at_ingestion(max(scan())); + assert_eq!( + apply(&operation).err(), + Some(ExecutionDataStateError::MaintenanceRowsAtRoot) + ); + let root = apply(&estimate(agg(operation, kll()))).unwrap(); + let timed_operation = child(&child(&root)); + assert_eq!( + data_state(&timed_operation), + Some(ExecutionDataState::INGESTION_ROWS) + ); + } + + #[test] + fn function_over_evaluation_is_rejected() { + let operation = placed_at_ingestion(max(estimate(agg(scan(), kll())))); + assert!(matches!( + apply(&estimate(agg(operation, kll()))), + Err(ExecutionDataStateError::IllegalChildDataState { + child: ExecutionDataState::QUERY_ROWS, + .. + }) + )); + } + + /// One shared sub-DAG reached as maintenance input and as query-time + /// input cannot be executed once for both; splitting it by phase first + /// makes the plan timeable. + #[test] + fn a_shared_subtree_reached_at_two_timings_conflicts() { + let shared = scan(); + let root = OperatorNode::new_shared(crate::ir::Operator::NonASAP(NonASAPOp::Concat { + children: vec![ + max(estimate(agg(Rc::clone(&shared), kll()))), + max(Rc::clone(&shared)), + ], + discriminator_unique_key: None, + })) + .unwrap(); + assert_eq!( + apply(&root).err(), + Some(ExecutionDataStateError::ConflictingTiming { + first: ExecutionDataState::INGESTION_ROWS, + second: ExecutionDataState::QUERY_ROWS, + }) + ); + let split = split_shared_by_phase(&root, &LifecycleAssignment::default_maintained()); + assert!(apply(&split).is_ok()); + } + + /// Both paired operands must be plain; an unrelated state column is not + /// an input. + #[test] + fn pearson_corr_checks_both_operand_states() { + let corr_over = |state_column: usize| { + let mut fields = vec![ + Field::plain("ts", DataType::Timestamp, false), + Field::plain("x", DataType::Float64, false), + Field::plain("y", DataType::Float64, false), + Field::plain("unused", DataType::Float64, false), + ]; + fields[state_column].dtype = kll(); + aggregate( + AggIntent::PearsonCorr { left: 1, right: 2 }, + scan_with(fields), + ) + }; + for operand in [1, 2] { + assert!(matches!( + validate_default(&corr_over(operand), ExecutionTiming::QueryTime), + Err(ExecutionDataStateError::NonPlainOperand { .. }) + )); + } + validate_default(&corr_over(3), ExecutionTiming::QueryTime).unwrap(); + } +} diff --git a/crates/types/src/post_asap/execution_data_state.rs b/crates/types/src/post_asap/execution_data_state.rs index 91373816b..74f1d47d2 100644 --- a/crates/types/src/post_asap/execution_data_state.rs +++ b/crates/types/src/post_asap/execution_data_state.rs @@ -156,6 +156,18 @@ impl ExecutionDataStateEdge { /// it expects, and so tests can assert the *reason* a plan was rejected. #[derive(Debug, Clone, PartialEq, Eq, Error)] pub enum ExecutionDataStateError { + #[error("operator reached with conflicting execution timings: {first:?} and {second:?}")] + ConflictingTiming { + first: ExecutionDataState, + second: ExecutionDataState, + }, + #[error("{operator} node has no execution timing")] + UntimedNode { operator: &'static str }, + #[error("evaluation value under maintenance: {edge} received {child}")] + EvaluationUnderMaintenance { + edge: &'static str, + child: ExecutionDataState, + }, #[error("invalid maintained-population maintenance/readout contract")] InvalidMaintainedPopulation, /// A query-time value (`SummaryEstimate` / read-time `ValueOperation` output) From 78ee7c1aa5fda9e7ab12fb413d82f945b5f0edfe Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Sat, 3 Oct 2026 19:49:55 +0000 Subject: [PATCH 2/6] feat(ir): export timed plans as PhysicalASAPDAG The logical export carries no execution timing. Physical planning output needs it: reuse the logical payloads and node ids, and add each node's and edge's data state plus window compatibility, as the earlier post-ASAP export did. Coverage is carried through. Co-Authored-By: Claude Opus 5.5 --- crates/types/src/ir/mod.rs | 4 +- crates/types/src/ir/physical_export.rs | 463 +++++++++++++++++++++++++ crates/types/tests/physical_export.rs | 102 ++++++ 3 files changed, 568 insertions(+), 1 deletion(-) create mode 100644 crates/types/src/ir/physical_export.rs create mode 100644 crates/types/tests/physical_export.rs diff --git a/crates/types/src/ir/mod.rs b/crates/types/src/ir/mod.rs index be29f0f9c..688204a87 100644 --- a/crates/types/src/ir/mod.rs +++ b/crates/types/src/ir/mod.rs @@ -18,12 +18,14 @@ pub use scalar::{ExprSemantics, Predicate, ProjectItem, ScalarExpr, SortKey}; pub mod canonicalize; pub mod cse; pub mod flat; -pub mod schema_support; +/// Physical ASAP DAG: the flattened operators plus execution timing. +pub mod physical_export; /// Execution timing for physical plans: a lifecycle assignment expanded onto every node. pub mod timing; pub use timing::{ apply_lifecycle_timings, data_state, planned_data_state, split_shared_by_phase, validate_default, LifecycleAssignment, TimingMemo, }; +pub mod schema_support; /// Semantic observation coverage, separate from field layout and physical timing. pub mod summary_coverage; diff --git a/crates/types/src/ir/physical_export.rs b/crates/types/src/ir/physical_export.rs new file mode 100644 index 000000000..7aa065fe4 --- /dev/null +++ b/crates/types/src/ir/physical_export.rs @@ -0,0 +1,463 @@ +//! Physical ASAP DAG (planner-layering stage 2 output). +//! +//! The flattened operators of [`super::flat`], plus the execution timing +//! (data state) of every node and edge. The input must already be timed +//! ([`super::timing::apply_lifecycle_timings`]); export reads each node's +//! timing and does not re-run data-state validation. + +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::rc::Rc; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use super::asap::ASAPOp; +use super::flat::{flatten, NodeId}; +use super::node::{Operator, OperatorNode}; +use super::non_asap::NonASAPOp; +use super::operator_properties::Reduction; +use super::query::QueryRoot; +use super::timing::data_state; +use crate::post_asap::execution_data_state::{ + ExecutionDataState, ExecutionDataStateError, ExecutionTiming, +}; +use crate::post_asap::guarantee::ResultGuarantee; +use crate::pre_asap::schema::{FieldDataType, Schema}; + +pub const PHYSICAL_ASAP_DAG_WIRE_VERSION: u32 = 8; + +/// A node's operator, with each child replaced by its node id. +pub type PhysicalASAPOperatorPayload = Operator; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum EdgeRole { + Input, + Left, + Right, + /// The consumer reads the producer from inside one of its scalar + /// expressions (`scalar(v)`, a scalar subquery, `EXISTS`, `IN`). + ScalarRef, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum GroupingEdgeCompatibility { + Identical, + ConsumerCoarsensProducer, + Incompatible, + NotApplicable, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum WindowEdgeCompatibility { + /// Physical lowering must prove equal pane/query phase or install an + /// exact boundary residual. The logical DAG alone cannot make that claim. + #[serde(rename = "RequiresAlignedPanePhaseOrExactBoundaryResidual")] + RequiresAlignedPanePhaseOrExactWindowEdgeResidual, + NotApplicable, +} + +pub type PhysicalASAPNodeId = NodeId; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PhysicalASAPDAGNode { + pub id: PhysicalASAPNodeId, + pub payload: PhysicalASAPOperatorPayload, + /// Phase is a placement choice for every operator, independent of payload kind. + pub output_state: ExecutionDataState, + pub output_schema: Schema, + pub guarantee: Option, + #[serde(default)] + pub coverage: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PhysicalASAPDAGEdge { + pub producer: PhysicalASAPNodeId, + pub consumer: PhysicalASAPNodeId, + pub role: EdgeRole, + pub intermediate_schema: Schema, + pub data_state: ExecutionDataState, + pub grouping: GroupingEdgeCompatibility, + pub window: WindowEdgeCompatibility, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PhysicalASAPDAG { + pub nodes: Vec, + pub edges: Vec, + /// Semantic workload root. Physical query/precompute sinks are selected + /// downstream by the control plane. + pub root: PhysicalASAPNodeId, +} + +/// Versioned transport envelope for a physical ASAP DAG. +/// +/// Process boundaries exchange this envelope and call [`Self::validate`]. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PhysicalASAPDAGDocument { + pub schema_version: u32, + pub dag: PhysicalASAPDAG, +} + +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum PhysicalASAPDAGValidationError { + #[error("phase assignment must name every DAG node exactly once")] + IncompletePhaseAssignment, + #[error("ingestion node {consumer:?} depends on query node {producer:?}")] + QueryDependencyInIngestion { + producer: PhysicalASAPNodeId, + consumer: PhysicalASAPNodeId, + }, + #[error("unsupported physical ASAP DAG schema version {0}")] + UnsupportedVersion(u32), + #[error("duplicate physical ASAP node id {0:?}")] + DuplicateNodeId(PhysicalASAPNodeId), + #[error("physical ASAP DAG root {0:?} does not name a node")] + MissingRoot(PhysicalASAPNodeId), + #[error("edge endpoint {0:?} does not name a node")] + MissingEdgeEndpoint(PhysicalASAPNodeId), + #[error("edge {producer:?}->{consumer:?} schema differs from producer output")] + EdgeSchemaMismatch { + producer: PhysicalASAPNodeId, + consumer: PhysicalASAPNodeId, + }, + #[error("edge {producer:?}->{consumer:?} data state differs from producer output")] + EdgeDataStateMismatch { + producer: PhysicalASAPNodeId, + consumer: PhysicalASAPNodeId, + }, + #[error("physical ASAP DAG contains a cycle")] + Cycle, + #[error("physical ASAP node {0:?} is not reachable from the root")] + UnreachableNode(PhysicalASAPNodeId), + #[error("summary aggregate node {node:?} output schema does not contain its declared family")] + SummaryFamilySchemaMismatch { node: PhysicalASAPNodeId }, + #[error( + "summary aggregate node {node:?} declares grouping inconsistent with its sketch state" + )] + SummaryGroupingMismatch { node: PhysicalASAPNodeId }, +} + +impl PhysicalASAPDAGDocument { + pub fn new(dag: PhysicalASAPDAG) -> Self { + Self { + schema_version: PHYSICAL_ASAP_DAG_WIRE_VERSION, + dag, + } + } + + pub fn validate(&self) -> Result<(), PhysicalASAPDAGValidationError> { + if self.schema_version != PHYSICAL_ASAP_DAG_WIRE_VERSION { + return Err(PhysicalASAPDAGValidationError::UnsupportedVersion( + self.schema_version, + )); + } + self.dag.validate() + } +} + +impl PhysicalASAPDAG { + /// Assign execution phases without changing operator semantics. Phase choices + /// do not prove deployment support: callers must bind concrete implementations + /// and storage boundaries before installing this plan. + pub fn with_execution_phases( + &self, + phases: &BTreeMap, + ) -> Result { + self.validate()?; + if phases.len() != self.nodes.len() + || self.nodes.iter().any(|node| !phases.contains_key(&node.id)) + { + return Err(PhysicalASAPDAGValidationError::IncompletePhaseAssignment); + } + let mut dag = self.clone(); + for node in &mut dag.nodes { + node.output_state.timing = phases[&node.id]; + } + let states: HashMap<_, _> = dag.nodes.iter().map(|n| (n.id, n.output_state)).collect(); + for edge in &mut dag.edges { + edge.data_state = states[&edge.producer]; + } + dag.validate()?; + Ok(dag) + } + + pub fn validate(&self) -> Result<(), PhysicalASAPDAGValidationError> { + let mut nodes = HashMap::new(); + for node in &self.nodes { + if nodes.insert(node.id, node).is_some() { + return Err(PhysicalASAPDAGValidationError::DuplicateNodeId(node.id)); + } + if let Operator::ASAP(ASAPOp::SummaryAgg { + family, grouping, .. + }) = &node.payload + { + let mut found_family = false; + for field in &node.output_schema.fields { + if &field.dtype == family { + found_family = true; + } + if let FieldDataType::Sketch(_, schema_grouping) = &field.dtype { + if schema_grouping != grouping { + return Err(PhysicalASAPDAGValidationError::SummaryGroupingMismatch { + node: node.id, + }); + } + } + } + if !found_family { + return Err( + PhysicalASAPDAGValidationError::SummaryFamilySchemaMismatch { + node: node.id, + }, + ); + } + } + } + if !nodes.contains_key(&self.root) { + return Err(PhysicalASAPDAGValidationError::MissingRoot(self.root)); + } + let mut children: HashMap> = HashMap::new(); + for edge in &self.edges { + let producer = nodes.get(&edge.producer).ok_or( + PhysicalASAPDAGValidationError::MissingEdgeEndpoint(edge.producer), + )?; + if !nodes.contains_key(&edge.consumer) { + return Err(PhysicalASAPDAGValidationError::MissingEdgeEndpoint( + edge.consumer, + )); + } + if producer.output_state.timing == ExecutionTiming::QueryTime + && nodes[&edge.consumer].output_state.timing == ExecutionTiming::IngestionTime + { + return Err(PhysicalASAPDAGValidationError::QueryDependencyInIngestion { + producer: edge.producer, + consumer: edge.consumer, + }); + } + if edge.intermediate_schema != producer.output_schema { + return Err(PhysicalASAPDAGValidationError::EdgeSchemaMismatch { + producer: edge.producer, + consumer: edge.consumer, + }); + } + if edge.data_state != producer.output_state { + return Err(PhysicalASAPDAGValidationError::EdgeDataStateMismatch { + producer: edge.producer, + consumer: edge.consumer, + }); + } + children + .entry(edge.consumer) + .or_default() + .push(edge.producer); + } + fn visit( + id: PhysicalASAPNodeId, + children: &HashMap>, + visiting: &mut HashSet, + visited: &mut HashSet, + ) -> bool { + if visited.contains(&id) { + return true; + } + if !visiting.insert(id) { + return false; + } + if children + .get(&id) + .into_iter() + .flatten() + .any(|child| !visit(*child, children, visiting, visited)) + { + return false; + } + visiting.remove(&id); + visited.insert(id); + true + } + if !visit( + self.root, + &children, + &mut HashSet::new(), + &mut HashSet::new(), + ) { + return Err(PhysicalASAPDAGValidationError::Cycle); + } + fn mark( + id: PhysicalASAPNodeId, + children: &HashMap>, + reachable: &mut HashSet, + ) { + if !reachable.insert(id) { + return; + } + for child in children.get(&id).into_iter().flatten() { + mark(*child, children, reachable); + } + } + let mut reachable = HashSet::new(); + mark(self.root, &children, &mut reachable); + if let Some(id) = nodes.keys().find(|id| !reachable.contains(id)) { + return Err(PhysicalASAPDAGValidationError::UnreachableNode(*id)); + } + Ok(()) + } +} + +// ── Compilation from the IR ────────────────────────────────────────────── + +/// Compiler-local identity assignment. It deliberately retains `Rc` handles +/// and is not serialized; deployed artifacts persist the physical ASAP node ID +/// together with their physical materialization/query IDs. +#[derive(Debug, Clone)] +pub struct PhysicalASAPNodeIdentityMap { + nodes_by_id: Vec>, +} + +impl PhysicalASAPNodeIdentityMap { + pub fn node_id(&self, node: &Rc) -> Option { + self.nodes_by_id + .iter() + .position(|candidate| Rc::ptr_eq(candidate, node)) + } + + pub fn operator_node(&self, id: PhysicalASAPNodeId) -> Option<&Rc> { + self.nodes_by_id.get(id) + } +} + +#[derive(Debug, Clone)] +pub struct PhysicalASAPDAGCompilation { + pub dag: PhysicalASAPDAG, + pub node_ids: PhysicalASAPNodeIdentityMap, +} + +pub fn compile_physical_asap_dag( + root: &Rc, +) -> Result { + Ok(compile_physical_asap_dag_with_node_ids(root)?.dag) +} + +/// Export the timed DAG below `root`. Every reachable node must carry a +/// timing (see [`super::timing::apply_lifecycle_timings`]); the data-state +/// rules were checked by that pass and are not re-run here. +pub fn compile_physical_asap_dag_with_node_ids( + root: &Rc, +) -> Result { + let (flat, nodes_by_id) = flatten(&[QueryRoot::Operator(Rc::clone(root))]); + let QueryRoot::Operator(root) = flat.roots[0] else { + unreachable!("an operator root flattens to an operator root") + }; + let mut nodes: Vec = Vec::with_capacity(flat.nodes.len()); + let mut edges = Vec::new(); + for (id, flat_node) in flat.nodes.into_iter().enumerate() { + let node = &nodes_by_id[id]; + let output_state = data_state(node).ok_or(ExecutionDataStateError::UntimedNode { + operator: node.operator.kind_name(), + })?; + // Children come before their parents, so every producer is already in `nodes`. + for (producer, role) in edge_roles(&flat_node.operator) { + let producer_state = nodes[producer].output_state; + let maintenance_dependency = producer_state.timing == ExecutionTiming::IngestionTime + && output_state.timing == ExecutionTiming::IngestionTime; + edges.push(PhysicalASAPDAGEdge { + producer, + consumer: id, + role, + intermediate_schema: nodes[producer].output_schema.clone(), + data_state: producer_state, + grouping: grouping_compatibility(&nodes[producer].payload, &flat_node.operator), + window: if maintenance_dependency { + WindowEdgeCompatibility::RequiresAlignedPanePhaseOrExactWindowEdgeResidual + } else { + WindowEdgeCompatibility::NotApplicable + }, + }); + } + nodes.push(PhysicalASAPDAGNode { + id, + payload: flat_node.operator, + output_state, + output_schema: flat_node.schema, + guarantee: flat_node.guarantee, + coverage: flat_node.coverage, + }); + } + let dag = PhysicalASAPDAG { nodes, edges, root }; + dag.validate() + .expect("compiler emits a valid physical ASAP DAG"); + Ok(PhysicalASAPDAGCompilation { + dag, + node_ids: PhysicalASAPNodeIdentityMap { nodes_by_id }, + }) +} + +/// Each child of `operator` with its edge role: the operator inputs, then the +/// nodes read by its scalar expressions (the order of [`Operator::children`]). +fn edge_roles(operator: &Operator) -> Vec<(C, EdgeRole)> { + use EdgeRole::*; + let inputs: Vec = match operator { + Operator::NonASAP(op) => match op { + NonASAPOp::Join { .. } | NonASAPOp::SetOp { .. } | NonASAPOp::BinaryOp { .. } => { + vec![Left, Right] + } + NonASAPOp::Concat { children, .. } => vec![Input; children.len()], + NonASAPOp::Scan { .. } + | NonASAPOp::Values { .. } + | NonASAPOp::PromqlVectorFromScalar(_) => vec![], + _ => vec![Input], + }, + Operator::ASAP(op) => match op { + ASAPOp::SummarySubtract { .. } | ASAPOp::SummaryJoin { .. } => vec![Left, Right], + ASAPOp::SummaryMerge { children } => vec![Input; children.len()], + _ => vec![Input], + }, + }; + let children = operator.children(); + let scalar_refs = children.len() - inputs.len(); + children + .into_iter() + .copied() + .zip( + inputs + .into_iter() + .chain(std::iter::repeat_n(ScalarRef, scalar_refs)), + ) + .collect() +} + +fn grouping_compatibility( + producer: &Operator, + consumer: &Operator, +) -> GroupingEdgeCompatibility { + let ( + Operator::ASAP(ASAPOp::SummaryAgg { + reduction: producer, + .. + }), + Operator::ASAP(ASAPOp::SummaryAgg { + reduction: consumer, + .. + }), + ) = (producer, consumer) + else { + return GroupingEdgeCompatibility::NotApplicable; + }; + match (producer, consumer) { + (p, c) if p == c => GroupingEdgeCompatibility::Identical, + (Reduction::PerEntity, Reduction::Reduce(_)) => { + GroupingEdgeCompatibility::ConsumerCoarsensProducer + } + (Reduction::Reduce(p), Reduction::Reduce(c)) + if !p.is_without() && !c.is_without() && c.iter().all(|key| p.contains(key)) => + { + GroupingEdgeCompatibility::ConsumerCoarsensProducer + } + _ => GroupingEdgeCompatibility::Incompatible, + } +} diff --git a/crates/types/tests/physical_export.rs b/crates/types/tests/physical_export.rs new file mode 100644 index 000000000..8bc133b77 --- /dev/null +++ b/crates/types/tests/physical_export.rs @@ -0,0 +1,102 @@ +//! A timed plan exports as a PhysicalASAPDAG that keeps timing and coverage. +use asap_types::ir::physical_export::{ + compile_physical_asap_dag, PhysicalASAPDAGDocument, PhysicalASAPDAGValidationError, +}; +use asap_types::ir::summary_coverage::{CoverageRegion, SummaryCoverage}; +use asap_types::ir::{ + apply_lifecycle_timings, ASAPOp, LifecycleAssignment, NonASAPOp, Operator, OperatorNode, + TimingMemo, +}; +use asap_types::post_asap::{ExactKind, ExactParams, ExecutionTiming, SummaryUpdate}; +use asap_types::pre_asap::{ColumnRef, DataType, Field, FieldDataType, Reduction, Schema, Source}; +use std::rc::Rc; + +/// Scan(t) → SummaryAgg(sum by key) → FinalizeExactAccumulator, untimed. +fn plan() -> Rc { + let source = Source::Table { + table_ref: "t".into(), + }; + let scan = OperatorNode::new_shared(Operator::NonASAP(NonASAPOp::Scan { + source: source.clone(), + predicates: vec![], + schema: Schema::new(vec![ + Field::plain("key", DataType::Utf8, false), + Field::plain("value", DataType::Float64, false), + ]), + })) + .unwrap(); + let state = OperatorNode::new(Operator::ASAP(ASAPOp::SummaryAgg { + child: scan, + family: FieldDataType::ExactAggregate(ExactKind::Sum, ExactParams::Sum), + input: SummaryUpdate::column(ColumnRef::Named("value".into())), + reduction: Reduction::by(vec![0]), + grouping: Default::default(), + filter: None, + })) + .unwrap() + .with_coverage(SummaryCoverage { + source, + regions: vec![CoverageRegion { + time_ms: Some(0..60_000), + population: Default::default(), + }], + }) + .unwrap(); + OperatorNode::new_shared(Operator::ASAP(ASAPOp::FinalizeExactAccumulator { + child: Rc::new(state), + })) + .unwrap() +} + +/// Default lifecycle: the summary is maintained at ingestion time and read at query time. +#[test] +fn timed_plan_exports_with_timing_and_coverage() { + let timed = apply_lifecycle_timings( + &plan(), + &LifecycleAssignment::default_maintained(), + &mut TimingMemo::new(), + ) + .unwrap(); + let dag = compile_physical_asap_dag(&timed).unwrap(); + let document = PhysicalASAPDAGDocument::new(dag.clone()); + document.validate().unwrap(); + + let timings: Vec<_> = dag.nodes.iter().map(|n| n.output_state.timing).collect(); + assert_eq!( + timings, + vec![ + ExecutionTiming::IngestionTime, + ExecutionTiming::IngestionTime, + ExecutionTiming::QueryTime + ] + ); + assert!(dag.nodes[1].coverage.is_some()); + + let decoded: PhysicalASAPDAGDocument = + serde_json::from_str(&serde_json::to_string(&document).unwrap()).unwrap(); + assert_eq!(decoded, document); +} + +/// A query-time producer cannot feed an ingestion-time consumer. +#[test] +fn query_time_input_to_ingestion_is_rejected() { + let timed = apply_lifecycle_timings( + &plan(), + &LifecycleAssignment::default_maintained(), + &mut TimingMemo::new(), + ) + .unwrap(); + let mut dag = compile_physical_asap_dag(&timed).unwrap(); + dag.nodes[0].output_state.timing = ExecutionTiming::QueryTime; + dag.edges[0].data_state = dag.nodes[0].output_state; + assert!(matches!( + dag.validate(), + Err(PhysicalASAPDAGValidationError::QueryDependencyInIngestion { .. }) + )); +} + +/// Untimed plans cannot be exported as physical plans. +#[test] +fn untimed_plan_is_rejected() { + assert!(compile_physical_asap_dag(&plan()).is_err()); +} From 14aaa1458e26d2e4b9fb65591f2c0dca6675f9c2 Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Fri, 2 Oct 2026 21:20:43 +0000 Subject: [PATCH 3/6] feat(runtime): compile unified operator and scalar graphs --- Cargo.lock | 1 + crates/asap-physical-operators/Cargo.toml | 1 + .../src/expressions/binary.rs | 3 + .../src/expressions/mod.rs | 11 + .../src/expressions/planner.rs | 4 - .../src/expressions/unified_planner.rs | 738 ++++++++ crates/asap-physical-operators/src/lib.rs | 3 + .../src/operators/joins/mod.rs | 36 +- .../src/operators/mod.rs | 2 +- .../src/operators/unchecked.rs | 6 +- .../unified_physical_planner/candidates.rs | 327 ++++ .../src/unified_physical_planner/compiled.rs | 352 ++++ .../src/unified_physical_planner/logical.rs | 374 ++++ .../src/unified_physical_planner/mod.rs | 1169 ++++++++++++ .../unified_physical_planner/precompute.rs | 643 +++++++ .../promql_fallback.rs | 859 +++++++++ .../unified_physical_planner/promql_rows.rs | 303 ++++ .../unified_physical_planner/promql_values.rs | 281 +++ .../unified_physical_planner/row_values.rs | 60 + .../src/unified_sources/memory.rs | 44 + .../src/unified_sources/mod.rs | 177 ++ .../tests/unified_common/mod.rs | 15 + .../tests/unified_promql_fallback.rs | 1611 +++++++++++++++++ crates/types/src/post_asap/expr.rs | 18 + 24 files changed, 7026 insertions(+), 12 deletions(-) create mode 100644 crates/asap-physical-operators/src/expressions/binary.rs create mode 100644 crates/asap-physical-operators/src/expressions/unified_planner.rs create mode 100644 crates/asap-physical-operators/src/unified_physical_planner/candidates.rs create mode 100644 crates/asap-physical-operators/src/unified_physical_planner/compiled.rs create mode 100644 crates/asap-physical-operators/src/unified_physical_planner/logical.rs create mode 100644 crates/asap-physical-operators/src/unified_physical_planner/mod.rs create mode 100644 crates/asap-physical-operators/src/unified_physical_planner/precompute.rs create mode 100644 crates/asap-physical-operators/src/unified_physical_planner/promql_fallback.rs create mode 100644 crates/asap-physical-operators/src/unified_physical_planner/promql_rows.rs create mode 100644 crates/asap-physical-operators/src/unified_physical_planner/promql_values.rs create mode 100644 crates/asap-physical-operators/src/unified_physical_planner/row_values.rs create mode 100644 crates/asap-physical-operators/src/unified_sources/memory.rs create mode 100644 crates/asap-physical-operators/src/unified_sources/mod.rs create mode 100644 crates/asap-physical-operators/tests/unified_common/mod.rs create mode 100644 crates/asap-physical-operators/tests/unified_promql_fallback.rs diff --git a/Cargo.lock b/Cargo.lock index feed74cf1..79da8962c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -406,6 +406,7 @@ dependencies = [ "asap-frontend-promql", "asap-types", "asap_sketchlib 0.3.0 (git+https://github.com/ProjectASAP/asap_sketchlib?rev=5f03ccbd798ed5fec62bdd839bcb331123cab369)", + "chrono", "futures", "regex", "rmp-serde", diff --git a/crates/asap-physical-operators/Cargo.toml b/crates/asap-physical-operators/Cargo.toml index 193707c48..5d33b6086 100644 --- a/crates/asap-physical-operators/Cargo.toml +++ b/crates/asap-physical-operators/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] +chrono = { version = "=0.4.39", default-features = false, features = ["std"] } futures = "0.3" planner-types = { package = "asap-types", path = "../types" } asap_sketchlib = { git = "https://github.com/ProjectASAP/asap_sketchlib", rev = "5f03ccbd798ed5fec62bdd839bcb331123cab369" } diff --git a/crates/asap-physical-operators/src/expressions/binary.rs b/crates/asap-physical-operators/src/expressions/binary.rs new file mode 100644 index 000000000..c115fd908 --- /dev/null +++ b/crates/asap-physical-operators/src/expressions/binary.rs @@ -0,0 +1,3 @@ +//! Temporary kernel aliases during the unified compiler migration. +pub use planner_types::post_asap::BinaryOperator; +pub use planner_types::pre_asap::BinaryOpKind; diff --git a/crates/asap-physical-operators/src/expressions/mod.rs b/crates/asap-physical-operators/src/expressions/mod.rs index 8947743dc..5c1a29576 100644 --- a/crates/asap-physical-operators/src/expressions/mod.rs +++ b/crates/asap-physical-operators/src/expressions/mod.rs @@ -5,7 +5,9 @@ use crate::{ }; use planner_types::pre_asap::{ArithmeticOpKind, DataType}; pub mod arithmetic; +pub mod binary; mod planner; +pub mod unified_planner; pub use planner::CompiledExpression; #[derive(serde::Serialize, serde::Deserialize, Clone, Debug)] pub enum Expression { @@ -15,6 +17,7 @@ pub enum Expression { right: Box, }, Planner(Box), + UnifiedPlanner(Box), Column(usize), ExactFloat64(usize), FiniteFloat64(Box), @@ -53,6 +56,9 @@ pub enum Expression { IsNull(Box), } impl Expression { + pub fn unified_planner(expression: unified_planner::CompiledExpression) -> Self { + Self::UnifiedPlanner(Box::new(expression)) + } pub fn planner(expression: crate::expressions::CompiledExpression) -> Self { Self::Planner(Box::new(expression)) } @@ -99,6 +105,10 @@ impl Expression { }; Ok((dtype, n || m)) } + UnifiedPlanner(expression) => { + expression.validate_input(input)?; + Ok(expression.dtype()) + } Planner(expression) => { expression.validate_input(input)?; Ok(expression.dtype()) @@ -286,6 +296,7 @@ impl Expression { } } Planner(expression) => expression.evaluate(row)?, + UnifiedPlanner(expression) => expression.evaluate(row)?, Label { column, name } => { let Value::Map(entries) = &row[*column] else { return Err(invalid("label read requires a map")); diff --git a/crates/asap-physical-operators/src/expressions/planner.rs b/crates/asap-physical-operators/src/expressions/planner.rs index ae04b3f7c..2044d6e16 100644 --- a/crates/asap-physical-operators/src/expressions/planner.rs +++ b/crates/asap-physical-operators/src/expressions/planner.rs @@ -336,10 +336,6 @@ pub struct CompiledExpression { output: (DataType, bool), } impl CompiledExpression { - pub(crate) fn expression(&self) -> &QueryExpr { - &self.expression - } - pub fn compile(expression: &QueryExpr, input: &SchemaRef) -> Result { let schema = input .fields diff --git a/crates/asap-physical-operators/src/expressions/unified_planner.rs b/crates/asap-physical-operators/src/expressions/unified_planner.rs new file mode 100644 index 000000000..a23b3dab1 --- /dev/null +++ b/crates/asap-physical-operators/src/expressions/unified_planner.rs @@ -0,0 +1,738 @@ +//! Planner scalar expressions evaluated over native typed rows. +use crate::{ + values::{SchemaRef, Value}, + Error, +}; +use planner_types::pre_asap::{ArithmeticOpKind, CompareOpKind, DataType, ScalarValue}; + +use planner_types::ir::ScalarExpr; +use std::{cmp::Ordering, sync::Arc}; + +pub(super) fn evaluate( + expr: &ScalarExpr, + row: &[Value], + schema: &planner_types::pre_asap::Schema, +) -> Result { + match expr { + ScalarExpr::Column(index) => row.get(*index).cloned().ok_or(Error::Invalid(format!( + "column {index} outside row width {}", + row.len() + ))), + ScalarExpr::Literal(value) => Ok(match value { + ScalarValue::Interval { + months, + days, + nanos, + } => Value::Interval { + months: *months, + days: *days, + nanos: *nanos, + }, + ScalarValue::Int64(value) => Value::Int64(*value), + ScalarValue::Float64(value) => Value::Float64(*value), + ScalarValue::Utf8(value) => Value::Utf8(value.clone().into()), + ScalarValue::Boolean(value) => Value::Bool(*value), + ScalarValue::Null => Value::Null, + }), + ScalarExpr::Cast { expr, to, .. } => { + let value = evaluate(expr, row, schema)?; + match (value, to) { + (Value::Null, _) => Ok(Value::Null), + (Value::Int64(value), DataType::Float64) => Ok(Value::Float64(value as f64)), + (value, _) + if expr + .scalar_type(schema) + .map_err(|e| Error::Invalid(e.to_string()))? + .0 + == *to => + { + Ok(value) + } + _ => Err(Error::Invalid("unsupported cast".into())), + } + } + ScalarExpr::Negative { expr, .. } => match evaluate(expr, row, schema)? { + Value::Float64(v) => Ok(Value::Float64(-v)), + Value::Int64(v) => v + .checked_neg() + .map(Value::Int64) + .ok_or_else(|| Error::Invalid("integer negation overflow".into())), + Value::Null => Ok(Value::Null), + _ => Err(Error::Invalid("invalid negation input".into())), + }, + ScalarExpr::Compare { + left, op, right, .. + } => { + let left = evaluate(left, row, schema)?; + let right = evaluate(right, row, schema)?; + compare(op, left, right) + } + ScalarExpr::Arithmetic { + op, left, right, .. + } => arithmetic( + op, + evaluate(left, row, schema)?, + evaluate(right, row, schema)?, + ), + ScalarExpr::Case { + operand: None, + branches, + else_expr, + } => { + for (condition, value) in branches { + if matches!(evaluate(condition, row, schema)?, Value::Bool(true)) { + return evaluate(value, row, schema); + } + } + else_expr + .as_ref() + .map_or(Ok(Value::Null), |e| evaluate(e, row, schema)) + } + ScalarExpr::BoolAnd(parts) | ScalarExpr::BoolOr(parts) => { + let and = matches!(expr, ScalarExpr::BoolAnd(_)); + let mut null = false; + for part in parts { + match evaluate(part, row, schema)? { + Value::Bool(value) if value != and => return Ok(Value::Bool(value)), + Value::Bool(_) => {} + Value::Null => null = true, + _ => return Err(Error::Invalid("boolean predicate required".into())), + } + } + Ok(if null { Value::Null } else { Value::Bool(and) }) + } + ScalarExpr::Not(value) => match evaluate(value, row, schema)? { + Value::Bool(value) => Ok(Value::Bool(!value)), + Value::Null => Ok(Value::Null), + _ => Err(Error::Invalid("boolean predicate required".into())), + }, + ScalarExpr::IsNull(value) => Ok(Value::Bool(matches!( + evaluate(value, row, schema)?, + Value::Null + ))), + ScalarExpr::IsNotNull(value) => Ok(Value::Bool(!matches!( + evaluate(value, row, schema)?, + Value::Null + ))), + ScalarExpr::FunctionCall { name, args } => { + use planner_types::pre_asap::scalar_type_rules::MapScalarFunction; + if planner_types::pre_asap::scalar_type_rules::promql_function_arity(name).is_some() { + let values = args + .iter() + .map(|arg| match evaluate(arg, row, schema)? { + Value::Float64(v) => Ok(v), + _ => Err(Error::Invalid("PromQL function requires floats".into())), + }) + .collect::, _>>()?; + return Ok(Value::Float64(promql_function(name, &values)?)); + } + if name == "promql_drop_metric_name" { + let Value::Utf8(encoded) = evaluate(&args[0], row, schema)? else { + return Err(Error::Invalid("series identity must be Utf8".into())); + }; + let mut labels: std::collections::BTreeMap = + serde_json::from_str(&encoded).map_err(|e| Error::Invalid(e.to_string()))?; + labels.remove("__name__"); + return Ok(Value::Utf8( + serde_json::to_string(&labels) + .map_err(|e| Error::Invalid(e.to_string()))? + .into(), + )); + } + if name.eq_ignore_ascii_case("asap_struct_field") { + expr.scalar_type(schema) + .map_err(|error| Error::Invalid(error.to_string()))?; + let DataType::Struct { fields } = args[0] + .scalar_type(schema) + .map_err(|error| Error::Invalid(error.to_string()))? + .0 + else { + unreachable!() + }; + let offset = match &args[1] { + ScalarExpr::Literal(ScalarValue::Int64(index)) => { + usize::try_from(index - 1).ok() + } + ScalarExpr::Literal(ScalarValue::Utf8(name)) => { + fields.iter().position(|field| &field.name == name) + } + _ => None, + } + .ok_or_else(|| Error::Invalid("struct field selector".into()))?; + let Value::Struct(values) = evaluate(&args[0], row, schema)? else { + return Err(Error::Invalid("struct field input".into())); + }; + return values + .get(offset) + .cloned() + .ok_or_else(|| Error::Invalid("struct field value".into())); + } + if name.eq_ignore_ascii_case("asap_element_access") { + let (output_type, _) = expr + .scalar_type(schema) + .map_err(|error| Error::Invalid(error.to_string()))?; + if let DataType::List { element } = args[0] + .scalar_type(schema) + .map_err(|error| Error::Invalid(error.to_string()))? + .0 + { + let Value::List(values) = evaluate(&args[0], row, schema)? else { + return Err(Error::Invalid("array access input".into())); + }; + let index = match evaluate(&args[1], row, schema)? { + Value::Null => return Ok(Value::Null), + Value::Int64(index) => index, + _ => return Err(Error::Invalid("array access index".into())), + }; + let offset = if index > 0 { + usize::try_from(index - 1).ok() + } else if index < 0 { + usize::try_from(index.unsigned_abs()) + .ok() + .and_then(|distance| values.len().checked_sub(distance)) + } else { + None + }; + return match offset.and_then(|offset| values.get(offset)) { + Some(value) => Ok(value.clone()), + None => default_collection_element(&output_type, element.nullable), + }; + } + } + let function = (if name.eq_ignore_ascii_case("asap_element_access") { + Some(MapScalarFunction::Access) + } else { + MapScalarFunction::from_name(name) + }) + .ok_or_else(|| Error::Invalid(format!("scalar function {name}")))?; + expr.scalar_type(schema) + .map_err(|error| Error::Invalid(error.to_string()))?; + let values = args + .iter() + .map(|arg| evaluate(arg, row, schema)) + .collect::, _>>()?; + match function { + MapScalarFunction::Construct => { + let mut values = values.into_iter(); + let mut entries = Vec::new(); + while let Some(key) = values.next() { + if !matches!(key, Value::Int64(_) | Value::Utf8(_) | Value::Bool(_)) { + return Err(Error::Invalid("map key value type".into())); + } + entries.push(( + key, + values + .next() + .ok_or_else(|| Error::Invalid("odd map argument count".into()))?, + )); + } + Ok(Value::Map(entries.into())) + } + MapScalarFunction::Concat => { + let mut entries = Vec::new(); + for value in values { + let Value::Map(next) = value else { + return Err(Error::Invalid("map concat argument".into())); + }; + entries.extend(next.iter().cloned()); + } + Ok(Value::Map(entries.into())) + } + MapScalarFunction::Access => { + let [Value::Map(entries), key] = values.as_slice() else { + return Err(Error::Invalid("map access arguments".into())); + }; + if matches!(key, Value::Null) { + return Ok(Value::Null); + } + if !matches!(key, Value::Int64(_) | Value::Utf8(_) | Value::Bool(_)) { + return Err(Error::Invalid("map lookup key type".into())); + } + if let Some((_, value)) = entries + .iter() + .find(|(candidate, _)| cell_cmp(candidate, key) == Some(Ordering::Equal)) + { + return Ok(value.clone()); + } + let ( + DataType::Map { + value, + value_nullable, + .. + }, + _, + ) = args[0] + .scalar_type(schema) + .map_err(|error| Error::Invalid(error.to_string()))? + else { + unreachable!() + }; + default_collection_element(&value, value_nullable) + } + } + } + other => Err(Error::Invalid(format!("scalar expression {other:?}"))), + } +} + +fn default_collection_element(dtype: &DataType, nullable: bool) -> Result { + if nullable { + return Ok(Value::Null); + } + Ok(match dtype { + DataType::Interval | DataType::Date => { + return Err(Error::Invalid("temporal value transport".into())) + } + DataType::Null => Value::Null, + DataType::Int64 => Value::Int64(0), + DataType::Float64 => Value::Float64(0.0), + DataType::Utf8 => Value::Utf8("".into()), + DataType::Bool => Value::Bool(false), + DataType::Map { .. } => Value::Map(Arc::from([])), + DataType::List { .. } => Value::List(Arc::from([])), + DataType::Struct { fields } => Value::Struct( + fields + .iter() + .map(|field| default_collection_element(&field.dtype, field.nullable)) + .collect::, _>>()? + .into(), + ), + _ => { + return Err(Error::Invalid( + "collection missing-element default type".into(), + )) + } + }) +} + +fn compare(op: &CompareOpKind, left: Value, right: Value) -> Result { + if matches!(left, Value::Null) || matches!(right, Value::Null) { + return Ok(Value::Null); + } + // NaN is unordered, not a type mismatch. Match the native scalar path. + if matches!(&left, Value::Float64(v) if v.is_nan()) + || matches!(&right, Value::Float64(v) if v.is_nan()) + { + return match op { + CompareOpKind::Ne => Ok(Value::Bool(true)), + CompareOpKind::Eq + | CompareOpKind::Lt + | CompareOpKind::Le + | CompareOpKind::Gt + | CompareOpKind::Ge => Ok(Value::Bool(false)), + _ => Err(Error::Invalid(format!("comparison {op:?}"))), + }; + } + let ordering = cell_cmp(&left, &right) + .ok_or_else(|| Error::Invalid("comparison of incompatible values".into()))?; + let value = match op { + CompareOpKind::Eq => ordering == Ordering::Equal, + CompareOpKind::Ne => ordering != Ordering::Equal, + CompareOpKind::Lt => ordering == Ordering::Less, + CompareOpKind::Le => ordering != Ordering::Greater, + CompareOpKind::Gt => ordering == Ordering::Greater, + CompareOpKind::Ge => ordering != Ordering::Less, + _ => return Err(Error::Invalid(format!("comparison {op:?}"))), + }; + Ok(Value::Bool(value)) +} + +fn arithmetic(op: &ArithmeticOpKind, left: Value, right: Value) -> Result { + let (left, right) = match (left, right) { + (Value::Int64(a), Value::Float64(b)) => (Value::Float64(a as f64), Value::Float64(b)), + (Value::Float64(a), Value::Int64(b)) => (Value::Float64(a), Value::Float64(b as f64)), + pair => pair, + }; + super::numeric(op, left, right) +} + +fn integer_float_cmp(integer: i64, float: f64) -> Option { + if float.is_nan() { + return None; + } + // These bounds are powers of two, exactly representable as Float64. + if float >= 9_223_372_036_854_775_808.0 { + return Some(Ordering::Less); + } + if float < -9_223_372_036_854_775_808.0 { + return Some(Ordering::Greater); + } + let integral = float as i64; + match integer.cmp(&integral) { + Ordering::Equal => 0.0_f64.partial_cmp(&float.fract()), + other => Some(other), + } +} + +fn cell_cmp(left: &Value, right: &Value) -> Option { + match (left, right) { + (Value::Int64(left), Value::Int64(right)) => Some(left.cmp(right)), + (Value::Float64(left), Value::Float64(right)) => left.partial_cmp(right), + (Value::Int64(left), Value::Float64(right)) => integer_float_cmp(*left, *right), + (Value::Float64(left), Value::Int64(right)) => { + integer_float_cmp(*right, *left).map(Ordering::reverse) + } + (Value::Utf8(left), Value::Utf8(right)) => Some(left.cmp(right)), + (Value::Bool(left), Value::Bool(right)) => Some(left.cmp(right)), + (Value::Timestamp(left), Value::Timestamp(right)) => Some(left.cmp(right)), + (Value::Map(left), Value::Map(right)) => { + for ((left_key, left_value), (right_key, right_value)) in left.iter().zip(right.iter()) + { + let order = cell_cmp(left_key, right_key)?; + if order != Ordering::Equal { + return Some(order); + } + let order = match (left_value, right_value) { + (Value::Null, Value::Null) => Ordering::Equal, + (Value::Null, _) => Ordering::Greater, + (_, Value::Null) => Ordering::Less, + _ => cell_cmp(left_value, right_value)?, + }; + if order != Ordering::Equal { + return Some(order); + } + } + Some(left.len().cmp(&right.len())) + } + _ => None, + } +} + +fn promql_function(name: &str, args: &[f64]) -> Result { + let x = args[0]; + Ok(match &name[7..] { + "abs" => x.abs(), + "ceil" => x.ceil(), + "floor" => x.floor(), + "exp" => x.exp(), + "ln" => x.ln(), + "log2" => x.log2(), + "log10" => x.log10(), + "sqrt" => x.sqrt(), + "sgn" => { + if x.is_nan() { + f64::NAN + } else if x == 0.0 { + 0.0 + } else { + x.signum() + } + } + "sin" => x.sin(), + "cos" => x.cos(), + "tan" => x.tan(), + "asin" => x.asin(), + "acos" => x.acos(), + "atan" => x.atan(), + "sinh" => x.sinh(), + "cosh" => x.cosh(), + "tanh" => x.tanh(), + "asinh" => x.asinh(), + "acosh" => x.acosh(), + "atanh" => x.atanh(), + "deg" => x.to_degrees(), + "rad" => x.to_radians(), + "round" => { + let inverse = 1.0 / args[1]; + (x * inverse + 0.5).floor() / inverse + } + "clamp_min" => { + if x.is_nan() || args[1].is_nan() { + f64::NAN + } else { + x.max(args[1]) + } + } + "clamp_max" => { + if x.is_nan() || args[1].is_nan() { + f64::NAN + } else { + x.min(args[1]) + } + } + "clamp" => { + if args.iter().any(|x| x.is_nan()) { + f64::NAN + } else { + x.max(args[1]).min(args[2]) + } + } + part => { + use chrono::{Datelike, Timelike}; + if !x.is_finite() || x < i64::MIN as f64 || x >= i64::MAX as f64 { + return Ok(f64::NAN); + } + let Some(date) = chrono::DateTime::from_timestamp(x as i64, 0) else { + return Ok(f64::NAN); + }; + match part { + "minute" => date.minute() as f64, + "hour" => date.hour() as f64, + "day_of_week" => date.weekday().num_days_from_sunday() as f64, + "day_of_month" => date.day() as f64, + "day_of_year" => date.ordinal() as f64, + "month" => date.month() as f64, + "year" => date.year() as f64, + "days_in_month" => { + let year = date.year(); + let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0); + match date.month() { + 2 => { + if leap { + 29.0 + } else { + 28.0 + } + } + 4 | 6 | 9 | 11 => 30.0, + _ => 31.0, + } + } + _ => return Err(Error::Invalid("unregistered PromQL function".into())), + } + } + }) +} + +#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)] +pub struct CompiledExpression { + expression: ScalarExpr, + schema: planner_types::pre_asap::Schema, + output: (DataType, bool), +} +impl CompiledExpression { + pub fn compile(expression: &ScalarExpr, input: &SchemaRef) -> Result { + if !input.is_all_plain() { + return Err(Error::Invalid( + "scalar expression cannot consume summary state".into(), + )); + } + let schema = input.as_ref().clone(); + validate(expression, &schema)?; + let output = expression + .scalar_type(&schema) + .map_err(|e| Error::Invalid(e.to_string()))?; + Ok(Self { + expression: expression.clone(), + schema, + output, + }) + } + pub(crate) fn dtype(&self) -> (DataType, bool) { + self.output.clone() + } + pub(crate) fn validate_input(&self, input: &SchemaRef) -> Result<(), Error> { + let checked = Self::compile(&self.expression, input)?; + if checked.output != self.output { + return Err(Error::Invalid( + "persisted expression type differs from its semantics".into(), + )); + } + if input.fields.len() != self.schema.fields.len() + || input + .fields + .iter() + .zip(&self.schema.fields) + .any(|(field, column)| { + field.dtype != column.dtype.clone() || field.nullable != column.nullable + }) + { + return Err(Error::Invalid( + "expression input differs from its bound schema".into(), + )); + } + Ok(()) + } + /// Evaluate a row under the same typed schema used when binding the expression. + pub fn evaluate(&self, row: &[Value]) -> Result { + if row.len() != self.schema.fields.len() + || row.iter().zip(&self.schema.fields).any(|(value, column)| { + !column + .plain_dtype() + .is_some_and(|dtype| value.matches(dtype, column.nullable)) + }) + { + return Err(Error::Invalid( + "expression input differs from its bound schema".into(), + )); + } + evaluate(&self.expression, row, &self.schema) + } +} +fn validate(expr: &ScalarExpr, schema: &planner_types::pre_asap::Schema) -> Result<(), Error> { + let invalid = || Error::Invalid(format!("unsupported scalar expression: {expr:?}")); + expr.scalar_type(schema) + .map_err(|e| Error::Invalid(e.to_string()))?; + match expr { + ScalarExpr::Column(_) | ScalarExpr::Literal(_) => Ok(()), + ScalarExpr::Cast { expr, to, .. } => { + let source = expr + .scalar_type(schema) + .map_err(|e| Error::Invalid(e.to_string()))? + .0; + if source != *to + && source != DataType::Null + && !(source == DataType::Int64 && *to == DataType::Float64) + { + return Err(invalid()); + } + validate(expr, schema) + } + ScalarExpr::Negative { expr, .. } => validate(expr, schema), + ScalarExpr::Arithmetic { left, right, .. } => { + for value in [left, right] { + validate(value, schema)?; + if !matches!( + value + .scalar_type(schema) + .map_err(|e| Error::Invalid(e.to_string()))? + .0, + DataType::Int64 | DataType::Float64 | DataType::Null + ) { + return Err(invalid()); + } + } + Ok(()) + } + ScalarExpr::Compare { + left, right, op, .. + } => { + if !matches!( + op, + CompareOpKind::Eq + | CompareOpKind::Ne + | CompareOpKind::Lt + | CompareOpKind::Le + | CompareOpKind::Gt + | CompareOpKind::Ge + ) { + return Err(invalid()); + } + validate(left, schema)?; + validate(right, schema)?; + let (a, _) = left + .scalar_type(schema) + .map_err(|e| Error::Invalid(e.to_string()))?; + let (b, _) = right + .scalar_type(schema) + .map_err(|e| Error::Invalid(e.to_string()))?; + fn comparable(dtype: &DataType) -> bool { + match dtype { + DataType::Null + | DataType::Int64 + | DataType::Float64 + | DataType::Utf8 + | DataType::Bool + | DataType::Timestamp => true, + DataType::Map { key, value, .. } => comparable(key) && comparable(value), + _ => false, + } + } + let numeric = |dtype: &DataType| matches!(dtype, DataType::Int64 | DataType::Float64); + if !comparable(&a) + || !comparable(&b) + || (a != b + && !matches!(a, DataType::Null) + && !matches!(b, DataType::Null) + && !(numeric(&a) && numeric(&b))) + { + return Err(invalid()); + } + Ok(()) + } + ScalarExpr::FunctionCall { name, args } => { + if name != "promql_drop_metric_name" + && planner_types::pre_asap::scalar_type_rules::promql_function_arity(name).is_none() + && name != "asap_struct_field" + && name != "asap_element_access" + && planner_types::pre_asap::scalar_type_rules::MapScalarFunction::from_name(name) + .is_none() + { + return Err(invalid()); + } + for arg in args { + validate(arg, schema)?; + } + Ok(()) + } + ScalarExpr::Case { + operand: None, + branches, + else_expr, + } => { + for (condition, value) in branches { + validate(condition, schema)?; + if condition + .scalar_type(schema) + .map_err(|e| Error::Invalid(e.to_string()))? + .0 + != DataType::Bool + { + return Err(invalid()); + } + validate(value, schema)?; + } + if let Some(value) = else_expr { + validate(value, schema)?; + } + Ok(()) + } + ScalarExpr::BoolAnd(parts) | ScalarExpr::BoolOr(parts) => { + for part in parts { + validate(part, schema)?; + if !matches!( + part.scalar_type(schema) + .map_err(|e| Error::Invalid(e.to_string()))? + .0, + DataType::Bool | DataType::Null + ) { + return Err(invalid()); + } + } + Ok(()) + } + ScalarExpr::Not(value) => { + validate(value, schema)?; + if !matches!( + value + .scalar_type(schema) + .map_err(|e| Error::Invalid(e.to_string()))? + .0, + DataType::Bool | DataType::Null + ) { + return Err(invalid()); + } + Ok(()) + } + ScalarExpr::IsNull(value) | ScalarExpr::IsNotNull(value) => validate(value, schema), + _ => Err(invalid()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn mixed_comparison_preserves_integer_precision_and_boundaries() { + assert_eq!( + integer_float_cmp(9_007_199_254_740_993, 9_007_199_254_740_992.0), + Some(Ordering::Greater) + ); + assert_eq!( + integer_float_cmp(i64::MAX, 9_223_372_036_854_775_808.0), + Some(Ordering::Less) + ); + assert_eq!( + integer_float_cmp(i64::MIN, -9_223_372_036_854_775_808.0), + Some(Ordering::Equal) + ); + assert_eq!(integer_float_cmp(-1, -1.5), Some(Ordering::Greater)); + assert_eq!(integer_float_cmp(1, 1.5), Some(Ordering::Less)); + assert_eq!(integer_float_cmp(0, f64::INFINITY), Some(Ordering::Less)); + assert_eq!( + integer_float_cmp(0, f64::NEG_INFINITY), + Some(Ordering::Greater) + ); + assert_eq!(integer_float_cmp(0, f64::NAN), None); + } +} diff --git a/crates/asap-physical-operators/src/lib.rs b/crates/asap-physical-operators/src/lib.rs index 345ee762d..586da2508 100644 --- a/crates/asap-physical-operators/src/lib.rs +++ b/crates/asap-physical-operators/src/lib.rs @@ -31,3 +31,6 @@ pub mod plan; pub mod runtime; pub mod sources; pub mod values; + +pub mod unified_physical_planner; +pub mod unified_sources; diff --git a/crates/asap-physical-operators/src/operators/joins/mod.rs b/crates/asap-physical-operators/src/operators/joins/mod.rs index 76e2f748c..9dfd20dea 100644 --- a/crates/asap-physical-operators/src/operators/joins/mod.rs +++ b/crates/asap-physical-operators/src/operators/joins/mod.rs @@ -47,13 +47,43 @@ impl Operator { kind: planner_types::pre_asap::JoinKind, predicate: &planner_types::pre_asap::Predicate, output: SchemaRef, + ) -> Result { + let mut joined = left.fields.clone(); + joined.extend(right.fields.clone()); + let predicate = Expression::planner(crate::expressions::CompiledExpression::compile( + &predicate.0, + &schema(joined), + )?); + Self::bound_relational_join(left, right, kind, predicate, output) + } + pub fn unified_relational_join( + left: SchemaRef, + right: SchemaRef, + kind: planner_types::pre_asap::JoinKind, + predicate: &planner_types::ir::Predicate, + output: SchemaRef, + ) -> Result { + let mut joined = left.fields.clone(); + joined.extend(right.fields.clone()); + let predicate = Expression::unified_planner( + crate::expressions::unified_planner::CompiledExpression::compile( + &predicate.0, + &schema(joined), + )?, + ); + Self::bound_relational_join(left, right, kind, predicate, output) + } + pub(crate) fn bound_relational_join( + left: SchemaRef, + right: SchemaRef, + kind: planner_types::pre_asap::JoinKind, + predicate: Expression, + output: SchemaRef, ) -> Result { use planner_types::pre_asap::JoinKind; let mut joined = left.fields.clone(); joined.extend(right.fields.clone()); - let predicate = - crate::expressions::CompiledExpression::compile(&predicate.0, &schema(joined.clone()))?; - if predicate.dtype().0 != DataType::Bool { + if predicate.dtype(&schema(joined.clone()))?.0 != DataType::Bool { return Err(invalid("join predicate must be boolean")); } let fields = if matches!(kind, JoinKind::Semi | JoinKind::Anti) { diff --git a/crates/asap-physical-operators/src/operators/mod.rs b/crates/asap-physical-operators/src/operators/mod.rs index 4c7e5f5c6..75b313d75 100644 --- a/crates/asap-physical-operators/src/operators/mod.rs +++ b/crates/asap-physical-operators/src/operators/mod.rs @@ -130,7 +130,7 @@ enum Kind { }, Join { kind: planner_types::pre_asap::JoinKind, - predicate: Box, + predicate: Box, }, SummaryBuild { family: FieldDataType, diff --git a/crates/asap-physical-operators/src/operators/unchecked.rs b/crates/asap-physical-operators/src/operators/unchecked.rs index d3504f9a9..a7d3bd683 100644 --- a/crates/asap-physical-operators/src/operators/unchecked.rs +++ b/crates/asap-physical-operators/src/operators/unchecked.rs @@ -134,13 +134,11 @@ impl TryFrom for Operator { operator } } - Kind::Join { kind, predicate } => Operator::relational_join( + Kind::Join { kind, predicate } => Operator::bound_relational_join( input(0)?, input(1)?, kind, - &planner_types::pre_asap::Predicate(std::rc::Rc::new( - predicate.expression().clone(), - )), + *predicate, output.clone(), )?, Kind::SummaryBuild { diff --git a/crates/asap-physical-operators/src/unified_physical_planner/candidates.rs b/crates/asap-physical-operators/src/unified_physical_planner/candidates.rs new file mode 100644 index 000000000..248f5d40f --- /dev/null +++ b/crates/asap-physical-operators/src/unified_physical_planner/candidates.rs @@ -0,0 +1,327 @@ +//! Compile maintenance-selected frontiers without deployment-specific dag rewrites. +use super::*; + +/// One computation realization; lifecycle/window/revision requirements accompany +/// it during optimization and deployment. Stored outputs have no storage identity. +/// Deserialization validates the producer/reader boundary. +#[derive(Clone, serde::Serialize, serde::Deserialize)] +#[serde(try_from = "UncheckedCompiledPhysicalPlan")] +pub struct CompiledPhysicalPlan { + pub precompute: Option, + pub query: CompiledPhysicalDAG, + pub materialized_outputs: BTreeMap, +} + +/// Compile an explicit materialization frontier selected by Planner maintenance +/// search. Operators upstream of that frontier run in precompute, including +/// evaluations/reductions; query execution receives their typed output values. +/// Empty frontiers retain the full computation in the query DAG. +/// +/// Repeated windows must be instantiated with the same evaluation/population +/// contract used to build each output. This API never treats a result from a +/// different window or revision as interchangeable merely because types match. +pub fn compile_candidate( + dag: &PhysicalASAPDAG, + inputs: BTreeMap, + roots: &[NodeId], + frontier: &[NodeId], +) -> Result { + cut_candidate(&compile(dag, inputs, roots)?, frontier) +} + +/// Derive one frontier's candidate from a complete [`compile`] result by +/// partitioning its operators; nothing is lowered again. A deployment compiles +/// each query DAG once and derives every placement choice from that result. +/// The candidate is identical to [`compile_candidate`] for the same frontier. +pub fn cut_candidate( + compiled: &CompiledPhysicalDAG, + frontier: &[NodeId], +) -> Result { + if frontier.is_empty() { + return Ok(CompiledPhysicalPlan { + precompute: None, + query: compiled.clone(), + materialized_outputs: BTreeMap::new(), + }); + } + let frontier_set: BTreeSet<_> = frontier.iter().copied().collect(); + // `compile` retains only reachable nodes and numbers its helper operators + // above the u32 Planner ID range; only Planner outputs are boundaries. + if frontier_set.len() != frontier.len() + || frontier + .iter() + .any(|&id| !compiled.is_operator(id) || u32::try_from(id).is_err()) + { + return Err(invalid("frontier must contain distinct computed outputs")); + } + let inputs: BTreeMap<_, _> = compiled + .input_contracts() + .map(|(id, contract)| (id, contract.clone())) + .collect(); + let precompute = compiled.cut(&inputs, frontier)?; + let mut materialized_outputs = BTreeMap::new(); + for &id in frontier { + let mut output = precompute.output_contract(id)?; + if output.properties.boundedness != Boundedness::Bounded { + return Err(invalid("materialized output requires bounded execution")); + } + // A stored reader may stream batches even when the producer blocked. + // Its timing is independent; the retained result still must be finite. + output.properties.emission = Emission::Unknown; + materialized_outputs.insert(id, output); + } + let mut query_inputs = inputs; + query_inputs.extend(materialized_outputs.clone()); + let query = compiled.cut(&query_inputs, compiled.roots())?; + let used: BTreeSet<_> = query.input_contracts().map(|(id, _)| id).collect(); + if !frontier.iter().all(|id| used.contains(id)) { + return Err(invalid( + "frontier contains an output shadowed by another boundary", + )); + } + Ok(CompiledPhysicalPlan { + precompute: Some(precompute), + query, + materialized_outputs, + }) +} + +/// Materialization frontier implied by lifecycle-assigned timing: ingestion-time +/// nodes read by a query-time node, plus the root when it is ingestion-timed. +/// `cut_candidate` of one [`compile`] result with this frontier realizes the +/// assignment, so different assignments are different cuts of one lowering. +/// That holds while timing-dependent lowering (an ingestion-time `Binary` +/// aligns by value column) has the same timing at compile time as here. +/// A query-time node feeding an ingestion-time node has no valid placement. +pub fn frontier_from_timing(dag: &PhysicalASAPDAG) -> Result, Error> { + use planner_types::post_asap::ExecutionTiming::IngestionTime; + let timing = dag + .nodes + .iter() + .map(|node| (node.id, node.output_state.timing)) + .collect::>(); + let mut frontier = BTreeSet::new(); + if timing.get(&dag.root) == Some(&IngestionTime) { + frontier.insert(u64::from(dag.root.0)); + } + for edge in &dag.edges { + let (Some(&producer), Some(&consumer)) = + (timing.get(&edge.producer), timing.get(&edge.consumer)) + else { + return Err(invalid("timed DAG edge names an unknown node")); + }; + match (producer == IngestionTime, consumer == IngestionTime) { + (true, false) => { + frontier.insert(u64::from(edge.producer.0)); + } + (false, true) => return Err(invalid("query-time node feeds an ingestion-time node")), + _ => {} + } + } + Ok(frontier.into_iter().collect()) +} + +/// Enumerate bounded, reachable materialization frontiers above explicit inputs. +/// Each frontier is an antichain: storing an output and its ancestor together +/// would leave the ancestor unused by query execution. Lifecycle eligibility +/// and deployment feasibility are evaluated separately before cost selection. +/// Exceeding the search budget returns an error, never a partial inventory. +pub fn enumerate_frontiers( + dag: &PhysicalASAPDAG, + inputs: &BTreeMap, + roots: &[NodeId], + max_candidates: usize, +) -> Result>, Error> { + enumerate_compiled_frontiers(&compile(dag, inputs.clone(), roots)?, max_candidates) +} + +fn enumerate_compiled_frontiers( + compiled: &CompiledPhysicalDAG, + max_candidates: usize, +) -> Result>, Error> { + if max_candidates == 0 { + return Err(invalid( + "frontier search requires a positive candidate budget", + )); + } + let mut ancestors = BTreeMap::>::new(); + let mut eligible = Vec::new(); + for (id, properties) in compiled.output_properties()? { + if !compiled.is_operator(id) + || u32::try_from(id).is_err() + || properties.boundedness != Boundedness::Bounded + { + continue; + } + let mut seen = BTreeSet::new(); + let mut pending = vec![id]; + while let Some(current) = pending.pop() { + if seen.insert(current) { + pending.extend(compiled.dependencies(current)); + } + } + ancestors.insert(id, seen); + eligible.push(id); + } + let mut frontiers = vec![vec![]]; + for id in eligible { + let additions = frontiers + .iter() + .filter(|frontier| { + frontier.iter().all(|previous| { + !ancestors[&id].contains(previous) && !ancestors[previous].contains(&id) + }) + }) + .map(|frontier| { + let mut next = frontier.clone(); + next.push(id); + next + }) + .collect::>(); + if additions.len() > max_candidates.saturating_sub(frontiers.len()) { + return Err(invalid( + "materialization frontier search exceeds candidate budget", + )); + } + frontiers.extend(additions); + } + Ok(frontiers) +} + +/// Lower every maintenance candidate before feasibility/cost evaluation. Keep +/// individual failures visible; do not substitute another computation on error. +/// The DAG is lowered once; each frontier is a [`cut_candidate`] of it. +pub fn compile_candidates( + dag: &PhysicalASAPDAG, + inputs: BTreeMap, + roots: &[NodeId], + frontiers: &[Vec], +) -> Vec> { + match compile(dag, inputs, roots) { + Ok(compiled) => frontiers + .iter() + .map(|frontier| cut_candidate(&compiled, frontier)) + .collect(), + Err(error) => frontiers.iter().map(|_| Err(error.clone())).collect(), + } +} + +/// Complete workload cost supplied by scoped optimizer/deployment evidence. +/// The evaluator includes build/update work, retained state, shared producers +/// and recurrent reads over the same horizon; these are not per-query timings. +#[derive(Clone, Debug)] +pub struct CandidateCost { + pub workload_scope: String, + pub horizon_seconds: f64, + pub total_cost: f64, +} + +pub struct CandidateSelection { + pub candidate: T, + pub candidate_index: usize, + pub cost: CandidateCost, +} + +/// Select only compiled and deployment-feasible physical candidates. `None` +/// rejects an unbindable candidate before pricing. Comparable scoped costs are +/// required; deployment never rewrites the selected frontier after this step. +/// The payload is generic so deployments can retain binding/diagnostic metadata +/// alongside each compiled computation without duplicating winner selection. +pub fn select_candidate( + candidates: Vec>, + mut evaluate: impl FnMut(&T) -> Result, Error>, +) -> Result, Error> { + let mut scope: Option<(String, f64)> = None; + let mut selected: Option> = None; + for (candidate_index, candidate) in candidates.into_iter().enumerate() { + let Ok(candidate) = candidate else { continue }; + let Some(cost) = evaluate(&candidate)? else { + continue; + }; + if cost.workload_scope.is_empty() + || !cost.horizon_seconds.is_finite() + || cost.horizon_seconds <= 0. + || !cost.total_cost.is_finite() + || cost.total_cost < 0. + { + return Err(invalid( + "candidate cost lacks a valid workload scope/horizon", + )); + } + let current_scope = (cost.workload_scope.clone(), cost.horizon_seconds); + if scope.as_ref().is_some_and(|scope| scope != ¤t_scope) { + return Err(invalid( + "candidate costs describe different workloads or horizons", + )); + } + scope = Some(current_scope); + if selected + .as_ref() + .is_none_or(|selected| cost.total_cost < selected.cost.total_cost) + { + selected = Some(CandidateSelection { + candidate, + candidate_index, + cost, + }); + } + } + selected.ok_or_else(|| invalid("no feasible priced physical candidate")) +} + +#[derive(serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct UncheckedCompiledPhysicalPlan { + precompute: Option, + query: CompiledPhysicalDAG, + materialized_outputs: BTreeMap, +} +impl TryFrom for CompiledPhysicalPlan { + type Error = Error; + fn try_from(candidate: UncheckedCompiledPhysicalPlan) -> Result { + let result = Self { + precompute: candidate.precompute, + query: candidate.query, + materialized_outputs: candidate.materialized_outputs, + }; + result.validate()?; + Ok(result) + } +} + +impl CompiledPhysicalPlan { + /// Validate the physical handoff, including the producer/reader boundary. + pub fn validate(&self) -> Result<(), Error> { + self.query.validate()?; + let Some(precompute) = &self.precompute else { + return if self.materialized_outputs.is_empty() { + Ok(()) + } else { + Err(invalid("materialized outputs have no producer DAG")) + }; + }; + precompute.validate()?; + let outputs: BTreeSet<_> = self.materialized_outputs.keys().copied().collect(); + if outputs.is_empty() || outputs != precompute.roots().iter().copied().collect() { + return Err(invalid("physical frontier differs from precompute outputs")); + } + let readers: BTreeMap<_, _> = self.query.input_contracts().collect(); + for (&id, contract) in &self.materialized_outputs { + let produced = precompute.output_contract(id)?; + // Direct frontiers retain their node IDs. Temporal candidates can + // read several window instances through distinct input slots; + // their deployment bindings must validate those slots separately. + let reader = readers.get(&id); + if contract.schema != produced.schema + || reader.is_some_and(|reader| contract.schema != reader.schema) + || produced.properties.boundedness != Boundedness::Bounded + || contract.properties.boundedness != Boundedness::Bounded + || reader + .is_some_and(|reader| reader.properties.boundedness != Boundedness::Bounded) + { + return Err(invalid("physical frontier schema or boundedness mismatch")); + } + } + Ok(()) + } +} diff --git a/crates/asap-physical-operators/src/unified_physical_planner/compiled.rs b/crates/asap-physical-operators/src/unified_physical_planner/compiled.rs new file mode 100644 index 000000000..2b9244806 --- /dev/null +++ b/crates/asap-physical-operators/src/unified_physical_planner/compiled.rs @@ -0,0 +1,352 @@ +//! Reader-independent physical computation and checked deployment instantiation. +use super::*; + +/// A typed execution boundary, without storage identity or a live reader. +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct InputContract { + pub schema: SchemaRef, + pub properties: PlanProperties, +} +impl InputContract { + pub fn bounded(schema: SchemaRef) -> Self { + Self { + schema, + properties: PlanProperties { + boundedness: Boundedness::Bounded, + emission: Emission::Unknown, + }, + } + } + pub fn from_source(source: &dyn PhysicalOperator) -> Self { + Self { + schema: source.output_schema(), + properties: source.properties(&[]), + } + } +} +#[derive(Clone, serde::Serialize, serde::Deserialize)] +enum Node { + Input(InputContract), + Operator { + inputs: Vec, + operator: Operator, + }, +} + +/// Selected native operators and input slots. Rebinding never repeats lowering. +/// Serde is format-agnostic; deployments choose the encoding and its versioning. +/// Deserialization validates the dag before it is usable. +#[derive(Clone, serde::Serialize, serde::Deserialize)] +#[serde(try_from = "UncheckedDAG")] +pub struct CompiledPhysicalDAG { + nodes: BTreeMap, + roots: Vec, +} +#[derive(serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct UncheckedDAG { + nodes: BTreeMap, + roots: Vec, +} +impl TryFrom for CompiledPhysicalDAG { + type Error = Error; + fn try_from(dag: UncheckedDAG) -> Result { + let result = Self { + nodes: dag.nodes, + roots: dag.roots, + }; + result.validate()?; + Ok(result) + } +} + +impl CompiledPhysicalDAG { + /// Link already-selected physical fragments without lowering operators again. + /// Fragment keys and source keys share a namespace; repeated dependency IDs + /// therefore remain one producer in the composed dag. + pub fn compose( + sources: BTreeMap, + fragments: BTreeMap, Self)>, + roots: Vec, + ) -> Result { + if sources.keys().any(|id| fragments.contains_key(id)) { + return Err(invalid("physical source and fragment IDs overlap")); + } + let mut contracts = sources.clone(); + for (&id, (_, fragment)) in &fragments { + fragment.validate()?; + let [root] = fragment.roots() else { + return Err(invalid("composed fragment requires one root")); + }; + if fragment.input_contracts().any(|(id, _)| id == *root) { + return Err(invalid("fragment root must be a computed output")); + } + contracts.insert(id, fragment.output_contract(*root)?); + } + let mut next = contracts + .keys() + .next_back() + .copied() + .unwrap_or(0) + .checked_add(1) + .ok_or_else(|| invalid("physical node ID overflow"))?; + let mut result = Self::new(roots); + for (id, contract) in sources { + result.add_input(id, contract)?; + } + for (id, (inputs, fragment)) in fragments { + if inputs.len() != fragment.input_contracts().count() { + return Err(invalid("physical fragment input arity mismatch")); + } + let mut mapping = BTreeMap::new(); + for ((local, expected), global) in fragment.input_contracts().zip(inputs) { + let actual = contracts + .get(&global) + .ok_or_else(|| invalid("missing physical fragment dependency"))?; + if expected.schema != actual.schema + || (expected.properties.boundedness == Boundedness::Bounded + && actual.properties.boundedness != Boundedness::Bounded) + { + return Err(invalid("physical fragment dependency contract mismatch")); + } + mapping.insert(local, global); + } + mapping.insert(fragment.roots[0], id); + for local in fragment.nodes.keys() { + if !mapping.contains_key(local) { + mapping.insert(*local, next); + next = next + .checked_add(1) + .ok_or_else(|| invalid("physical node ID overflow"))?; + } + } + for (local, node) in fragment.nodes { + if let Node::Operator { inputs, operator } = node { + result.add( + mapping[&local], + inputs.into_iter().map(|input| mapping[&input]).collect(), + operator, + )?; + } + } + } + result.validate()?; + Ok(result) + } + + /// Assemble already-lowered operators and typed external inputs. This is + /// useful for engines that compose multiple compiled computation fragments. + pub fn from_operators( + inputs: BTreeMap, + operators: BTreeMap, Operator)>, + roots: Vec, + ) -> Result { + let mut result = Self::new(roots); + for (id, contract) in inputs { + result.add_input(id, contract)?; + } + for (id, (inputs, operator)) in operators { + result.add(id, inputs, operator)?; + } + result.validate()?; + Ok(result) + } + pub(super) fn new(roots: Vec) -> Self { + Self { + nodes: BTreeMap::new(), + roots, + } + } + pub(super) fn add_input(&mut self, id: NodeId, contract: InputContract) -> Result<(), Error> { + self.insert(id, Node::Input(contract)) + } + pub(super) fn add( + &mut self, + id: NodeId, + inputs: Vec, + operator: Operator, + ) -> Result<(), Error> { + self.insert(id, Node::Operator { inputs, operator }) + } + fn insert(&mut self, id: NodeId, node: Node) -> Result<(), Error> { + if self.nodes.insert(id, node).is_some() { + return Err(invalid(format!("duplicate physical node {id}"))); + } + Ok(()) + } + /// Identify the external input whose rows survive unchanged at this output. + /// Protocol adapters can retain labels that are outside a closed physical schema. + pub fn row_source(&self, id: NodeId) -> Option { + match self.nodes.get(&id)? { + Node::Input(_) => Some(id), + Node::Operator { inputs, operator } => { + let index = operator.row_preserving_input()?; + self.row_source(*inputs.get(index)?) + } + } + } + + /// Selected operator name, for plan inspection without decoding its wire format. + /// Certified candidate pruning checks authoritative-key coverage inside this operator. + pub fn certified_pruning_keys(&self, id: NodeId) -> Option<&[(usize, usize)]> { + match self.nodes.get(&id)? { + Node::Operator { operator, .. } => operator.certified_pruning_keys(), + Node::Input(_) => None, + } + } + pub fn operator_name(&self, id: NodeId) -> Option<&str> { + match self.nodes.get(&id)? { + Node::Input(_) => Some("Input"), + Node::Operator { operator, .. } => Some(operator.name()), + } + } + + pub fn roots(&self) -> &[NodeId] { + &self.roots + } + pub fn input_contracts(&self) -> impl Iterator { + self.nodes.iter().filter_map(|(&id, node)| match node { + Node::Input(contract) => Some((id, contract)), + Node::Operator { .. } => None, + }) + } + /// Derive a reachable output contract without opening deployment readers. + pub fn output_contract(&self, id: NodeId) -> Result { + let properties = *self + .output_properties()? + .get(&id) + .ok_or_else(|| invalid("output is not reachable"))?; + let schema = match self + .nodes + .get(&id) + .ok_or_else(|| invalid("missing output"))? + { + Node::Input(contract) => contract.schema.clone(), + Node::Operator { operator, .. } => operator.output_schema(), + }; + Ok(InputContract { schema, properties }) + } + /// Properties of every reachable node, derived in one contract-only pass. + pub(super) fn output_properties(&self) -> Result, Error> { + let sources = self + .input_contracts() + .map(|(id, contract)| (id, Box::new(contract.clone()) as Source<'_>)) + .collect(); + self.instantiate(sources)?.properties(&self.roots) + } + /// Direct physical dependencies; empty for inputs and unknown IDs. + pub(super) fn dependencies(&self, id: NodeId) -> &[NodeId] { + match self.nodes.get(&id) { + Some(Node::Operator { inputs, .. }) => inputs, + _ => &[], + } + } + pub(super) fn is_operator(&self, id: NodeId) -> bool { + matches!(self.nodes.get(&id), Some(Node::Operator { .. })) + } + /// Keep the already-lowered operators reachable from `roots`, replacing + /// each node in `boundaries` by a typed input. Nothing is lowered again. + pub(super) fn cut( + &self, + boundaries: &BTreeMap, + roots: &[NodeId], + ) -> Result { + let mut result = Self::new(roots.to_vec()); + let mut pending = roots.to_vec(); + while let Some(id) = pending.pop() { + if result.nodes.contains_key(&id) { + continue; + } + let node = match boundaries.get(&id) { + Some(contract) => Node::Input(contract.clone()), + None => self + .nodes + .get(&id) + .cloned() + .ok_or_else(|| invalid(format!("missing physical node {id}")))?, + }; + if let Node::Operator { inputs, .. } = &node { + pending.extend(inputs); + } + result.nodes.insert(id, node); + } + result.validate()?; + Ok(result) + } + /// Validate using contract-only sources. No deployment reader is available. + pub fn validate(&self) -> Result<(), Error> { + let sources = self + .input_contracts() + .map(|(id, c)| (id, Box::new(c.clone()) as Source<'_>)) + .collect(); + self.instantiate(sources).map(|_| ()) + } + /// Resolve exactly the declared inputs and validate before any source starts. + pub fn instantiate<'a>( + &self, + mut sources: BTreeMap>, + ) -> Result, Error> { + let mut dag = PhysicalDAG::default(); + for (&id, node) in &self.nodes { + match node { + Node::Input(contract) => { + let source = sources + .remove(&id) + .ok_or_else(|| invalid(format!("missing physical input {id}")))?; + let actual = source.properties(&[]); + if !source.input_schemas().is_empty() + || source.output_schema() != contract.schema + || (contract.properties.boundedness != Boundedness::Unknown + && actual.boundedness != contract.properties.boundedness) + || (contract.properties.emission != Emission::Unknown + && actual.emission != contract.properties.emission) + { + return Err(invalid(format!( + "physical input {id} violates its compiled contract" + ))); + } + dag.add_boxed( + id, + vec![], + Box::new(CheckedSource { + source, + output: contract.schema.clone(), + }), + )?; + } + Node::Operator { inputs, operator } => { + dag.add(id, inputs.clone(), operator.clone())?; + } + } + } + if !sources.is_empty() { + return Err(invalid("unexpected physical input binding")); + } + dag.validate(&self.roots)?; + Ok(dag) + } +} +impl PhysicalOperator for InputContract { + fn name(&self) -> &str { + "UnresolvedInput" + } + fn input_schemas(&self) -> Vec { + vec![] + } + fn output_schema(&self) -> SchemaRef { + self.schema.clone() + } + fn properties(&self, _: &[PlanProperties]) -> PlanProperties { + self.properties + } + fn output_bytes(&self, batch: &Batch) -> usize { + batch.bytes() + } + fn start<'a>( + &'a self, + _: Vec>, + _: crate::runtime::RunContext, + ) -> Result, Error> { + Err(invalid("physical input must be resolved before execution")) + } +} diff --git a/crates/asap-physical-operators/src/unified_physical_planner/logical.rs b/crates/asap-physical-operators/src/unified_physical_planner/logical.rs new file mode 100644 index 000000000..dbc541895 --- /dev/null +++ b/crates/asap-physical-operators/src/unified_physical_planner/logical.rs @@ -0,0 +1,374 @@ +//! Reconstruct shared operator references from the transport DAG for native lowering. +use super::*; +use planner_types::ir::export::{EdgeRole, NonASAPOpKind as N, PhysicalASAPNodeId, WireScalarExpr}; +use planner_types::ir::{ + ASAPOp, NonASAPOp, Operator as LogicalOperator, OperatorNode, Predicate, ProjectItem, + ScalarExpr, SortKey as LogicalSortKey, +}; +use std::rc::Rc; +pub(super) fn scalar( + expr: &WireScalarExpr, + id_of: &mut impl FnMut(PhysicalASAPNodeId) -> Rc, +) -> ScalarExpr { + fn boxed( + e: &WireScalarExpr, + id_of: &mut impl FnMut(PhysicalASAPNodeId) -> Rc, + ) -> Box { + Box::new(scalar(e, id_of)) + } + fn list( + es: &[WireScalarExpr], + id_of: &mut impl FnMut(PhysicalASAPNodeId) -> Rc, + ) -> Vec { + es.iter().map(|e| scalar(e, id_of)).collect() + } + match expr { + WireScalarExpr::Column(id) => ScalarExpr::Column(*id), + WireScalarExpr::Literal(v) => ScalarExpr::Literal(v.clone()), + WireScalarExpr::Negative { expr, semantics } => ScalarExpr::Negative { + expr: boxed(expr, id_of), + semantics: *semantics, + }, + WireScalarExpr::Compare { + left, + op, + right, + semantics, + } => ScalarExpr::Compare { + left: boxed(left, id_of), + op: op.clone(), + right: boxed(right, id_of), + semantics: *semantics, + }, + WireScalarExpr::BoolAnd(parts) => ScalarExpr::BoolAnd(list(parts, id_of)), + WireScalarExpr::BoolOr(parts) => ScalarExpr::BoolOr(list(parts, id_of)), + WireScalarExpr::Not(e) => ScalarExpr::Not(boxed(e, id_of)), + WireScalarExpr::IsNull(e) => ScalarExpr::IsNull(boxed(e, id_of)), + WireScalarExpr::IsNotNull(e) => ScalarExpr::IsNotNull(boxed(e, id_of)), + WireScalarExpr::Cast { expr, to, try_cast } => ScalarExpr::Cast { + expr: boxed(expr, id_of), + to: to.clone(), + try_cast: *try_cast, + }, + WireScalarExpr::InList { + expr, + list: items, + negated, + } => ScalarExpr::InList { + expr: boxed(expr, id_of), + list: list(items, id_of), + negated: *negated, + }, + WireScalarExpr::FunctionCall { name, args } => ScalarExpr::FunctionCall { + name: name.clone(), + args: list(args, id_of), + }, + WireScalarExpr::Arithmetic { + op, + left, + right, + semantics, + } => ScalarExpr::Arithmetic { + op: op.clone(), + left: boxed(left, id_of), + right: boxed(right, id_of), + semantics: *semantics, + }, + WireScalarExpr::Case { + operand, + branches, + else_expr, + } => ScalarExpr::Case { + operand: operand.as_ref().map(|e| boxed(e, id_of)), + branches: branches + .iter() + .map(|(w, t)| (scalar(w, id_of), scalar(t, id_of))) + .collect(), + else_expr: else_expr.as_ref().map(|e| boxed(e, id_of)), + }, + WireScalarExpr::CurrentTimestamp => ScalarExpr::CurrentTimestamp, + WireScalarExpr::EvalTimestamp => ScalarExpr::EvalTimestamp, + WireScalarExpr::PromqlScalarFromVector(node) => { + ScalarExpr::PromqlScalarFromVector(id_of(*node)) + } + WireScalarExpr::ScalarSubquery(node) => ScalarExpr::ScalarSubquery(id_of(*node)), + WireScalarExpr::Exists { subquery, negated } => ScalarExpr::Exists { + subquery: id_of(*subquery), + negated: *negated, + }, + WireScalarExpr::InSubquery { + expr, + subquery, + negated, + } => ScalarExpr::InSubquery { + expr: boxed(expr, id_of), + subquery: id_of(*subquery), + negated: *negated, + }, + } +} + +pub(super) fn restore(dag: &PhysicalASAPDAG) -> Result>, Error> { + dag.validate().map_err(|e| invalid(e.to_string()))?; + let mut done = BTreeMap::new(); + let mut remaining: Vec<_> = dag.nodes.iter().collect(); + while !remaining.is_empty() { + let before = remaining.len(); + let mut next = Vec::new(); + for node in remaining { + let mut edges: Vec<_> = dag.edges.iter().filter(|e| e.consumer == node.id).collect(); + if edges + .iter() + .any(|e| !done.contains_key(&u64::from(e.producer.0))) + { + next.push(node); + continue; + } + edges.sort_by_key(|e| match e.role { + EdgeRole::Left => 0, + EdgeRole::Input => 1, + EdgeRole::Right => 2, + EdgeRole::ScalarRef => 3, + }); + let inputs: Vec<_> = edges + .iter() + .filter(|e| e.role != EdgeRole::ScalarRef) + .map(|e| Rc::clone(&done[&u64::from(e.producer.0)])) + .collect(); + let input = |index: usize| { + inputs + .get(index) + .cloned() + .ok_or_else(|| invalid("operator is missing an input")) + }; + let mut missing = false; + let mut ref_node = |id: PhysicalASAPNodeId| { + if let Some(node) = done.get(&u64::from(id.0)) { + Rc::clone(node) + } else { + missing = true; + Rc::new(OperatorNode::with_schema( + LogicalOperator::NonASAP(NonASAPOp::Values { + rows: vec![], + schema: Default::default(), + }), + Default::default(), + )) + } + }; + let mut value = |expr: &WireScalarExpr| scalar(expr, &mut ref_node); + let operator = match &node.payload { + Payload::Relational { operator } => LogicalOperator::NonASAP(match operator { + N::Scan { + source, + predicates, + schema, + } => NonASAPOp::Scan { + source: source.clone(), + predicates: predicates.iter().map(|p| Predicate(value(&p.0))).collect(), + schema: schema.clone(), + }, + N::Values { rows, schema } => NonASAPOp::Values { + rows: rows + .iter() + .map(|r| r.iter().map(&mut value).collect()) + .collect(), + schema: schema.clone(), + }, + N::Filter { pred } => NonASAPOp::Filter { + pred: Predicate(value(&pred.0)), + child: input(0)?, + }, + N::Project { cols, qualifier } => NonASAPOp::Project { + cols: cols + .iter() + .map(|c| ProjectItem { + alias: c.alias.clone(), + expr: value(&c.expr), + }) + .collect(), + qualifier: qualifier.clone(), + child: input(0)?, + }, + N::Aggregate { + reduction, + measures, + output_names, + filters, + having, + } => NonASAPOp::Aggregate { + reduction: reduction.clone(), + measures: measures.clone(), + output_names: output_names.clone(), + filters: filters + .iter() + .map(|p| p.as_ref().map(|p| Predicate(value(&p.0)))) + .collect(), + having: having.as_ref().map(|p| Predicate(value(&p.0))), + child: input(0)?, + }, + N::Join { join_kind, pred } => NonASAPOp::Join { + kind: join_kind.clone(), + pred: Predicate(value(&pred.0)), + left: input(0)?, + right: input(1)?, + }, + N::SetOp { set_kind, all } => NonASAPOp::SetOp { + kind: set_kind.clone(), + all: *all, + left: input(0)?, + right: input(1)?, + }, + N::Concat { + discriminator_unique_key, + } => NonASAPOp::Concat { + children: inputs.clone(), + discriminator_unique_key: discriminator_unique_key.clone(), + }, + N::Dedup { cols } => NonASAPOp::Dedup { + cols: cols.clone(), + child: input(0)?, + }, + N::Sort { keys, partition_by } => NonASAPOp::Sort { + keys: keys + .iter() + .map(|k| LogicalSortKey { + expr: value(&k.expr), + ascending: k.ascending, + nulls_first: k.nulls_first, + }) + .collect(), + partition_by: partition_by.clone(), + child: input(0)?, + }, + N::Limit { + n, + offset, + partition_by, + } => NonASAPOp::Limit { + n: *n, + offset: *offset, + partition_by: partition_by.clone(), + child: input(0)?, + }, + N::BinaryOp { + operator, + return_bool, + } => NonASAPOp::BinaryOp { + operator: operator.clone(), + return_bool: *return_bool, + lhs: input(0)?, + rhs: input(1)?, + }, + N::SQLWindowFunc { + func, + args, + partition_by, + order_by, + frame, + output_name, + } => NonASAPOp::SQLWindowFunc { + func: func.clone(), + args: args.iter().map(&mut value).collect(), + partition_by: partition_by.clone(), + order_by: order_by + .iter() + .map(|k| LogicalSortKey { + expr: value(&k.expr), + ascending: k.ascending, + nulls_first: k.nulls_first, + }) + .collect(), + frame: frame.clone(), + output_name: output_name.clone(), + child: input(0)?, + }, + N::TimeRange { range, range_kind } => NonASAPOp::TimeRange { + range: *range, + kind: *range_kind, + child: input(0)?, + }, + N::TimeShift { shift } => NonASAPOp::TimeShift { + shift: *shift, + child: input(0)?, + }, + N::PromqlVectorFromScalar { expr } => { + NonASAPOp::PromqlVectorFromScalar(value(expr)) + } + N::PromqlRelabel { dst, value: expr } => NonASAPOp::PromqlRelabel { + dst: dst.clone(), + value: value(expr), + child: input(0)?, + }, + N::PromqlInfoEnrich { selector } => NonASAPOp::PromqlInfoEnrich { + selector: selector.clone(), + child: input(0)?, + }, + N::PromqlSeriesSample { by, sample_kind } => NonASAPOp::PromqlSeriesSample { + by: by.clone(), + kind: *sample_kind, + child: input(0)?, + }, + N::PromqlSubquery { range, resolution } => NonASAPOp::PromqlSubquery { + range: *range, + resolution: *resolution, + child: input(0)?, + }, + }), + Payload::SummaryAgg { + family, + input: update, + reduction, + grouping, + filter, + } => LogicalOperator::ASAP(ASAPOp::SummaryAgg { + child: input(0)?, + family: family.clone(), + input: update.clone(), + reduction: reduction.clone(), + grouping: grouping.clone(), + filter: filter.as_ref().map(|p| Predicate(value(&p.0))), + }), + Payload::SummaryEstimate { query } => { + LogicalOperator::ASAP(ASAPOp::SummaryEstimate { + summary_input: input(0)?, + query: query.clone(), + }) + } + Payload::FinalizeExactAccumulator => { + LogicalOperator::ASAP(ASAPOp::FinalizeExactAccumulator { child: input(0)? }) + } + Payload::MaintainPopulation { population } => { + LogicalOperator::ASAP(ASAPOp::MaintainPopulation { + child: input(0)?, + population: population.clone(), + }) + } + Payload::EvaluatePopulation { evaluation } => { + LogicalOperator::ASAP(ASAPOp::EvaluatePopulation { + child: input(0)?, + evaluation: evaluation.clone(), + }) + } + Payload::SummaryMerge => LogicalOperator::ASAP(ASAPOp::SummaryMerge { + children: inputs.clone(), + }), + _ => return Err(invalid("reserved ASAP operation has no native lowering")), + }; + if missing { + return Err(invalid( + "scalar reference is not a preceding DAG dependency", + )); + } + let mut rebuilt = OperatorNode::with_schema(operator, node.output_schema.clone()); + rebuilt.guarantee = node.guarantee.clone(); + rebuilt.timing = Some(node.output_state.timing); + done.insert(u64::from(node.id.0), Rc::new(rebuilt)); + } + if next.len() == before { + return Err(invalid("operator DAG is cyclic")); + } + remaining = next; + } + Ok(done) +} diff --git a/crates/asap-physical-operators/src/unified_physical_planner/mod.rs b/crates/asap-physical-operators/src/unified_physical_planner/mod.rs new file mode 100644 index 000000000..8d680fcc9 --- /dev/null +++ b/crates/asap-physical-operators/src/unified_physical_planner/mod.rs @@ -0,0 +1,1169 @@ +//! Compile logical computation to native operators with typed external inputs. +//! Compilation needs no readers; deployment resolves inputs after selection. +use crate::operators::ReadoutQuery; +use crate::summary_kernels::exact::ExactReadout; +use crate::{ + operators::{Expression, Operator, Reduction, SortKey}, + plan::{Boundedness, Emission, NodeId, PhysicalDAG, PhysicalOperator, PlanProperties}, + values::{Batch, SchemaRef}, + Error, +}; +use planner_types::ir::export::{ + NonASAPOpKind, PhysicalASAPDAG, PhysicalASAPDAGNode, PhysicalASAPOperatorPayload as Payload, + WireScalarExpr, +}; +use planner_types::ir::{ASAPOp, NonASAPOp, Operator as LogicalOperator, OperatorNode, ScalarExpr}; +use planner_types::{ + post_asap::{FieldDataType, SketchStatistic, SummaryInputExpr}, + pre_asap::{ + AggIntent, ColumnRef, CompareOpKind, DataType, GroupKeys, Reduction as PlannerReduction, + }, +}; +mod logical; +use std::{ + collections::{BTreeMap, BTreeSet}, + sync::Arc, +}; +fn invalid(message: impl Into) -> Error { + Error::Invalid(message.into()) +} + +/// Source nodes cut the DAG at an installed storage/ingestion frontier. The +/// binding must have exactly the declared schema and no upstream dependencies. +/// A deployment must authorize these frontiers before calling this function. +pub type Source<'a> = Box + 'a>; + +pub mod precompute; +pub mod promql_fallback; +pub mod promql_rows; +pub mod promql_values; + +mod candidates; +pub use candidates::{ + compile_candidate, compile_candidates, cut_candidate, enumerate_frontiers, + frontier_from_timing, select_candidate, CandidateCost, CandidateSelection, + CompiledPhysicalPlan, +}; + +mod compiled; +pub use compiled::{CompiledPhysicalDAG, InputContract}; + +mod row_values; + +/// Compile computation without opening or retaining deployment readers. +/// Input contracts identify explicit boundaries selected by maintenance planning. +pub fn compile( + dag: &PhysicalASAPDAG, + inputs: BTreeMap, + roots: &[NodeId], +) -> Result { + compile_internal(dag, inputs, roots) +} + +/// Convenience for callers that already resolved inputs. Lowering still uses +/// only their contracts, and instantiation checks those contracts again. +pub fn bind<'a>( + dag: &PhysicalASAPDAG, + sources: BTreeMap>, + roots: &[NodeId], +) -> Result, Error> { + let inputs = sources + .iter() + .map(|(&id, source)| (id, InputContract::from_source(source.as_ref()))) + .collect(); + compile(dag, inputs, roots)?.instantiate(sources) +} + +/// Resolve raw scan connectors before invoking the reader-independent compiler. +pub fn bind_with_data_sources<'a>( + dag: &PhysicalASAPDAG, + mut sources: BTreeMap>, + roots: &[NodeId], + data_sources: &crate::unified_sources::DataSources, +) -> Result, Error> { + let restored = logical::restore(dag)?; + // Only resolve scans reachable below the selected input boundaries. + let mut pending = roots.to_vec(); + let mut seen = BTreeSet::new(); + while let Some(id) = pending.pop() { + if !seen.insert(id) || sources.contains_key(&id) { + continue; + } + let _node = dag + .nodes + .iter() + .find(|n| u64::from(n.id.0) == id) + .ok_or_else(|| invalid(format!("missing node {id}")))?; + if matches!(restored[&id].non_asap(), Some(NonASAPOp::Scan { .. })) { + sources.insert(id, Box::new(data_sources.bind(&restored[&id])?)); + } else { + pending.extend( + dag.edges + .iter() + .filter(|e| u64::from(e.consumer.0) == id) + .map(|e| u64::from(e.producer.0)), + ); + } + } + bind(dag, sources, roots) +} + +#[cfg(test)] +thread_local! { + /// Planner nodes lowered by this thread, for compile-once tests. + static LOWERED_NODES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Helper operators are numbered from their Planner node alone, above the u32 +/// Planner ID range, so every boundary choice yields a subgraph of the same +/// lowering and candidate cuts need not renumber operators. A node lowering to +/// several helpers takes consecutive indices below its base. +fn helper_id(node: NodeId, index: u64) -> NodeId { + debug_assert!(node <= u64::from(u32::MAX) && index < 1 << 16); + u64::MAX - (node << 16) - index +} + +fn compile_internal( + dag: &PhysicalASAPDAG, + mut sources: BTreeMap, + roots: &[NodeId], +) -> Result { + preflight_depth(dag)?; + let restored = logical::restore(dag)?; + dag.validate().map_err(|e| invalid(e.to_string()))?; + let nodes = dag + .nodes + .iter() + .map(|node| (u64::from(node.id.0), node)) + .collect::>(); + let mut dependencies = BTreeMap::>::new(); + // Binary input order is semantic; serialized edge order is not. + let mut edges = dag.edges.iter().collect::>(); + edges.sort_by_key(|edge| { + ( + edge.consumer.0, + match edge.role { + planner_types::ir::export::EdgeRole::Left => 0, + planner_types::ir::export::EdgeRole::Input => 1, + planner_types::ir::export::EdgeRole::Right => 2, + planner_types::ir::export::EdgeRole::ScalarRef => 3, + }, + ) + }); + let literals = BTreeMap::::new(); + for edge in edges { + dependencies + .entry(u64::from(edge.consumer.0)) + .or_default() + .push(u64::from(edge.producer.0)); + } + let mut fallback = BTreeMap::new(); + for (&id, root) in &restored { + let raw_summary_input = matches!(root.non_asap(), Some(NonASAPOp::TimeRange { .. })) + && dag.edges.iter().any(|e| { + u64::from(e.producer.0) == id + && matches!( + nodes[&u64::from(e.consumer.0)].payload, + Payload::SummaryAgg { .. } + ) + }); + if !root.contains_asap() && !raw_summary_input { + if let Ok(lowered) = promql_fallback::lower(root) { + fallback.insert(id, lowered); + } + } + } + let known = |id: &NodeId| { + nodes.contains_key(id) + || promql_fallback::raw_series_owner(*id).is_some_and(|owner| { + matches!( + nodes.get(&owner), + Some(PhysicalASAPDAGNode { + payload: Payload::Relational { .. }, + .. + }) + ) + }) + }; + if !sources.keys().all(known) { + return Err(invalid("source binding names an unknown node")); + } + let mut ordered = Vec::new(); + let mut seen = BTreeSet::new(); + let mut pending = roots.iter().map(|&id| (id, false)).collect::>(); + while let Some((id, expanded)) = pending.pop() { + if expanded { + ordered.push(id); + continue; + } + if !seen.insert(id) { + continue; + } + if !nodes.contains_key(&id) { + return Err(invalid(format!("missing root {id}"))); + } + pending.push((id, true)); + if !sources.contains_key(&id) && !fallback.contains_key(&id) { + for &input in dependencies.get(&id).into_iter().flatten() { + pending.push((input, false)); + } + } + } + let mut dag = CompiledPhysicalDAG::new(roots.to_vec()); + for id in ordered { + let node = nodes[&id]; + let mut auxiliary = helper_id(id, 0); + let output = Arc::new(node.output_schema.clone()); + crate::values::validate_schema(&output)?; + if let Some(source) = sources.remove(&id) { + if source.schema != output { + return Err(invalid("frontier does not have the declared schema")); + } + dag.add_input(id, source)?; + } else { + #[cfg(test)] + LOWERED_NODES.with(|count| count.set(count.get() + 1)); + let mut inputs = dependencies.get(&id).cloned().unwrap_or_default(); + let mut schemas = inputs + .iter() + .map(|id| Arc::new(nodes[id].output_schema.clone())) + .collect::>(); + if matches!(node.payload, Payload::SummaryMerge) && inputs.len() > 1 { + if schemas.iter().any(|s| s != &schemas[0]) { + return Err(invalid("summary merge inputs have different schemas")); + } + dag.add( + auxiliary, + inputs, + Operator::union(schemas[0].clone(), schemas.len())?, + )?; + inputs = vec![auxiliary]; + schemas.truncate(1); + } + if let Some(promql_fallback::Lowering { + selectors, + mut steps, + }) = fallback.remove(&id) + { + let mut slots = Vec::new(); + for (i, (_, schema)) in selectors.iter().enumerate() { + let slot = promql_fallback::raw_series_input(id, i); + match sources.remove(&slot) { + Some(contract) if &contract.schema == schema => { + dag.add_input(slot, contract)? + } + Some(_) => { + return Err(invalid(format!( + "node {id}: raw series input {slot} differs from the selector schema" + ))) + } + None => { + return Err(invalid(format!( + "node {id}: PromQL fallback requires raw series input {slot}" + ))) + } + } + slots.push(slot); + } + let (last, last_inputs) = steps + .pop() + .ok_or_else(|| invalid("empty PromQL lowering"))?; + let mut ids = Vec::new(); + let resolve = |inputs: Vec, ids: &[NodeId]| { + inputs + .into_iter() + .map(|input| match input { + promql_fallback::Input::Raw(i) => slots[i], + promql_fallback::Input::Step(i) => ids[i], + }) + .collect::>() + }; + for (operator, inputs) in steps { + dag.add(auxiliary, resolve(inputs, &ids), operator)?; + ids.push(auxiliary); + auxiliary -= 1; + } + dag.add( + id, + resolve(last_inputs, &ids), + last.with_output_schema(output)?, + )?; + continue; + } + if let Payload::MaintainPopulation { population } = &node.payload { + use planner_types::post_asap::maintained_population::PopulationInput; + let PopulationInput::CurrentSeries(spec) = &population.input else { + return Err(invalid( + "native maintained population requires a current-series input", + )); + }; + let [input] = schemas.as_slice() else { + return Err(invalid("current-series population requires one input")); + }; + if spec.without { + return Err(invalid( + "dynamic without grouping requires label-set projection", + )); + } + let identity = named_column( + input, + &ColumnRef::Named(promql_rows::SERIES_IDENTITY_COLUMN.into()), + )?; + let coordinate = input + .time_index + .ok_or_else(|| invalid("current-series input lacks timestamp"))?; + let value = named_column(input, &ColumnRef::SampleValue)?; + let lookback = i64::try_from(spec.lookback_ms) + .map_err(|_| invalid("current-series lookback overflows"))?; + dag.add( + id, + inputs, + Operator::current_series(input.clone(), identity, coordinate, value, lookback)? + .with_output_schema(output)?, + )?; + continue; + } + if let Payload::EvaluatePopulation { evaluation } = &node.payload { + use planner_types::post_asap::maintained_population::{ + PopulationInput, PopulationStatistic, + }; + let [producer] = inputs.as_slice() else { + return Err(invalid("population evaluation requires one input")); + }; + let Payload::MaintainPopulation { population } = &nodes[producer].payload else { + return Err(invalid( + "population evaluation requires its declared population", + )); + }; + let PopulationInput::CurrentSeries(spec) = &population.input else { + return Err(invalid("current-series population required")); + }; + if spec.without { + return Err(invalid( + "dynamic without ranking requires label-set projection", + )); + } + let input = schemas[0].clone(); + let PopulationStatistic::TopK { k } = evaluation else { + let mut chain = + row_values::population_aggregate(&input, &spec.grouping, evaluation)?; + let last = chain.pop().expect("nonempty chain"); + let mut inputs = inputs; + for operator in chain { + dag.add(auxiliary, inputs, operator)?; + inputs = vec![auxiliary]; + auxiliary -= 1; + } + dag.add(id, inputs, last.with_output_schema(output)?)?; + continue; + }; + let groups = spec + .grouping + .iter() + .map(|name| named_column(&input, &ColumnRef::Named(name.clone()))) + .collect::, _>>()?; + let value = named_column(&input, &ColumnRef::SampleValue)?; + dag.add( + auxiliary, + inputs, + Operator::sort( + input.clone(), + vec![SortKey { + column: value, + descending: true, + nulls_first: false, + }], + groups.clone(), + )?, + )?; + dag.add( + id, + vec![auxiliary], + Operator::limit(input, *k as u64, 0, groups)?.with_output_schema(output)?, + )?; + continue; + } + // A closed row must include either all source labels or the explicit + // complete-label identity. Projected labels alone are insufficient. + if let Payload::SummaryAgg { + family, + input: update, + reduction: PlannerReduction::PerEntity, + grouping, + filter: None, + } = &node.payload + { + let [input_id] = inputs.as_slice() else { + return Err(invalid("per-entity summary requires one input")); + }; + let Some(NonASAPOp::TimeRange { child, .. }) = restored[input_id].non_asap() else { + return Err(invalid( + "per-entity summary requires a resolved raw time range", + )); + }; + let Some(NonASAPOp::Scan { schema, .. }) = child.non_asap() else { + return Err(invalid("per-entity summary requires a resolved source")); + }; + if !schema.closed || update.item.is_some() { + return Err(invalid( + "per-entity summary requires complete source identity", + )); + } + crate::capability::validate_summary_kernel(family, update, grouping) + .map_err(Error::Invalid)?; + let SummaryInputExpr::Column(value) = &update.weight else { + return Err(invalid( + "per-entity update requires a projected value column", + )); + }; + let input = schemas[0].clone(); + let value = named_column(&input, value)?; + let coordinate = input + .time_index + .ok_or_else(|| invalid("temporal input lacks time"))?; + let groups = (0..input.fields.len()) + .filter(|&column| column != value && column != coordinate) + .collect(); + let build = Operator::summary_build( + input, + family.clone(), + value, + Some(coordinate), + groups, + )?; + let compact = build.schema(); + dag.add(auxiliary, inputs, build)?; + dag.add( + id, + vec![auxiliary], + Operator::scope_timestamp(compact, output)?, + )?; + continue; + } + if let Payload::Relational { + operator: + NonASAPOpKind::BinaryOp { + operator, + return_bool, + }, + } = &node.payload + { + let operator = crate::expressions::binary::BinaryOperator::from_logical( + operator, + *return_bool, + ); + let query_time = node.output_state.timing + == planner_types::post_asap::ExecutionTiming::QueryTime; + if let Some(&(value, left)) = literals.get(&id) { + let [input] = schemas.as_slice() else { + return Err(invalid("scalar binary requires one row input")); + }; + if !query_time { + return Err(invalid("scalar literal binary must run at query time")); + } + let scalar = + Operator::scalar(crate::values::Value::Float64(value), DataType::Float64)?; + let (sides, scalars, operands) = if left { + ( + [scalar.schema(), input.clone()], + [true, false], + vec![auxiliary, inputs[0]], + ) + } else { + ( + [input.clone(), scalar.schema()], + [false, true], + vec![inputs[0], auxiliary], + ) + }; + let [l, r] = sides; + let binary = Operator::series_binary(l, r, operator.clone(), scalars) + .map_err(|error| invalid(format!("node {id}: {error}")))?; + dag.add(auxiliary, vec![], scalar)?; + dag.add(id, operands, binary.with_output_schema(output)?)?; + auxiliary -= 1; + continue; + } + let label_map = |schema: &SchemaRef| { + schema + .fields + .iter() + .any(|f| matches!(f.dtype, FieldDataType::Plain(DataType::Map { .. }))) + }; + // Grouped rows carry their labels as columns; per-series rows + // carry the series identity. + if let (true, [left, right]) = (query_time, schemas.as_slice()) { + if !label_map(left) && !label_map(right) { + let binary = Operator::series_binary( + left.clone(), + right.clone(), + operator.clone(), + [false, false], + ) + .map_err(|error| invalid(format!("node {id}: {error}")))?; + dag.add(id, inputs, binary.with_output_schema(output)?)?; + continue; + } + } + } + if let Payload::FinalizeExactAccumulator = &node.payload { + // Exact counts read out as Int64; PromQL declares a Float64 sample. + let evaluation = bind_operation(node, &schemas) + .map_err(|error| invalid(format!("node {id}: {error}")))?; + let actual = evaluation.schema(); + let converted = actual.fields.iter().zip(&output.fields).position(|(a, d)| { + a.dtype == FieldDataType::Plain(DataType::Int64) + && d.dtype == FieldDataType::Plain(DataType::Float64) + }); + if let Some(column) = converted { + let columns = actual + .fields + .iter() + .enumerate() + .map(|(i, field)| { + ( + field.name.clone(), + if i == column { + Expression::ExactFloat64(i) + } else { + Expression::Column(i) + }, + ) + }) + .collect(); + let project = + Operator::project(actual, columns)?.with_output_schema(output.clone())?; + dag.add(auxiliary, inputs, evaluation)?; + if temporal_evaluation_drops_name(node) { + dag.add(auxiliary - 1, vec![auxiliary], project)?; + dag.add( + id, + vec![auxiliary - 1], + Operator::series_without_name(output)?, + )?; + } else { + dag.add(id, vec![auxiliary], project)?; + } + auxiliary -= 1; + continue; + } + } + let mut operator = compile_node(node, &schemas) + .map_err(|error| invalid(format!("node {id}: {error}")))?; + if operator.is_counter_readout() { + let mut pending = vec![id]; + let mut visited = BTreeSet::new(); + let mut ranges = BTreeSet::new(); + while let Some(ancestor) = pending.pop() { + if !visited.insert(ancestor) { + continue; + } + if let Payload::Relational { + operator: NonASAPOpKind::TimeRange { range, .. }, + } = &nodes[&ancestor].payload + { + ranges.insert( + i64::try_from(range.as_millis()) + .map_err(|_| invalid("counter lookback exceeds Int64"))?, + ); + continue; + } + pending.extend(dependencies.get(&ancestor).into_iter().flatten().copied()); + } + if ranges.len() > 1 { + return Err(invalid("counter evaluation has ambiguous logical windows")); + } + if let Some(lookback) = ranges.into_iter().next() { + operator = operator.with_counter_lookback(lookback)?; + } + } + if temporal_evaluation_drops_name(node) { + dag.add(auxiliary, inputs, operator)?; + dag.add(id, vec![auxiliary], Operator::series_without_name(output)?)?; + } else { + dag.add(id, inputs, operator)?; + } + } + } + dag.validate()?; + Ok(dag) +} + +// Temporal summary evaluations produce PromQL vectors, whose range functions drop +// the metric name before matching/filtering. Stored state retains its full identity. +fn temporal_evaluation_drops_name(node: &PhysicalASAPDAGNode) -> bool { + node.output_schema + .fields + .iter() + .any(|field| field.name == promql_rows::SERIES_IDENTITY_COLUMN) + && matches!( + &node.payload, + Payload::FinalizeExactAccumulator + | Payload::SummaryEstimate { + query: SketchStatistic::Quantile { .. } + | SketchStatistic::Cardinality + | SketchStatistic::PointCount { .. } + | SketchStatistic::FrequencyL2 + | SketchStatistic::FrequencyEntropy + } + ) +} + +/// Bind a Planner node against the schemas supplied by its deployment edges. +/// This is the same checked path used by complete DAG binding. +pub fn compile_node(node: &PhysicalASAPDAGNode, inputs: &[SchemaRef]) -> Result { + for schema in inputs { + crate::values::validate_schema(schema)?; + } + bind_operation(node, inputs)?.with_output_schema(Arc::new(node.output_schema.clone())) +} + +fn bind_operation(node: &PhysicalASAPDAGNode, inputs: &[SchemaRef]) -> Result { + if let Payload::Relational { + operator: NonASAPOpKind::BinaryOp { + operator, + return_bool, + }, + } = &node.payload + { + let operator = + crate::expressions::binary::BinaryOperator::from_logical(operator, *return_bool); + let [left, right] = inputs else { + return Err(invalid("binary requires two inputs")); + }; + if node.output_state.timing == planner_types::post_asap::ExecutionTiming::IngestionTime { + let value = |schema: &SchemaRef| -> Result { + let columns = schema + .fields + .iter() + .enumerate() + .filter(|(_, field)| { + field.dtype + == FieldDataType::Plain(planner_types::pre_asap::DataType::Float64) + }) + .map(|(i, _)| i) + .collect::>(); + match columns.as_slice() { + [value] => Ok(*value), + _ => Err(invalid("aligned binary requires one value column")), + } + }; + let (l, r) = (value(left)?, value(right)?); + let keys = left + .fields + .iter() + .enumerate() + .filter(|(i, _)| *i != l) + .map(|(i, field)| { + right + .fields + .iter() + .position(|other| other.name == field.name && other.dtype == field.dtype) + .map(|j| (i, j)) + .ok_or_else(|| invalid("aligned input identities differ")) + }) + .collect::, _>>()?; + return Operator::aligned_binary( + left.clone(), + right.clone(), + keys, + (l, r), + operator.clone(), + ); + } + return Operator::vector_binary(left.clone(), right.clone(), operator.clone(), false); + } + if let Payload::Relational { + operator: NonASAPOpKind::Join { join_kind, pred }, + } = &node.payload + { + let [left, right] = inputs else { + return Err(invalid("join requires two inputs")); + }; + let pred = planner_types::ir::Predicate(local_scalar(&pred.0)?); + if *join_kind == planner_types::pre_asap::JoinKind::Semi { + if let Ok(keys) = equijoin_keys(&pred, left, right) { + return Operator::semi_join(left.clone(), right.clone(), keys); + } + } + return Operator::unified_relational_join( + left.clone(), + right.clone(), + join_kind.clone(), + &pred, + Arc::new(node.output_schema.clone()), + ); + } + if let Payload::Relational { + operator: NonASAPOpKind::Values { rows, schema }, + } = &node.payload + { + if !inputs.is_empty() { + return Err(invalid("Values takes no relational inputs")); + } + let empty = Arc::new(planner_types::pre_asap::Schema::default()); + let rows = rows + .iter() + .map(|row| { + row.iter() + .map(|expr| expression(expr, &empty)?.evaluate(&[])) + .collect::, Error>>() + }) + .collect::, Error>>()?; + let schema = Arc::new(schema.clone()); + return Operator::source( + schema.clone(), + vec![crate::values::Batch::try_new(schema, rows)?], + ); + } + let [input] = inputs else { + return Err(invalid( + "native Planner binding currently requires a unary operation or an explicit source", + )); + }; + match &node.payload { + Payload::FinalizeExactAccumulator => { + let state = summary_column(input)?; + use crate::Statistic as S; + use planner_types::post_asap::ExactKind as E; + let statistic = match &input.fields[state].dtype { + FieldDataType::ExactAggregate(kind, _) => match kind { + E::Sum => S::Sum, + E::Count => S::Count, + E::Min => S::Min, + E::Max => S::Max, + E::Rate => S::Rate, + E::Increase => S::Increase, + _ => return Err(invalid("exact family evaluation is unsupported")), + }, + _ => return Err(invalid("exact finalization requires exact state")), + }; + Operator::readout( + input.clone(), + state, + ReadoutQuery::Exact(ExactReadout { + statistic, + lookback_ms: None, + }), + ) + } + + Payload::Relational { operator } => match operator { + NonASAPOpKind::Project { cols, .. } => Operator::project( + input.clone(), + cols.iter() + .enumerate() + .map(|(i, col)| { + Ok(( + node.output_schema + .fields + .get(i) + .ok_or_else(|| invalid("projection width mismatch"))? + .name + .clone(), + match &col.expr { + WireScalarExpr::Column(index) => Expression::Column(*index), + expr => expression(expr, input)?, + }, + )) + }) + .collect::>()?, + ), + NonASAPOpKind::Filter { pred } => { + Operator::filter(input.clone(), expression(&pred.0, input)?) + } + NonASAPOpKind::Sort { keys, partition_by } => Operator::sort( + input.clone(), + keys.iter() + .map(|key| { + let WireScalarExpr::Column(column) = key.expr else { + return Err(invalid( + "sort expression must be projected before sorting", + )); + }; + Ok(SortKey { + column, + descending: !key.ascending, + nulls_first: key.nulls_first, + }) + }) + .collect::>()?, + groups(input, partition_by)?, + ), + NonASAPOpKind::Limit { + n, + offset, + partition_by, + } => Operator::limit( + input.clone(), + n.unwrap_or(usize::MAX) as u64, + *offset as u64, + groups(input, partition_by)?, + ), + NonASAPOpKind::Aggregate { + reduction, + measures, + output_names, + filters, + having: None, + } => { + if filters.iter().any(Option::is_some) { + return Err(invalid("filtered aggregate has no native implementation")); + } + if measures.len() != output_names.len() { + return Err(invalid("aggregate output names differ from measures")); + } + let PlannerReduction::Reduce(keys) = reduction else { + return Err(invalid( + "per-entity aggregate requires an explicit entity binding", + )); + }; + let measures = measures + .iter() + .zip(output_names) + .map(|(m, name)| { + let column = |col: Option| { + col.map(Ok) + .unwrap_or_else(|| named_column(input, &ColumnRef::SampleValue)) + }; + let m = match m { + AggIntent::Count { .. } => Reduction::Count, + AggIntent::Sum { col } => Reduction::Sum(column(*col)?), + AggIntent::Avg { col } => Reduction::Avg(column(*col)?), + AggIntent::Min { col } => Reduction::Min(column(*col)?), + AggIntent::Max { col } => Reduction::Max(column(*col)?), + _ => { + return Err(invalid( + "aggregate intent has no native implementation", + )) + } + }; + Ok((name.clone(), m)) + }) + .collect::>()?; + Operator::aggregate(input.clone(), groups(input, keys)?, measures) + } + _ => Err(invalid("value operation has no native implementation")), + }, + Payload::SummaryAgg { + family, + input: update, + reduction, + grouping, + filter, + } => { + if filter.is_some() { + return Err(invalid( + "filtered summary update has no native implementation", + )); + } + if let Some(item) = &update.item { + let PlannerReduction::Reduce(keys) = reduction else { + return Err(invalid("keyed summary requires explicit partitions")); + }; + let SummaryInputExpr::Column(weight) = &update.weight else { + return Err(invalid( + "keyed summary weight must be a finalized value column", + )); + }; + if matches!(family, FieldDataType::Sketch(kind, _) if kind.algorithm() == &planner_types::post_asap::SketchAlgorithm::CmsWithHeap) + && !matches!( + update.weight_domain, + planner_types::post_asap::WeightDomain::NonNegative { .. } + ) + { + return Err(invalid("CMS requires a nonnegative weight contract")); + } + fn columns( + expr: &SummaryInputExpr, + input: &SchemaRef, + result: &mut Vec, + ) -> Result<(), Error> { + match expr { + SummaryInputExpr::Column(column) => { + result.push(named_column(input, column)?) + } + SummaryInputExpr::Tuple(items) => { + for item in items { + columns(item, input, result)?; + } + } + _ => return Err(invalid("keyed summary needs explicit item columns")), + } + Ok(()) + } + let mut items = Vec::new(); + columns(item, input, &mut items)?; + return Operator::keyed_summary_build( + input.clone(), + family.clone(), + named_column(input, weight)?, + items, + groups(input, keys)?, + ); + } + crate::capability::validate_summary_kernel(family, update, grouping) + .map_err(Error::Invalid)?; + let SummaryInputExpr::Column(column) = &update.weight else { + return Err(invalid( + "summary update expression must be projected to a column", + )); + }; + let PlannerReduction::Reduce(keys) = reduction else { + return Err(invalid( + "summary construction requires explicit grouping columns", + )); + }; + Operator::summary_build( + input.clone(), + family.clone(), + named_column(input, column)?, + input.time_index, + groups(input, keys)?, + ) + } + Payload::SummaryMerge => { + let state = summary_column(input)?; + Operator::summary_merge( + input.clone(), + state, + (0..input.fields.len()) + .filter(|&i| i != state && Some(i) != input.time_index) + .collect(), + ) + } + Payload::SummaryEstimate { query } => { + if let SketchStatistic::TopK { k } = query { + return Operator::keyed_readout( + input.clone(), + summary_column(input)?, + *k, + Arc::new(node.output_schema.clone()), + ); + } + Operator::readout( + input.clone(), + summary_column(input)?, + ReadoutQuery::Sketch(query.clone()), + ) + } + _ => Err(invalid( + "physical operation has no native binding; no fallback is installed", + )), + } +} +fn summary_column(input: &SchemaRef) -> Result { + let columns = input + .fields + .iter() + .enumerate() + .filter(|(_, f)| !matches!(f.dtype, FieldDataType::Plain(_))) + .map(|(i, _)| i) + .collect::>(); + match columns.as_slice() { + [column] => Ok(*column), + _ => Err(invalid("one summary state column required")), + } +} +fn named_column(input: &SchemaRef, column: &ColumnRef) -> Result { + let name = match column { + // Executable SchemaRef retains column names, not table qualifiers. + // Frontend binding has resolved the qualifier; still reject ambiguous + // names here rather than guessing a join side. + ColumnRef::Named(name) | ColumnRef::Qualified { name, .. } => name.as_str(), + ColumnRef::SampleValue => "value", + _ => { + return Err(invalid( + "summary update requires an unambiguous bound column", + )) + } + }; + let matches = input + .fields + .iter() + .enumerate() + .filter(|(_, field)| field.name == name) + .map(|(i, _)| i) + .collect::>(); + match matches.as_slice() { + [column] => Ok(*column), + _ => Err(invalid("summary update column missing or ambiguous")), + } +} +fn groups(input: &SchemaRef, groups: &GroupKeys) -> Result, Error> { + if groups.is_without() { + return Err(invalid("grouping without requires resolved label columns")); + } + if groups.keys().iter().any(|&i| i >= input.fields.len()) { + return Err(invalid("grouping column out of range")); + } + Ok(groups.keys().to_vec()) +} +fn expression(expr: &WireScalarExpr, input: &SchemaRef) -> Result { + let expr = local_scalar(expr)?; + Ok(Expression::unified_planner( + crate::expressions::unified_planner::CompiledExpression::compile(&expr, input)?, + )) +} + +struct CheckedSource<'a> { + source: Source<'a>, + output: SchemaRef, +} +impl PhysicalOperator for CheckedSource<'_> { + fn properties(&self, inputs: &[crate::plan::PlanProperties]) -> crate::plan::PlanProperties { + self.source.properties(inputs) + } + + fn name(&self) -> &str { + self.source.name() + } + fn input_schemas(&self) -> Vec { + vec![] + } + fn output_schema(&self) -> SchemaRef { + self.output.clone() + } + fn output_bytes(&self, batch: &Batch) -> usize { + self.source.output_bytes(batch) + } + fn start<'a>( + &'a self, + inputs: Vec>, + context: crate::runtime::RunContext, + ) -> Result, Error> { + use futures::StreamExt; + Ok(self + .source + .start(inputs, context)? + .map(|batch| { + let batch = batch?; + if batch.schema() != &self.output { + return Err(invalid("source batch differs from its bound schema")); + } + Ok(batch) + }) + .boxed_local()) + } +} + +// Bound recursion before invoking the upstream recursive provenance validator. +fn preflight_depth(dag: &PhysicalASAPDAG) -> Result<(), Error> { + let mut remaining = dag + .nodes + .iter() + .map(|node| (node.id, 0usize)) + .collect::>(); + if remaining.len() != dag.nodes.len() { + return Err(invalid("duplicate Planner node")); + } + let mut consumers = BTreeMap::<_, Vec<_>>::new(); + for edge in &dag.edges { + if !remaining.contains_key(&edge.producer) { + return Err(invalid("missing Planner edge producer")); + } + *remaining + .get_mut(&edge.consumer) + .ok_or_else(|| invalid("missing Planner edge consumer"))? += 1; + consumers + .entry(edge.producer) + .or_default() + .push(edge.consumer); + } + let mut ready = remaining + .iter() + .filter(|(_, n)| **n == 0) + .map(|(id, _)| *id) + .collect::>(); + let mut depths = BTreeMap::new(); + let mut visited = 0; + while let Some(id) = ready.pop_front() { + visited += 1; + let depth = *depths.get(&id).unwrap_or(&1usize); + if depth > 128 { + return Err(invalid("DAG exceeds the supported execution depth of 128")); + } + for &consumer in consumers.get(&id).into_iter().flatten() { + let next = depths.entry(consumer).or_insert(1); + *next = (*next).max(depth + 1); + let count = remaining.get_mut(&consumer).expect("validated endpoint"); + *count -= 1; + if *count == 0 { + ready.push_back(consumer); + } + } + } + if visited != dag.nodes.len() { + return Err(invalid("Planner DAG contains a cycle")); + } + Ok(()) +} + +/// Join predicates address the concatenated left/right schema. +fn semi_join_keys( + expr: &ScalarExpr, + left: usize, + right: usize, + keys: &mut Vec<(usize, usize)>, +) -> Result<(), Error> { + match expr { + ScalarExpr::BoolAnd(parts) => { + for part in parts { + semi_join_keys(part, left, right, keys)?; + } + } + ScalarExpr::Compare { + left: a, + op: CompareOpKind::Eq, + right: b, + .. + } => { + let (ScalarExpr::Column(a), ScalarExpr::Column(b)) = (a.as_ref(), b.as_ref()) else { + return Err(invalid("semi-join requires column equality keys")); + }; + let (a, b) = if a < b { (*a, *b) } else { (*b, *a) }; + if a >= left || b < left || b >= left + right { + return Err(invalid("semi-join key must match left to right")); + } + keys.push((a, b - left)); + } + _ => return Err(invalid("unsupported semi-join predicate")), + } + Ok(()) +} + +/// Resolve equality keys against the Planner join's concatenated input schema. +/// Deployments may use these positions to bind their source columns. +pub fn equijoin_keys( + pred: &planner_types::ir::Predicate, + left: &planner_types::post_asap::Schema, + right: &planner_types::post_asap::Schema, +) -> Result, Error> { + let mut keys = Vec::new(); + semi_join_keys(&pred.0, left.fields.len(), right.fields.len(), &mut keys)?; + if keys.is_empty() { + return Err(invalid("semi-join requires explicit matching keys")); + } + Ok(keys) +} + +fn local_scalar(expr: &WireScalarExpr) -> Result { + let mut missing = false; + let result = logical::scalar(expr, &mut |_| { + missing = true; + std::rc::Rc::new(OperatorNode::with_schema( + LogicalOperator::NonASAP(NonASAPOp::Values { + rows: vec![], + schema: Default::default(), + }), + Default::default(), + )) + }); + if missing { + Err(invalid( + "scalar plan reads require explicit execution bindings", + )) + } else { + Ok(result) + } +} diff --git a/crates/asap-physical-operators/src/unified_physical_planner/precompute.rs b/crates/asap-physical-operators/src/unified_physical_planner/precompute.rs new file mode 100644 index 000000000..12e7564d8 --- /dev/null +++ b/crates/asap-physical-operators/src/unified_physical_planner/precompute.rs @@ -0,0 +1,643 @@ +//! Compile immutable summary-input computation with explicit population and pane identity. +use super::promql_rows::SERIES_IDENTITY_COLUMN as SERIES_IDENTITY; +use super::*; +use planner_types::post_asap::FieldDataType as SummaryFamilyType; +use planner_types::{ + post_asap::{ExecutionTiming, GroupingStrategy, Schema}, + pre_asap::DataType, +}; + +/// Physical rows carry the population and pane coordinate alongside the logical value. +/// These fields preserve identities which are implicit in a stored summary instance. +pub fn population_schema(family: SummaryFamilyType) -> SchemaRef { + Arc::new(Schema { + fields: vec![ + planner_types::post_asap::Field { + name: "$population".into(), + dtype: SummaryFamilyType::Plain(DataType::Map { + key: Box::new(DataType::Utf8), + value: Box::new(DataType::Utf8), + value_nullable: false, + }), + nullable: false, + table: None, + }, + planner_types::post_asap::Field { + name: "$window_end".into(), + dtype: SummaryFamilyType::Plain(DataType::Timestamp), + nullable: false, + table: None, + }, + planner_types::post_asap::Field { + name: "value".into(), + dtype: family, + nullable: false, + table: None, + }, + ], + time_index: Some(1), + unique_keys: vec![], + closed: false, + }) +} + +/// Raw sample rows at a precompute boundary. `$population` holds the series' +/// complete label set, so it is the complete source identity of per-series +/// summaries; `$timestamp` is the sample time and `value` a finite sample +/// (stale markers are not samples). Rows are what the boundary's source scan +/// selected; the deployment decides which rows and panes they are. Label sets +/// must be canonical (sorted, unique, no empty values), since they are the +/// population identity: build rows with [`raw_sample_row`]. +pub fn raw_sample_schema() -> SchemaRef { + let mut schema = (*population_schema(SummaryFamilyType::Plain(DataType::Float64))).clone(); + schema.fields[1].name = "$timestamp".into(); + Arc::new(schema) +} + +/// A raw sample row whose label set is sorted, unique and omits empty values, +/// so one series always has one population identity. +pub fn raw_sample_row( + labels: &BTreeMap, + timestamp_ms: i64, + value: f64, +) -> Vec { + use crate::values::Value; + vec![ + Value::Map( + labels + .iter() + .filter(|(_, v)| !v.is_empty()) + .map(|(k, v)| { + ( + Value::Utf8(k.as_str().into()), + Value::Utf8(v.as_str().into()), + ) + }) + .collect::>() + .into(), + ), + Value::Timestamp(timestamp_ms), + Value::Float64(value), + ] +} + +/// Input contract of a precompute boundary: raw sample rows for a raw time +/// series scan, otherwise the stored population of its summary state. +pub fn boundary_schema(node: &PhysicalASAPDAGNode) -> Result { + if !matches!( + &node.payload, + Payload::Relational { + operator: NonASAPOpKind::Scan { + source: planner_types::pre_asap::Source::TimeSeries { .. }, + .. + } | NonASAPOpKind::TimeRange { .. } + } + ) { + return source_schema(&node.output_schema); + } + let logical = &node.output_schema; + // Labels may be absent from a series; its label map then omits them. + let valid = logical + .fields + .iter() + .enumerate() + .all(|(i, field)| match &field.dtype { + SummaryFamilyType::Plain(DataType::Timestamp) => { + Some(i) == logical.time_index && !field.nullable + } + SummaryFamilyType::Plain(DataType::Float64) => field.name == "value" && !field.nullable, + SummaryFamilyType::Plain(DataType::Utf8) => true, + _ => false, + }) + && !logical + .fields + .iter() + .any(|f| f.name.starts_with('$') && f.name != SERIES_IDENTITY) + && logical.time_index.is_some() + && logical.fields.iter().filter(|f| f.name == "value").count() == 1; + if !valid { + return Err(invalid( + "raw sample boundary requires labels, a timestamp and one Float64 value", + )); + } + Ok(raw_sample_schema()) +} + +/// Validate the adapter layout during installed-plan recovery without lowering operators. +pub fn source_schema(logical: &Schema) -> Result { + let states = logical + .fields + .iter() + .filter(|f| !matches!(f.dtype, SummaryFamilyType::Plain(_))) + .collect::>(); + let [state] = states.as_slice() else { + return Err(invalid( + "stored population requires one typed summary state", + )); + }; + if logical.fields.iter().enumerate().any(|(i, field)| matches!(&field.dtype, SummaryFamilyType::Plain(dtype) + if field.nullable || !matches!(dtype, DataType::Utf8) && !(Some(i) == logical.time_index && *dtype == DataType::Timestamp))) { + return Err(invalid("stored population metadata cannot reconstruct extra value columns")); + } + if state.nullable { + return Err(invalid("stored population state cannot be null")); + } + Ok(population_schema(state.dtype.clone())) +} + +pub fn is_population_schema(schema: &SchemaRef) -> bool { + schema + .fields + .get(2) + .is_some_and(|field| *schema == population_schema(field.dtype.clone())) +} + +/// Compile a complete selected precompute sub-DAG. Inputs are already-computed +/// state boundaries; the deployment supplies groups, panes and states, never operations. +pub fn compile( + dag: &PhysicalASAPDAG, + frontiers: &[NodeId], + roots: &[NodeId], +) -> Result { + preflight_depth(dag)?; + dag.validate().map_err(|e| invalid(e.to_string()))?; + let nodes = dag + .nodes + .iter() + .map(|n| (u64::from(n.id.0), n)) + .collect::>(); + let frontier = frontiers.iter().copied().collect::>(); + if frontier.len() != frontiers.len() || roots.iter().any(|r| frontier.contains(r)) { + return Err(invalid( + "precompute boundaries must be distinct from outputs", + )); + } + let mut dependencies = BTreeMap::>::new(); + let mut edges = dag.edges.iter().collect::>(); + edges.sort_by_key(|edge| { + ( + edge.consumer.0, + match edge.role { + planner_types::ir::export::EdgeRole::Left => 0, + planner_types::ir::export::EdgeRole::Input => 1, + planner_types::ir::export::EdgeRole::Right => 2, + planner_types::ir::export::EdgeRole::ScalarRef => 3, + }, + ) + }); + for edge in edges { + dependencies + .entry(u64::from(edge.consumer.0)) + .or_default() + .push(u64::from(edge.producer.0)); + } + let mut ordered = Vec::new(); + let mut seen = BTreeSet::new(); + let mut pending = roots.iter().map(|&id| (id, false)).collect::>(); + while let Some((id, expanded)) = pending.pop() { + if expanded { + ordered.push(id); + continue; + } + if !seen.insert(id) { + continue; + } + if !nodes.contains_key(&id) { + return Err(invalid("missing precompute node")); + } + pending.push((id, true)); + if !frontier.contains(&id) { + pending.extend( + dependencies + .get(&id) + .into_iter() + .flatten() + .map(|id| (*id, false)), + ); + } + } + let mut sources = BTreeMap::new(); + let mut fragments = BTreeMap::new(); + let mut outputs = BTreeMap::::new(); + for id in ordered { + let node = nodes[&id]; + if frontier.contains(&id) { + let schema = boundary_schema(node)?; + sources.insert(id, InputContract::bounded(schema.clone())); + outputs.insert(id, schema); + continue; + } + if node.output_state.timing != ExecutionTiming::IngestionTime { + return Err(invalid("precompute dag contains a query-time operation")); + } + let inputs = dependencies.get(&id).cloned().unwrap_or_default(); + let schemas = inputs + .iter() + .map(|id| { + outputs + .get(id) + .cloned() + .ok_or_else(|| invalid("missing precompute input")) + }) + .collect::, _>>()?; + let dag = fragment( + node, + &schemas, + &inputs.iter().map(|id| nodes[id]).collect::>(), + )?; + outputs.insert(id, dag.output_contract(dag.roots()[0])?.schema); + fragments.insert(id, (inputs, dag)); + } + CompiledPhysicalDAG::compose(sources, fragments, roots.to_vec()) +} + +fn validate_value_output(node: &PhysicalASAPDAGNode) -> Result<(), Error> { + let schema = &node.output_schema; + // Physical population rows already carry the complete identity in `$population`. + // Typed logical plans may expose its opaque series-identity column as metadata. + let identity = planner_types::pre_asap::schema::PROMQL_SERIES_IDENTITY; + let identities = schema + .fields + .iter() + .filter(|field| field.name == identity) + .collect::>(); + if identities.len() > 1 + || identities + .iter() + .any(|field| field.nullable || field.dtype != SummaryFamilyType::Plain(DataType::Utf8)) + { + return Err(invalid( + "precompute series identity requires one non-null Utf8 column", + )); + } + let values = schema + .fields + .iter() + .enumerate() + .filter(|(i, field)| Some(*i) != schema.time_index && field.name != identity) + .collect::>(); + if !matches!(values.as_slice(), [(_, field)] if !field.nullable && field.dtype == SummaryFamilyType::Plain(DataType::Float64)) + || schema.time_index.is_some_and(|i| { + schema.fields.get(i).is_none_or(|f| { + f.nullable || f.dtype != SummaryFamilyType::Plain(DataType::Timestamp) + }) + }) + { + return Err(invalid( + "precompute value schema requires Float64 and an optional declared timestamp", + )); + } + Ok(()) +} + +fn fragment( + node: &PhysicalASAPDAGNode, + schemas: &[SchemaRef], + parents: &[&PhysicalASAPDAGNode], +) -> Result { + let sources = schemas + .iter() + .enumerate() + .map(|(id, schema)| (id as u64, InputContract::bounded(schema.clone()))) + .collect(); + let mut operators = BTreeMap::new(); + let mut next = schemas.len() as u64; + let mut add = |inputs: Vec, op: Operator| -> Result { + let id = next; + next += 1; + operators.insert(id, (inputs, op)); + Ok(id) + }; + let root = match &node.payload { + Payload::Relational { + operator: + NonASAPOpKind::BinaryOp { + operator, + return_bool, + }, + } => { + let operator = + crate::expressions::binary::BinaryOperator::from_logical(operator, *return_bool); + validate_value_output(node)?; + if node.output_schema.time_index.is_none() + || parents.iter().any(|p| p.output_schema.time_index.is_none()) + { + return Err(invalid( + "precompute binary requires declared window timestamps", + )); + } + let [left, right] = schemas else { + return Err(invalid("precompute binary requires two inputs")); + }; + add( + vec![0, 1], + Operator::aligned_binary( + left.clone(), + right.clone(), + vec![(0, 0), (1, 1)], + (2, 2), + operator.clone(), + )?, + )? + } + Payload::FinalizeExactAccumulator => { + let [input] = schemas else { + return Err(invalid("finalize requires one state input")); + }; + validate_value_output(node)?; + let statistic = match &input.fields[2].dtype { + SummaryFamilyType::ExactAggregate(planner_types::post_asap::ExactKind::Sum, _) => { + crate::Statistic::Sum + } + SummaryFamilyType::ExactAggregate( + planner_types::post_asap::ExactKind::Count, + _, + ) => crate::Statistic::Count, + _ => { + return Err(invalid( + "precompute finalization requires explicit Sum or Count semantics", + )) + } + }; + let read = Operator::readout( + input.clone(), + 2, + ReadoutQuery::Exact(ExactReadout { + statistic, + lookback_ms: None, + }), + )?; + let output = read.schema(); + let read = add(vec![0], read)?; + let project = Operator::project( + output, + vec![ + ("$population".into(), Expression::Column(0)), + ("$window_end".into(), Expression::Column(1)), + ( + "value".into(), + Expression::FiniteFloat64(Box::new(Expression::ExactFloat64(2))), + ), + ], + )? + .with_output_schema(population_schema(SummaryFamilyType::Plain( + DataType::Float64, + )))?; + add(vec![read], project)? + } + Payload::SummaryAgg { + family, + input: update, + reduction, + grouping, + filter, + } => { + if filter.is_some() { + return Err(invalid( + "filtered summary update has no native implementation", + )); + } + let [input] = schemas else { + return Err(invalid("summary update requires one input")); + }; + // Item identities resolve against the complete label set of raw + // samples; finalized evaluations carry no such identity. + let raw = *input == raw_sample_schema(); + // A unit-frequency summary (HLL) observes each raw sample value. + let unit_frequency = raw + && crate::capability::is_unit_sample_frequency(update) + && matches!(family, SummaryFamilyType::Sketch(kind, _) if !matches!( + kind.algorithm(), + planner_types::post_asap::SketchAlgorithm::Cms + | planner_types::post_asap::SketchAlgorithm::CountSketch + | planner_types::post_asap::SketchAlgorithm::CmsWithHeap + | planner_types::post_asap::SketchAlgorithm::CountSketchWithHeap + )); + let keyed = update.item.is_some() && !unit_frequency; + if (keyed && !raw) || !matches!(grouping, GroupingStrategy::PerSubpopulationInstance) { + return Err(invalid( + "precompute keyed/shared update needs its dedicated physical candidate", + )); + } + crate::capability::validate_summary_kernel(family, update, grouping) + .map_err(Error::Invalid)?; + if raw + && matches!( + update.weight_domain, + planner_types::post_asap::WeightDomain::NonNegative { + proof: planner_types::post_asap::NonNegativeWeightProof::ResetAwareCounterDerivative + } + ) + { + return Err(invalid( + "a counter-derivative weight cannot be read from raw cumulative samples", + )); + } + if keyed + && matches!(family, SummaryFamilyType::Sketch(kind, _) if kind.algorithm() == &planner_types::post_asap::SketchAlgorithm::CmsWithHeap) + && !matches!( + update.weight_domain, + planner_types::post_asap::WeightDomain::NonNegative { .. } + ) + { + return Err(invalid("CMS requires a nonnegative weight contract")); + } + let labels = match reduction { + PlannerReduction::PerEntity => Expression::Column(0), + PlannerReduction::Reduce(keys) => Expression::LabelSet { + column: 0, + labels: keys + .keys() + .iter() + .map(|key| { + parents[0] + .output_schema + .fields + .get(*key) + // A raw label map omits absent labels; the + // series identity is not one of its labels. + .filter(|field| { + (raw || !field.nullable) + && field.name != SERIES_IDENTITY + && field.dtype == SummaryFamilyType::Plain(DataType::Utf8) + }) + .map(|f| f.name.clone()) + .ok_or_else(|| { + invalid("summary grouping must identify population labels") + }) + }) + .collect::, _>>()?, + without: keys.is_without(), + }, + }; + let weight = match &update.weight { + _ if unit_frequency => Expression::Column(2), + SummaryInputExpr::Constant(value) => Expression::Literal { + value: crate::values::Value::Float64(*value), + dtype: DataType::Float64, + }, + SummaryInputExpr::Column(ColumnRef::SampleValue) => Expression::Column(2), + SummaryInputExpr::Column(ColumnRef::Named(name)) + if parents[0].output_schema.fields.iter().any(|f| { + f.name == *name && f.dtype == SummaryFamilyType::Plain(DataType::Float64) + }) => + { + Expression::Column(2) + } + _ => { + return Err(invalid( + "summary weight does not resolve to the input value", + )) + } + }; + let mut columns = vec![ + ("$population".into(), labels), + ("$window_end".into(), Expression::Column(1)), + ("value".into(), Expression::FiniteFloat64(Box::new(weight))), + ]; + let mut fields = population_schema(SummaryFamilyType::Plain(DataType::Float64)) + .fields + .clone(); + if keyed { + let mut items = Vec::new(); + raw_items( + update.item.as_ref().expect("keyed item"), + &parents[0].output_schema, + &mut items, + )?; + for (index, (expression, dtype)) in items.into_iter().enumerate() { + let name = format!("$item{index}"); + fields.push(planner_types::post_asap::Field { + name: name.clone(), + dtype: SummaryFamilyType::Plain(dtype), + nullable: false, + table: None, + }); + columns.push((name, expression)); + } + } + let item_columns = (3..fields.len()).collect::>(); + let project = Operator::project(input.clone(), columns)?.with_output_schema( + Arc::new(Schema { + fields, + unique_keys: vec![], + closed: false, + time_index: Some(1), + }), + )?; + let projected = project.schema(); + let project = add(vec![0], project)?; + let build = if keyed { + Operator::keyed_summary_build(projected, family.clone(), 2, item_columns, vec![0])? + } else { + Operator::summary_build(projected, family.clone(), 2, Some(1), vec![0])? + }; + let built = build.schema(); + let build = add(vec![project], build)?; + add( + vec![build], + Operator::scope_timestamp(built, population_schema(family.clone()))?, + )? + } + Payload::SummaryMerge => { + let Some(input) = schemas.first() else { + return Err(invalid("summary merge requires inputs")); + }; + if schemas.iter().any(|s| s != input) { + return Err(invalid("summary merge inputs differ")); + } + let union = add( + (0..schemas.len() as u64).collect(), + Operator::union(input.clone(), schemas.len())?, + )?; + let merge = Operator::summary_merge(input.clone(), 2, vec![0])?; + let merged = merge.schema(); + let merge = add(vec![union], merge)?; + add( + vec![merge], + Operator::scope_timestamp(merged, input.clone())?, + )? + } + _ => { + return Err(invalid( + "precompute operation has no native population implementation", + )) + } + }; + CompiledPhysicalDAG::from_operators(sources, operators, vec![root]) +} + +/// Resolve keyed item identities over raw sample rows: labels (absent labels +/// read as empty, as in PromQL), the sample value, or the canonical encoding +/// of the label set less excluded labels. +fn raw_items( + expr: &SummaryInputExpr, + scan: &Schema, + items: &mut Vec<(Expression, DataType)>, +) -> Result<(), Error> { + // Open PromQL scans need not list every label, so any name that is not + // another scan column (value, time, series identity) reads as a label. + let label = |column: &ColumnRef| match column { + ColumnRef::Named(name) | ColumnRef::Qualified { name, .. } + if !name.starts_with('$') + && scan.fields.iter().all(|f| { + &f.name != name || f.dtype == SummaryFamilyType::Plain(DataType::Utf8) + }) => + { + Some(name.clone()) + } + _ => None, + }; + let identity = |excluding: Vec| { + ( + Expression::LabelIdentity { + column: 0, + excluding, + }, + DataType::Utf8, + ) + }; + match expr { + SummaryInputExpr::Column(ColumnRef::SampleValue) => { + items.push((Expression::Column(2), DataType::Float64)) + } + SummaryInputExpr::Column(ColumnRef::Named(name) | ColumnRef::Qualified { name, .. }) + if name == "value" => + { + items.push((Expression::Column(2), DataType::Float64)) + } + SummaryInputExpr::Column(ColumnRef::Named(name) | ColumnRef::Qualified { name, .. }) + if name == SERIES_IDENTITY => + { + items.push(identity(vec![])) + } + SummaryInputExpr::Column(column) if label(column).is_some() => items.push(( + Expression::Label { + column: 0, + name: label(column).expect("resolved label"), + }, + DataType::Utf8, + )), + SummaryInputExpr::EntityIdentity( + planner_types::post_asap::EntityIdentity::PromqlLabelSet { excluding }, + ) => items.push(identity( + excluding + .iter() + .map(|column| { + label(column).ok_or_else(|| invalid("excluded identity label is not a label")) + }) + .collect::>()?, + )), + SummaryInputExpr::Tuple(parts) if !parts.is_empty() => { + for part in parts { + raw_items(part, scan, items)?; + } + } + _ => { + return Err(invalid( + "keyed summary item does not resolve over raw samples", + )) + } + } + Ok(()) +} diff --git a/crates/asap-physical-operators/src/unified_physical_planner/promql_fallback.rs b/crates/asap-physical-operators/src/unified_physical_planner/promql_fallback.rs new file mode 100644 index 000000000..5d03bb0b9 --- /dev/null +++ b/crates/asap-physical-operators/src/unified_physical_planner/promql_fallback.rs @@ -0,0 +1,859 @@ +//! Compile a retained PromQL sub-DAG (`Fallback`) from its typed expression. +//! The deployment supplies the raw series of each selector; the Planner +//! computes selection, range functions, subqueries, matching and aggregation. +use super::*; +use crate::operators::SubquerySteps; +use planner_types::post_asap::execution_data_state::lift_plain; +use planner_types::pre_asap::{AtModifier, VectorMatchKind}; + +/// Input slot for the raw series read by the `selector`th selector (in +/// [`raw_series`] order) of Fallback node `node`. The node's own ID names its +/// computed output, so the raw rows need another. +pub fn raw_series_input(node: NodeId, selector: usize) -> NodeId { + node | ((selector as u64 + 1) << 32) +} + +/// The Fallback node that owns a raw-series input slot. +pub(super) fn raw_series_owner(slot: NodeId) -> Option { + (slot >> 32 != 0).then_some(slot & u64::from(u32::MAX)) +} + +/// A selector expression and its raw-series row schema. +pub type Selector = (OperatorNode, SchemaRef); + +/// The selectors a Fallback expression reads, left to right, and the row +/// schema of the raw series the deployment supplies for each at +/// [`raw_series_input`]. The rows must cover the selector's window at every +/// evaluation instant `T`, or at its `@` time: `(T - offset - range, T - offset]`; +/// under a subquery `[R:S] offset O` that is `(T - O - R - offset - range, T - O - offset]`. +pub fn raw_series(expression: &OperatorNode) -> Result, Error> { + Ok(lower(expression)?.selectors) +} + +/// An operator input: a selector's raw rows or an earlier step. +pub(super) enum Input { + Raw(usize), + Step(usize), +} + +/// Operators computing an expression; the last step is its result. +#[derive(Default)] +pub(super) struct Lowering { + pub selectors: Vec, + pub steps: Vec<(Operator, Vec)>, +} + +pub(super) fn lower(expression: &OperatorNode) -> Result { + let mut lowering = Lowering::default(); + lowering.value(expression)?; + Ok(lowering) +} + +/// Compile a standalone scalar expression and expose its real series dependencies. +/// Input slots use root 0; no logical wrapper node is introduced. +pub fn compile_scalar_root( + expr: &ScalarExpr, +) -> Result<(CompiledPhysicalDAG, Vec), Error> { + let mut lowering = Lowering::default(); + lowering.scalar_value(expr)?; + let mut inputs = BTreeMap::new(); + for (i, (_, schema)) in lowering.selectors.iter().enumerate() { + inputs.insert( + raw_series_input(0, i), + InputContract::bounded(schema.clone()), + ); + } + let last = lowering.steps.len() - 1; + let mut operators = BTreeMap::new(); + for (i, (operator, dependencies)) in lowering.steps.into_iter().enumerate() { + let id = if i == last { 0 } else { i as u64 + 1 }; + let dependencies = dependencies + .into_iter() + .map(|input| match input { + Input::Raw(i) => raw_series_input(0, i), + Input::Step(i) => i as u64 + 1, + }) + .collect(); + operators.insert(id, (dependencies, operator)); + } + Ok(( + CompiledPhysicalDAG::from_operators(inputs, operators, vec![0])?, + lowering.selectors, + )) +} + +fn declared(expression: &OperatorNode) -> Result { + let schema = expression.schema.clone(); + Ok(Arc::new(lift_plain(&schema))) +} + +fn millis(duration: &std::time::Duration) -> Result { + i64::try_from(duration.as_millis()).map_err(|_| invalid("PromQL duration exceeds Int64")) +} + +/// A fixed `@` time. `start()`/`end()` depend on the deployment's range query. +fn at(shift: &planner_types::pre_asap::TimeShift) -> Result, Error> { + match shift.at { + None => Ok(None), + Some(AtModifier::Timestamp(at)) => Ok(Some(at)), + Some(AtModifier::Start | AtModifier::End) => Ok(None), + } +} + +fn range_anchor(expression: &OperatorNode) -> Option { + match expression.expect_non_asap() { + NonASAPOp::TimeRange { child, .. } => range_anchor(child), + NonASAPOp::TimeShift { shift, .. } => shift + .at + .filter(|at| matches!(at, AtModifier::Start | AtModifier::End)), + _ => None, + } +} + +/// `TimeRange { range, [TimeShift { offset, @ }], Scan }`: range, offset, `@`. +fn selector(expression: &OperatorNode) -> Result<(i64, i64, Option), Error> { + let NonASAPOp::TimeRange { range, child, .. } = expression.expect_non_asap() else { + return Err(invalid("PromQL operand must be a series selector")); + }; + let (offset, at, scan) = match child.expect_non_asap() { + NonASAPOp::TimeShift { shift, child } => { + (shift.offset_ms, at(shift)?, child.expect_non_asap()) + } + scan => (0, None, scan), + }; + if !matches!(scan, NonASAPOp::Scan { .. }) { + return Err(invalid("PromQL selector must read one scan")); + } + Ok((millis(range)?, offset, at)) +} + +impl Lowering { + fn schema(&self, input: &Input) -> SchemaRef { + match input { + Input::Raw(i) => self.selectors[*i].1.clone(), + Input::Step(i) => self.steps[*i].0.schema(), + } + } + + fn add(&mut self, operator: Operator, inputs: Vec) -> Input { + self.steps.push((operator, inputs)); + Input::Step(self.steps.len() - 1) + } + + /// Conform `operator` to the logical schema of the expression it computes. + fn push( + &mut self, + operator: Operator, + inputs: Vec, + logical: &OperatorNode, + ) -> Result { + Ok(self.add(operator.with_output_schema(declared(logical)?)?, inputs)) + } + + fn read(&mut self, selector: &OperatorNode) -> Result { + let schema = declared(selector)?; + if !schema + .fields + .iter() + .any(|f| f.name == promql_rows::SERIES_IDENTITY_COLUMN) + { + return Err(invalid( + "PromQL fallback requires the complete series identity", + )); + } + self.selectors.push((selector.clone(), schema)); + Ok(Input::Raw(self.selectors.len() - 1)) + } + + /// An instant vector, or a scalar for scalar-valued expressions. + fn value(&mut self, expression: &OperatorNode) -> Result { + match expression.expect_non_asap() { + NonASAPOp::Concat { children, .. } => { + if !children.iter().all(|branch| matches!(branch.expect_non_asap(), + NonASAPOp::PromqlRelabel { child, .. } if matches!(child.expect_non_asap(), + NonASAPOp::Aggregate { measures, .. } if matches!(measures.as_slice(), [AggIntent::HistogramQuantile { .. }])))) { + return Err(invalid("PromQL concatenation requires classic histogram quantile branches")); + } + let inputs = children + .iter() + .map(|child| self.value(child)) + .collect::, _>>()?; + let output = declared(expression)?; + if inputs.iter().any(|input| self.schema(input) != output) { + return Err(invalid( + "concatenated PromQL branches require equal schemas", + )); + } + let union = self.add(Operator::union(output.clone(), inputs.len())?, inputs); + // Multi-quantile branches drop the metric name and form one vector. + self.push( + Operator::series_without_name(output)?, + vec![union], + expression, + ) + } + NonASAPOp::PromqlRelabel { dst, value, child } => { + let step = self.value(child)?; + let input = self.schema(&step); + let (replacement, source_regex) = match value { + ScalarExpr::Literal(planner_types::pre_asap::ScalarValue::Utf8(value)) => { + (value.clone(), None) + } + ScalarExpr::FunctionCall { name, args } if name == "label_replace" => { + let [ScalarExpr::Column(source), ScalarExpr::Literal(planner_types::pre_asap::ScalarValue::Utf8( + pattern, + )), ScalarExpr::Literal(planner_types::pre_asap::ScalarValue::Utf8( + replacement, + ))] = args.as_slice() + else { + return Err(invalid("invalid label_replace arguments")); + }; + let source = input + .fields + .get(*source) + .ok_or_else(|| invalid("label_replace source missing"))? + .name + .clone(); + (replacement.clone(), Some((source, pattern.clone()))) + } + _ => return Err(invalid("unsupported PromQL label rewrite")), + }; + let operator = Operator::series_relabel( + input, + declared(expression)?, + dst.clone(), + replacement, + source_regex, + )?; + self.push(operator, vec![step], expression) + } + NonASAPOp::TimeRange { .. } => { + let (range, offset, at) = selector(expression)?; + let input = self.read(expression)?; + let schema = self.schema(&input); + self.push( + Operator::series_window(schema, None, range, offset, at, None)? + .with_series_range_bounds(range_anchor(expression), None)?, + vec![input], + expression, + ) + } + NonASAPOp::Aggregate { + reduction: planner_types::pre_asap::Reduction::PerEntity, + measures, + having: None, + child, + filters, + .. + } if filters.iter().all(Option::is_none) => { + let [function] = measures.as_slice() else { + return Err(invalid("range function requires one measure")); + }; + let step = self.range_function(function, child, expression)?; + if matches!(function, AggIntent::LastOverTime) { + return Ok(step); + } + // Other range functions drop the name; equal label sets then error. + let input = self.schema(&step); + Ok(self.add(Operator::series_without_name(input)?, vec![step])) + } + NonASAPOp::Aggregate { + reduction: planner_types::pre_asap::Reduction::Reduce(keys), + measures, + having: None, + child, + filters, + .. + } if filters.iter().all(Option::is_none) => { + let [measure] = measures.as_slice() else { + return Err(invalid("vector aggregation requires one measure")); + }; + let input = self.value(child)?; + self.aggregate(input, measure, keys, expression) + } + NonASAPOp::Project { + cols, + child, + qualifier, + } => { + let value = planner_types::pre_asap::column_resolution::resolve_column_ref( + &ColumnRef::SampleValue, + &child.schema, + ) + .map_err(|e| invalid(e.to_string()))?; + let sample = cols + .iter() + .find(|col| { + col.alias.as_deref() == Some(child.schema.fields[value].name.as_str()) + }) + .ok_or_else(|| invalid("missing sample projection"))?; + let keep_name = matches!(sample.expr, ScalarExpr::Negative { .. }); + let fields: Vec<_> = child + .schema + .fields + .iter() + .enumerate() + .filter(|(_, field)| keep_name || field.name != "__name__") + .collect(); + if qualifier.is_some() || cols.len() != fields.len() { + return Err(invalid("unsupported temporal projection shape")); + } + let mut computed = None; + for (col, (index, field)) in cols.iter().zip(fields) { + if col.alias.as_deref() != Some(field.name.as_str()) { + return Err(invalid("unsupported temporal projection alias")); + } + if index == value { + computed = Some(col); + } else { + let expected = if !keep_name + && field.name == planner_types::pre_asap::schema::PROMQL_SERIES_IDENTITY + { + ScalarExpr::FunctionCall { + name: "promql_drop_metric_name".into(), + args: vec![ScalarExpr::Column(index)], + } + } else { + ScalarExpr::Column(index) + }; + if col.expr != expected { + return Err(invalid("unsupported temporal projection expression")); + } + } + } + let computed = computed.ok_or_else(|| invalid("no computed sample"))?; + if matches!( + computed.expr, + ScalarExpr::Negative { .. } | ScalarExpr::FunctionCall { .. } + ) { + return self.pointwise_projection(cols, child, value, expression, keep_name); + } + self.sample_scalar_operation(&computed.expr, child, value, expression) + } + NonASAPOp::Filter { pred, child } => { + let value = planner_types::pre_asap::column_resolution::resolve_column_ref( + &ColumnRef::SampleValue, + &child.schema, + ) + .map_err(|e| invalid(e.to_string()))?; + self.sample_scalar_operation(&pred.0, child, value, expression) + } + NonASAPOp::Sort { + keys, + partition_by, + child, + } => { + let step = self.value(child)?; + let input = self.schema(&step); + let keys = keys + .iter() + .map(|key| match key.expr { + ScalarExpr::Column(column) => Ok(SortKey { + column, + descending: !key.ascending, + nulls_first: key.nulls_first, + }), + _ => Err(invalid("sort key must be a column")), + }) + .collect::>()?; + let groups = groups(&input, partition_by)?; + self.push(Operator::sort(input, keys, groups)?, vec![step], expression) + } + NonASAPOp::Limit { + n, offset, child, .. + } => { + let step = self.value(child)?; + let input = self.schema(&step); + // `topk by (...)` partitions through the Sort it limits. + let groups = match child.expect_non_asap() { + NonASAPOp::Sort { partition_by, .. } => groups(&input, partition_by)?, + _ => vec![], + }; + self.push( + Operator::limit( + input, + n.unwrap_or(usize::MAX) as u64, + *offset as u64, + groups, + )?, + vec![step], + expression, + ) + } + NonASAPOp::BinaryOp { + operator, + lhs, + rhs, + return_bool, + } => { + let sides = vec![self.value(lhs)?, self.value(rhs)?]; + let operator = crate::expressions::binary::BinaryOperator::from_logical( + operator, + *return_bool, + ); + let binary = Operator::series_binary( + self.schema(&sides[0]), + self.schema(&sides[1]), + operator, + [false, false], + )?; + self.push(binary, sides, expression) + } + NonASAPOp::PromqlVectorFromScalar(expr) => { + let step = self.scalar_value(expr)?; + let input = self.schema(&step); + Ok(self.add( + Operator::scope_timestamp(input, declared(expression)?)?, + vec![step], + )) + } + _ => Err(invalid("PromQL expression has no native fallback lowering")), + } + } + + fn pointwise_projection( + &mut self, + cols: &[planner_types::ir::ProjectItem], + child: &OperatorNode, + value: usize, + output: &OperatorNode, + keep_name: bool, + ) -> Result { + let mut input = self.value(child)?; + let mut projected = cols.to_vec(); + for col in &mut projected { + if col.alias.as_deref() != Some(child.schema.fields[value].name.as_str()) { + continue; + } + if let ScalarExpr::FunctionCall { name, args } = &mut col.expr { + if planner_types::pre_asap::scalar_type_rules::promql_function_arity(name).is_none() + || args.first() != Some(&ScalarExpr::Column(value)) + { + return Err(invalid("unsupported pointwise function")); + } + for arg in args.iter_mut().skip(1) { + let scalar = self.scalar_value(arg)?; + let left = self.schema(&input); + let right = self.schema(&scalar); + let index = left.fields.len(); + let mut schema = (*left).clone(); + schema.fields.extend(right.fields.clone()); + let join = Operator::unified_relational_join( + left, + right, + planner_types::pre_asap::JoinKind::Inner, + &planner_types::ir::Predicate(ScalarExpr::Literal( + planner_types::pre_asap::ScalarValue::Boolean(true), + )), + Arc::new(schema), + )?; + input = self.add(join, vec![input, scalar]); + *arg = ScalarExpr::Column(index); + } + if name == "promql_clamp" { + let predicate = ScalarExpr::Not(Box::new(ScalarExpr::Compare { + left: Box::new(args[1].clone()), + right: Box::new(args[2].clone()), + op: planner_types::pre_asap::CompareOpKind::Gt, + semantics: planner_types::ir::ExprSemantics::Promql, + })); + let schema = self.schema(&input); + let predicate = + crate::expressions::unified_planner::CompiledExpression::compile( + &predicate, &schema, + )?; + input = self.add( + Operator::filter( + schema, + crate::expressions::Expression::unified_planner(predicate), + )?, + vec![input], + ); + } + } + } + let schema = self.schema(&input); + let columns = projected + .iter() + .map(|col| { + Ok(( + col.alias.clone().unwrap(), + crate::expressions::Expression::unified_planner( + crate::expressions::unified_planner::CompiledExpression::compile( + &col.expr, &schema, + )?, + ), + )) + }) + .collect::, Error>>()?; + let project = Operator::project(schema, columns)?; + let result = self.push(project, vec![input], output)?; + if keep_name { + Ok(result) + } else { + self.push( + Operator::series_without_name(self.schema(&result))?, + vec![result], + output, + ) + } + } + + fn sample_scalar_operation( + &mut self, + expr: &ScalarExpr, + child: &OperatorNode, + value: usize, + output: &OperatorNode, + ) -> Result { + let (left, right, kind) = scalar_binary(expr)?; + let (scalar, scalar_left) = match (left, right) { + (ScalarExpr::Column(i), scalar) if *i == value => (scalar, false), + (scalar, ScalarExpr::Column(i)) if *i == value => (scalar, true), + _ => { + return Err(invalid( + "sample projection requires one vector sample and one scalar", + )) + } + }; + let vector = self.value(child)?; + let scalar = self.scalar_value(scalar)?; + let sides = if scalar_left { + vec![scalar, vector] + } else { + vec![vector, scalar] + }; + let operator = Operator::series_binary( + self.schema(&sides[0]), + self.schema(&sides[1]), + kernel(kind), + [scalar_left, !scalar_left], + )?; + self.push(operator, sides, output) + } + + fn scalar_value(&mut self, expr: &ScalarExpr) -> Result { + match expr { + ScalarExpr::Literal(planner_types::pre_asap::ScalarValue::Float64(value)) => Ok(self + .add( + Operator::scalar(crate::values::Value::Float64(*value), DataType::Float64)?, + vec![], + )), + ScalarExpr::EvalTimestamp => Ok(self.add(Operator::evaluation_time(), vec![])), + ScalarExpr::PromqlScalarFromVector(child) => { + let step = self.value(child)?; + let input = self.schema(&step); + let values: Vec<_> = input + .fields + .iter() + .enumerate() + .filter(|(_, f)| f.dtype == FieldDataType::Plain(DataType::Float64)) + .map(|(i, _)| i) + .collect(); + let [value] = values.as_slice() else { + return Err(invalid("scalar() requires one float sample column")); + }; + let value = *value; + Ok(self.add(Operator::vector_to_scalar(input, value)?, vec![step])) + } + ScalarExpr::Negative { expr, .. } => { + let value = self.scalar_value(expr)?; + let minus = self.scalar_value(&ScalarExpr::literal_f64(-1.0))?; + let op = Operator::series_binary( + self.schema(&value), + self.schema(&minus), + kernel(crate::expressions::binary::BinaryOpKind::Arithmetic( + planner_types::pre_asap::ArithmeticOpKind::Mul, + )), + [true, true], + )?; + Ok(self.add(op, vec![value, minus])) + } + _ => { + let (left, right, kind) = scalar_binary(expr)?; + let sides = vec![self.scalar_value(left)?, self.scalar_value(right)?]; + let op = Operator::series_binary( + self.schema(&sides[0]), + self.schema(&sides[1]), + kernel(kind), + [true, true], + )?; + Ok(self.add(op, sides)) + } + } + } + + /// `function(matrix)`, where the matrix is a range selector or a subquery. + fn range_function( + &mut self, + function: &AggIntent, + matrix: &OperatorNode, + logical: &OperatorNode, + ) -> Result { + let function = unbound(function)?; + let (subquery, offset, at_ms) = match matrix.expect_non_asap() { + NonASAPOp::TimeShift { shift, child } => (child.as_ref(), shift.offset_ms, at(shift)?), + _ => (matrix, 0, None), + }; + let NonASAPOp::PromqlSubquery { + range: outer, + resolution, + child, + } = subquery.expect_non_asap() + else { + let (range, offset, at) = selector(matrix)?; + let input = self.read(matrix)?; + let schema = self.schema(&input); + return self.push( + Operator::series_window(schema, Some(function), range, offset, at, None)? + .with_series_range_bounds(range_anchor(matrix), None)?, + vec![input], + logical, + ); + }; + let step = resolution.as_ref().ok_or_else(|| { + invalid("subquery resolution defaults to the deployment evaluation interval") + })?; + let steps = SubquerySteps { + range_ms: millis(outer)?, + step_ms: millis(step)?, + offset_ms: offset, + at_ms, + }; + // Each step evaluates a per-series selection or range function. + let (inner, selected) = match child.expect_non_asap() { + NonASAPOp::Aggregate { + reduction: planner_types::pre_asap::Reduction::PerEntity, + measures, + having: None, + child: selected, + .. + } => match measures.as_slice() { + [inner] => (Some(unbound(inner)?), selected.as_ref()), + _ => return Err(invalid("range function requires one measure")), + }, + _ => (None, child.as_ref()), + }; + let (range, inner_offset, inner_at) = selector(selected)?; + let raw = self.read(selected)?; + let schema = self.schema(&raw); + let mut step = self.push( + Operator::series_window( + schema, + inner.clone(), + range, + inner_offset, + inner_at, + Some(steps), + )? + .with_series_range_bounds(range_anchor(selected), range_anchor(matrix))?, + vec![raw], + child, + )?; + // Name removal must validate each subquery evaluation step. + if inner.is_some() && !matches!(inner, Some(AggIntent::LastOverTime)) { + let input = self.schema(&step); + let relabel = Operator::series_without_name(input)?; + step = self.add(relabel, vec![step]); + } + let input = self.schema(&step); + self.push( + Operator::series_window(input, Some(function), steps.range_ms, offset, at_ms, None)? + .with_series_range_bounds(range_anchor(matrix), None)?, + vec![step], + logical, + ) + } + + /// Cross-series aggregation. A global aggregate groups by one constant so + /// that no input series yields an empty vector, not one row. + fn aggregate( + &mut self, + mut step: Input, + measure: &AggIntent, + keys: &GroupKeys, + logical: &OperatorNode, + ) -> Result { + let mut input = self.schema(&step); + if let AggIntent::HistogramQuantile { q, le } = measure { + if !keys.is_without() || keys.keys() != [*le] { + return Err(invalid("histogram_quantile must group without (le)")); + } + let operator = Operator::series_histogram_quantile(input, *q, *le)?; + return self.push(operator, vec![step], logical); + } + let value = input + .fields + .iter() + .enumerate() + .filter(|(_, f)| f.dtype == FieldDataType::Plain(DataType::Float64)) + .map(|(i, _)| i) + .collect::>(); + let [value] = value.as_slice() else { + return Err(invalid("PromQL aggregation requires one Float64 value")); + }; + let value = *value; + let reduction = match measure { + AggIntent::Sum { .. } => Reduction::Sum(value), + AggIntent::Avg { .. } => Reduction::Avg(value), + AggIntent::Min { .. } => Reduction::Min(value), + AggIntent::Max { .. } => Reduction::Max(value), + AggIntent::Count { .. } => Reduction::Count, + _ => return Err(invalid("vector aggregate has no native lowering")), + }; + let mut groups = if keys.is_without() { + // Group by every remaining label, including the rewritten identity. + let excluded = keys.keys(); + if excluded.iter().any(|&i| i >= input.fields.len()) { + return Err(invalid("grouping column out of range")); + } + let names = excluded.iter().map(|&i| input.fields[i].name.clone()); + let relabel = + Operator::series_labels(input.clone(), VectorMatchKind::Ignoring, names.collect())?; + step = self.add(relabel, vec![step]); + (0..input.fields.len()) + .filter(|&i| Some(i) != input.time_index && i != value && !excluded.contains(&i)) + .collect() + } else { + groups(&input, keys)? + }; + let global = groups.is_empty(); + if global { + let mut columns = (0..input.fields.len()) + .map(|i| (input.fields[i].name.clone(), Expression::Column(i))) + .collect::>(); + columns.push(( + "$promql_global_group".into(), + Expression::Literal { + value: crate::values::Value::Utf8("".into()), + dtype: DataType::Utf8, + }, + )); + let project = Operator::project(input, columns)?; + input = project.schema(); + groups = vec![input.fields.len() - 1]; + step = self.add(project, vec![step]); + } + let output = declared(logical)?; + let name = output + .fields + .last() + .ok_or_else(|| invalid("aggregate output lacks a value"))? + .name + .clone(); + let aggregate = Operator::aggregate(input, groups, vec![(name, reduction)])?; + let actual = aggregate.schema(); + let step = self.add(aggregate, vec![step]); + // Drop the constant group; convert counts where PromQL declares Float64. + let skip = usize::from(global); + let columns = actual.fields[skip..] + .iter() + .zip(&output.fields) + .enumerate() + .map(|(i, (field, declared))| { + let column = i + skip; + let expression = if field.dtype != declared.dtype { + Expression::ExactFloat64(column) + } else { + Expression::Column(column) + }; + (field.name.clone(), expression) + }) + .collect(); + self.push(Operator::project(actual, columns)?, vec![step], logical) + } +} + +fn unbound(intent: &AggIntent) -> Result, Error> { + Ok(match intent { + AggIntent::Rate => AggIntent::Rate, + AggIntent::Deriv => AggIntent::Deriv, + AggIntent::PredictLinear { seconds } => AggIntent::PredictLinear { seconds: *seconds }, + AggIntent::Increase => AggIntent::Increase, + AggIntent::Delta => AggIntent::Delta, + AggIntent::Count { accuracy } => AggIntent::Count { + accuracy: accuracy.clone(), + }, + AggIntent::Sum { .. } => AggIntent::Sum { col: None }, + AggIntent::Avg { .. } => AggIntent::Avg { col: None }, + AggIntent::Min { .. } => AggIntent::Min { col: None }, + AggIntent::Max { .. } => AggIntent::Max { col: None }, + AggIntent::IRate => AggIntent::IRate, + AggIntent::IDelta => AggIntent::IDelta, + AggIntent::Changes => AggIntent::Changes, + AggIntent::Resets => AggIntent::Resets, + AggIntent::LastOverTime => AggIntent::LastOverTime, + AggIntent::Quantile { + col: None, + q, + accuracy, + } => AggIntent::Quantile { + col: None, + q: *q, + accuracy: accuracy.clone(), + }, + _ => return Err(invalid("unsupported PromQL range function")), + }) +} + +fn kernel( + kind: crate::expressions::binary::BinaryOpKind, +) -> crate::expressions::binary::BinaryOperator { + crate::expressions::binary::BinaryOperator { + kind, + vector_match: None, + checked_relative_division: false, + checked_finite_division: false, + } +} + +fn scalar_binary( + expr: &ScalarExpr, +) -> Result< + ( + &ScalarExpr, + &ScalarExpr, + crate::expressions::binary::BinaryOpKind, + ), + Error, +> { + use crate::expressions::binary::BinaryOpKind as K; + match expr { + ScalarExpr::Arithmetic { + left, + right, + op, + semantics: planner_types::ir::ExprSemantics::Promql, + } => Ok((left, right, K::Arithmetic(op.clone()))), + ScalarExpr::Compare { + left, + right, + op, + semantics: planner_types::ir::ExprSemantics::Promql, + } => Ok((left, right, K::Compare(op.clone()))), + ScalarExpr::Case { + operand: None, + branches, + else_expr, + } if matches!(else_expr.as_deref(), Some(ScalarExpr::Literal(planner_types::pre_asap::ScalarValue::Float64(v))) if *v == 0.0) => + { + let [( + ScalarExpr::Compare { + left, + right, + op, + semantics: planner_types::ir::ExprSemantics::Promql, + }, + ScalarExpr::Literal(planner_types::pre_asap::ScalarValue::Float64(v)), + )] = branches.as_slice() + else { + return Err(invalid("unsupported scalar case")); + }; + if *v != 1.0 { + return Err(invalid("unsupported scalar case result")); + } + Ok((left, right, K::CompareBool(op.clone()))) + } + _ => Err(invalid("scalar expression has no native temporal lowering")), + } +} diff --git a/crates/asap-physical-operators/src/unified_physical_planner/promql_rows.rs b/crates/asap-physical-operators/src/unified_physical_planner/promql_rows.rs new file mode 100644 index 000000000..46670f478 --- /dev/null +++ b/crates/asap-physical-operators/src/unified_physical_planner/promql_rows.rs @@ -0,0 +1,303 @@ +//! A bounded PromQL source row carries the entire label set, not just labels +//! mentioned by the query. The source adapter owns this lossless encoding. +use super::*; +use planner_types::ir::export::{ + compile_physical_asap_dag, compile_physical_asap_dag_with_node_ids, +}; +use planner_types::post_asap::FieldDataType as SummaryFamilyType; +use planner_types::pre_asap::DataType; +use std::rc::Rc; + +/// Not a legal PromQL label name, so it cannot shadow a user label. +pub use planner_types::pre_asap::schema::PROMQL_SERIES_IDENTITY as SERIES_IDENTITY_COLUMN; + +/// Canonical, reversible identity. JSON object encoding preserves label names, +/// empty values and escaping; sorting makes ingestion order irrelevant. +pub fn encode_series_identity(labels: &BTreeMap) -> Result { + serde_json::to_string(labels).map_err(|error| invalid(error.to_string())) +} + +pub fn decode_series_identity(encoded: &str) -> Result, Error> { + let labels: BTreeMap = + serde_json::from_str(encoded).map_err(|error| invalid(error.to_string()))?; + if encode_series_identity(&labels)? != encoded { + return Err(invalid("series identity is not canonically encoded")); + } + Ok(labels) +} + +/// Resolve the row representation before candidate search; see +/// [`planner_types::ir::schema_support::with_promql_series_identity`]. +pub fn with_series_identity(root: &Rc) -> Result, Error> { + planner_types::ir::schema_support::with_promql_series_identity(root).map_err(invalid) +} + +/// Construct source rows only from full identities. The named label columns +/// are projections of that same identity and cannot independently redefine it. +pub fn series_row( + schema: &SchemaRef, + labels: &BTreeMap, + timestamp: i64, + value: f64, +) -> Result, Error> { + use crate::values::Value; + let identity = encode_series_identity(labels)?; + let mut found = false; + let row = schema + .fields + .iter() + .enumerate() + .map(|(index, field)| { + if field.name == SERIES_IDENTITY_COLUMN { + if field.dtype != SummaryFamilyType::Plain(DataType::Utf8) + || field.nullable + || found + { + return Err(invalid("invalid series identity column")); + } + found = true; + Ok(Value::Utf8(identity.clone().into())) + } else if Some(index) == schema.time_index { + Ok(Value::Timestamp(timestamp)) + } else if field.name == "value" + && field.dtype == SummaryFamilyType::Plain(DataType::Float64) + { + Ok(Value::Float64(value)) + } else if field.dtype == SummaryFamilyType::Plain(DataType::Utf8) { + Ok(labels.get(&field.name).map_or_else( + || Value::Utf8("".into()), + |value| Value::Utf8(value.clone().into()), + )) + } else { + Err(invalid("unsupported PromQL source column")) + } + }) + .collect::, _>>()?; + if !found { + return Err(invalid("source lacks its full series identity")); + } + Ok(row) +} + +/// Compile the selected TopK computation above an existing maintained-population +/// source. The boundary supplies the complete eligible vector, not a truncated +/// TopK result; ranking remains a native physical operator. +pub fn compile_current_series_evaluation( + selected: &Rc, +) -> Result { + use planner_types::post_asap::{ + maintained_population::PopulationStatistic, Field as SummaryField, + }; + let selected = planner_types::ir::apply_lifecycle_timings( + selected, + &planner_types::ir::LifecycleAssignment::default_maintained(), + &mut planner_types::ir::TimingMemo::new(), + ) + .map_err(|e| invalid(e.to_string()))?; + let mut dag = + compile_physical_asap_dag(&selected).map_err(|error| invalid(error.to_string()))?; + // Typed snapshot candidates already carry full identity throughout the DAG. + // Cut at the population output, preserving all selected heap/evaluation nodes. + let populations = dag.nodes.iter().filter(|node| matches!(&node.payload, + Payload::MaintainPopulation { population } + if matches!(population.input, planner_types::post_asap::maintained_population::PopulationInput::CurrentSeries(_)) + )).collect::>(); + if let [population] = populations.as_slice() { + if population + .output_schema + .fields + .iter() + .any(|field| field.name == SERIES_IDENTITY_COLUMN) + { + return compile( + &dag, + BTreeMap::from([( + u64::from(population.id.0), + InputContract::bounded(Arc::new(population.output_schema.clone())), + )]), + &[u64::from(dag.root.0)], + ); + } + } + let mut frontier = None; + for node in &mut dag.nodes { + match &mut node.payload { + Payload::Relational { operator } => { + if let NonASAPOpKind::Scan { schema, .. } = operator { + schema.fields.push(SummaryField::new( + SERIES_IDENTITY_COLUMN, + SummaryFamilyType::Plain(DataType::Utf8), + false, + )); + schema.closed = true; + } + } + Payload::MaintainPopulation { .. } => { + frontier = Some(u64::from(node.id.0)); + } + Payload::EvaluatePopulation { + evaluation: PopulationStatistic::TopK { .. }, + } => {} + _ => return Err(invalid("unsupported current-series evaluation dependency")), + } + if node + .output_schema + .fields + .iter() + .any(|field| field.name == SERIES_IDENTITY_COLUMN) + { + return Err(invalid( + "current-series input already has a physical identity column", + )); + } + node.output_schema.fields.push(SummaryField { + name: SERIES_IDENTITY_COLUMN.into(), + dtype: SummaryFamilyType::Plain(DataType::Utf8), + nullable: false, + table: None, + }); + } + for edge in &mut dag.edges { + edge.intermediate_schema = dag + .nodes + .iter() + .find(|node| node.id == edge.producer) + .unwrap() + .output_schema + .clone(); + } + let frontier = frontier.ok_or_else(|| invalid("missing current-series population"))?; + let schema = Arc::new( + dag.nodes + .iter() + .find(|node| u64::from(node.id.0) == frontier) + .unwrap() + .output_schema + .clone(), + ); + compile( + &dag, + BTreeMap::from([(frontier, InputContract::bounded(schema))]), + &[u64::from(dag.root.0)], + ) +} + +/// Compile selected ranking or aggregation above an exact per-series Rate +/// evaluation. Deployments bind complete window evaluations at this boundary; +/// the heap is rebuilt independently for each evaluation. This does not move +/// that frontier to ingestion time or authorize combining finalized rates. +pub fn compile_rate_ranking( + selected: &Rc, +) -> Result<(Rc, CompiledPhysicalDAG), Error> { + use planner_types::post_asap::ExactKind; + fn frontier(node: &Rc) -> Option> { + if matches!(&node.operator, LogicalOperator::ASAP(ASAPOp::FinalizeExactAccumulator { child }) + if matches!(&child.operator, LogicalOperator::ASAP(ASAPOp::SummaryAgg { + family: FieldDataType::ExactAggregate(ExactKind::Rate, _), + reduction: planner_types::pre_asap::Reduction::PerEntity, child: raw, .. + }) if matches!(raw.non_asap(), Some(NonASAPOp::TimeRange { .. })))) + { + return Some(Rc::clone(node)); + } + node.children().into_iter().find_map(frontier) + } + let selected = planner_types::ir::apply_lifecycle_timings( + selected, + &planner_types::ir::LifecycleAssignment::default_maintained(), + &mut planner_types::ir::TimingMemo::new(), + ) + .map_err(|e| invalid(e.to_string()))?; + let source = frontier(&selected) + .ok_or_else(|| invalid("ranking requires one exact per-series Rate frontier"))?; + if !source + .schema + .fields + .iter() + .any(|field| field.name == SERIES_IDENTITY_COLUMN) + { + return Err(invalid("Rate ranking requires complete series identity")); + } + let compiled = compile_physical_asap_dag_with_node_ids(&selected) + .map_err(|error| invalid(error.to_string()))?; + let id = u64::from( + compiled + .node_ids + .node_id(&source) + .ok_or_else(|| invalid("missing Rate frontier"))? + .0, + ); + let program = compile( + &compiled.dag, + BTreeMap::from([(id, InputContract::bounded(Arc::new(source.schema.clone())))]), + &[u64::from(compiled.dag.root.0)], + )?; + Ok((source, program)) +} + +/// Compile a lifecycle-timed DAG whose heap or grouped Sum over per-series +/// Rate evaluations runs at ingestion time: fresh aggregate state per closed +/// window. The input is the complete collection of per-series counter states. +pub fn compile_fixed_window_rate_aggregation( + dag: &planner_types::ir::export::PhysicalASAPDAG, +) -> Result { + use planner_types::post_asap::{ExactKind, ExecutionTiming, SketchAlgorithm}; + let sources = dag + .nodes + .iter() + .filter(|n| { + matches!( + &n.payload, + Payload::SummaryAgg { + family: SummaryFamilyType::ExactAggregate(ExactKind::Rate, _), + reduction: planner_types::pre_asap::Reduction::PerEntity, + .. + } + ) + }) + .collect::>(); + let heaps = dag + .nodes + .iter() + .filter(|n| { + n.output_state.timing == ExecutionTiming::IngestionTime + && match &n.payload { + Payload::SummaryAgg { + family: SummaryFamilyType::Sketch(kind, _), + .. + } => matches!( + kind.algorithm(), + SketchAlgorithm::CmsWithHeap | SketchAlgorithm::CountSketchWithHeap + ), + Payload::SummaryAgg { + family: SummaryFamilyType::ExactAggregate(ExactKind::Sum, _), + .. + } => true, + _ => false, + } + }) + .collect::>(); + let ([source], [heap]) = (sources.as_slice(), heaps.as_slice()) else { + return Err(invalid( + "expected one selected fixed-window Rate aggregation", + )); + }; + if !source + .output_schema + .fields + .iter() + .any(|f| f.name == SERIES_IDENTITY_COLUMN) + { + return Err(invalid( + "fixed-window Rate aggregation requires complete series identity", + )); + } + compile_candidate( + dag, + BTreeMap::from([( + u64::from(source.id.0), + InputContract::bounded(Arc::new(source.output_schema.clone())), + )]), + &[u64::from(dag.root.0)], + &[u64::from(heap.id.0)], + ) +} diff --git a/crates/asap-physical-operators/src/unified_physical_planner/promql_values.rs b/crates/asap-physical-operators/src/unified_physical_planner/promql_values.rs new file mode 100644 index 000000000..223d7f15c --- /dev/null +++ b/crates/asap-physical-operators/src/unified_physical_planner/promql_values.rs @@ -0,0 +1,281 @@ +//! Physical scalar/vector contracts preserve complete label sets across native computation. +use super::*; +use planner_types::post_asap::FieldDataType as SummaryFamilyType; + +pub fn scalar_schema() -> SchemaRef { + crate::operators::vector_binary::value_schema(true) +} +pub fn vector_schema() -> SchemaRef { + crate::operators::vector_binary::value_schema(false) +} + +pub fn matrix_schema() -> SchemaRef { + crate::operators::vector_window::matrix_schema() +} + +pub fn compile_scalar(value: f64) -> Result { + let operator = Operator::scalar( + crate::values::Value::Float64(value), + planner_types::pre_asap::DataType::Float64, + )? + .with_output_schema(scalar_schema())?; + CompiledPhysicalDAG::from_operators( + BTreeMap::new(), + BTreeMap::from([(0, (vec![], operator))]), + vec![0], + ) +} + +pub fn compile_temporal( + intent: &AggIntent, + preserve_metric_name: bool, +) -> Result { + let operator = Operator::range_window(intent.clone())?; + let mut operators = vec![operator]; + if !preserve_metric_name { + operators.push(Operator::project( + vector_schema(), + vec![ + ( + "labels".into(), + Expression::LabelSet { + column: 0, + labels: vec![], + without: true, + }, + ), + ("value".into(), Expression::Column(1)), + ], + )?); + } + unary(operators, matrix_schema()) +} + +pub fn compile_histogram_quantile() -> Result { + CompiledPhysicalDAG::from_operators( + BTreeMap::from([ + (0, InputContract::bounded(scalar_schema())), + (1, InputContract::bounded(vector_schema())), + ]), + BTreeMap::from([(2, (vec![0, 1], Operator::histogram_quantile()))]), + vec![2], + ) +} + +/// Compile before deployment chooses readers. Input slots 0 and 1 retain operand order. +pub fn compile_binary( + operator: &crate::expressions::binary::BinaryOperator, + return_bool: bool, + left_scalar: bool, + right_scalar: bool, +) -> Result { + let left = crate::operators::vector_binary::value_schema(left_scalar); + let right = crate::operators::vector_binary::value_schema(right_scalar); + let op = Operator::vector_binary(left.clone(), right.clone(), operator.clone(), return_bool)?; + CompiledPhysicalDAG::from_operators( + BTreeMap::from([ + (0, InputContract::bounded(left)), + (1, InputContract::bounded(right)), + ]), + BTreeMap::from([(2, (vec![0, 1], op))]), + vec![2], + ) +} + +fn unary(operators: Vec, input: SchemaRef) -> Result { + let root = operators.len() as u64; + CompiledPhysicalDAG::from_operators( + BTreeMap::from([(0, InputContract::bounded(input))]), + operators + .into_iter() + .enumerate() + .map(|(i, op)| ((i + 1) as u64, (vec![i as u64], op))) + .collect(), + vec![root], + ) +} + +fn grouped(grouping: &GroupKeys) -> Result { + let labels = grouping + .keys() + .iter() + .map(|key| match key { + ColumnRef::Named(label) => Ok(label.clone()), + _ => Err(invalid("vector grouping requires label names")), + }) + .collect::, _>>()?; + Operator::project( + vector_schema(), + vec![ + ("labels".into(), Expression::Column(0)), + ("value".into(), Expression::Column(1)), + ( + "group".into(), + Expression::LabelSet { + column: 0, + labels, + without: grouping.is_without(), + }, + ), + ], + ) +} + +fn vector_output(input: SchemaRef, labels: usize, value: usize) -> Result { + let value = Expression::ExactFloat64(value); + Operator::project( + input, + vec![ + ("labels".into(), Expression::Column(labels)), + ("value".into(), value), + ], + ) +} + +pub fn compile_aggregate( + intent: &AggIntent, + grouping: &GroupKeys, +) -> Result { + let project = grouped(grouping)?; + let reduction = match intent { + AggIntent::Sum { .. } => Reduction::Sum(1), + AggIntent::Avg { .. } => Reduction::Avg(1), + AggIntent::Count { .. } => Reduction::Count, + AggIntent::Min { .. } => Reduction::Min(1), + AggIntent::Max { .. } => Reduction::Max(1), + _ => return Err(invalid("unsupported vector aggregate")), + }; + let aggregate = + Operator::aggregate(project.schema(), vec![2], vec![("value".into(), reduction)])?; + let output = vector_output(aggregate.schema(), 0, 1)?; + unary(vec![project, aggregate, output], vector_schema()) +} + +pub fn compile_sort( + descending: bool, + grouping: &GroupKeys, +) -> Result { + let project = grouped(grouping)?; + let sort = Operator::sort( + project.schema(), + vec![SortKey { + column: 1, + descending, + nulls_first: false, + }], + vec![2], + )?; + let output = vector_output(sort.schema(), 0, 1)?; + unary(vec![project, sort, output], vector_schema()) +} + +pub fn compile_limit( + n: u64, + offset: u64, + grouping: &GroupKeys, +) -> Result { + let project = grouped(grouping)?; + let limit = Operator::limit(project.schema(), n, offset, vec![2])?; + let output = vector_output(limit.schema(), 0, 1)?; + unary(vec![project, limit, output], vector_schema()) +} + +pub fn compile_negate(scalar: bool) -> Result { + let input = if scalar { + scalar_schema() + } else { + vector_schema() + }; + let mut columns = Vec::new(); + if !scalar { + columns.push(("labels".into(), Expression::Column(0))); + } + columns.push(( + if scalar { + "$promql_scalar".into() + } else { + "value".into() + }, + Expression::Negate(Box::new(Expression::Column(if scalar { 0 } else { 1 }))), + )); + unary(vec![Operator::project(input.clone(), columns)?], input) +} + +pub fn compile_vector_to_scalar() -> Result { + unary( + vec![Operator::vector_to_scalar(vector_schema(), 1)?.with_output_schema(scalar_schema())?], + vector_schema(), + ) +} + +/// A stored exact-state input retains the complete population identity. The +/// deployment supplies eligible panes; merging and finalization are computation. +pub fn exact_state_schema(family: SummaryFamilyType) -> Result { + if !matches!(family, SummaryFamilyType::ExactAggregate(..)) { + return Err(invalid("exact-state input requires an exact family")); + } + crate::values::validate_family(&family)?; + let mut schema = (*vector_schema()).clone(); + schema.fields[1].dtype = family; + Ok(Arc::new(schema)) +} + +/// Retain exact evaluation semantics before any deployment state is opened. +pub fn compile_exact_evaluation( + family: SummaryFamilyType, + lookback_ms: u64, + preserve_metric_name: bool, +) -> Result { + use planner_types::post_asap::ExactKind; + let statistic = match &family { + SummaryFamilyType::ExactAggregate(kind, _) => match kind { + ExactKind::Sum => crate::Statistic::Sum, + ExactKind::Count => crate::Statistic::Count, + ExactKind::Min => crate::Statistic::Min, + ExactKind::Max => crate::Statistic::Max, + ExactKind::Rate => crate::Statistic::Rate, + ExactKind::Increase => crate::Statistic::Increase, + ExactKind::IRate => { + return Err(invalid("instant-rate state evaluation is not supported")) + } + }, + _ => return Err(invalid("exact evaluation requires an exact family")), + }; + let input = exact_state_schema(family)?; + let merge = Operator::summary_merge(input.clone(), 1, vec![0])?; + let mut evaluation = Operator::readout( + merge.schema(), + 1, + ReadoutQuery::Exact(ExactReadout { + statistic, + lookback_ms: None, + }), + )?; + if matches!( + statistic, + crate::Statistic::Rate | crate::Statistic::Increase + ) { + evaluation = evaluation.with_counter_lookback( + i64::try_from(lookback_ms).map_err(|_| invalid("counter lookback exceeds Int64"))?, + )?; + } + let project = Operator::project( + evaluation.schema(), + vec![ + ( + "labels".into(), + if preserve_metric_name { + Expression::Column(0) + } else { + Expression::LabelSet { + column: 0, + labels: vec![], + without: true, + } + }, + ), + ("value".into(), Expression::ExactFloat64(1)), + ], + )?; + unary(vec![merge, evaluation, project], input) +} diff --git a/crates/asap-physical-operators/src/unified_physical_planner/row_values.rs b/crates/asap-physical-operators/src/unified_physical_planner/row_values.rs new file mode 100644 index 000000000..14f3d4813 --- /dev/null +++ b/crates/asap-physical-operators/src/unified_physical_planner/row_values.rs @@ -0,0 +1,60 @@ +//! Query-time PromQL value computation over logical row schemas. +use super::*; +use planner_types::post_asap::maintained_population::PopulationStatistic; +use planner_types::pre_asap::DataType; + +/// Aggregate evaluations of a maintained current-series population, as a chain. +pub(super) fn population_aggregate( + input: &SchemaRef, + grouping: &[String], + evaluation: &PopulationStatistic, +) -> Result, Error> { + let groups = grouping + .iter() + .map(|name| named_column(input, &ColumnRef::Named(name.clone()))) + .collect::, _>>()?; + let value = named_column(input, &ColumnRef::SampleValue)?; + let reduction = match evaluation { + PopulationStatistic::Sum => Reduction::Sum(value), + PopulationStatistic::Count => Reduction::Count, + PopulationStatistic::Average => Reduction::Avg(value), + PopulationStatistic::Quantile { q } => Reduction::Quantile { + column: value, + q: *q, + }, + PopulationStatistic::TopK { .. } => { + return Err(invalid( + "TopK population evaluation ranks; it does not aggregate", + )) + } + }; + if !groups.is_empty() { + return Ok(vec![Operator::aggregate( + input.clone(), + groups, + vec![("value".into(), reduction)], + )?]); + } + // A global aggregate over no members is an empty PromQL vector, not one row. + let aggregate = Operator::aggregate( + input.clone(), + vec![], + vec![ + ("value".into(), reduction), + ("members".into(), Reduction::Count), + ], + )?; + let zero = Expression::Literal { + value: crate::values::Value::Int64(0), + dtype: DataType::Int64, + }; + let filter = Operator::filter( + aggregate.schema(), + Expression::Less(Box::new(zero), Box::new(Expression::Column(1))), + )?; + let project = Operator::project( + filter.schema(), + vec![("value".into(), Expression::Column(0))], + )?; + Ok(vec![aggregate, filter, project]) +} diff --git a/crates/asap-physical-operators/src/unified_sources/memory.rs b/crates/asap-physical-operators/src/unified_sources/memory.rs new file mode 100644 index 000000000..856c73cb0 --- /dev/null +++ b/crates/asap-physical-operators/src/unified_sources/memory.rs @@ -0,0 +1,44 @@ +use super::*; +/// Immutable in-memory raw data. The connector owns the resident input; each +/// cursor clones only the next requested batch, not the entire data set. +pub struct MemorySource { + schema: SchemaRef, + batches: Vec, +} +impl MemorySource { + pub fn new(schema: SchemaRef, batches: Vec) -> Result { + crate::values::validate_schema(&schema)?; + if schema + .fields + .iter() + .any(|f| !matches!(f.dtype, SummaryFamilyType::Plain(_))) + { + return Err(Error::Invalid( + "raw source cannot contain summary states".into(), + )); + } + if batches.iter().any(|batch| batch.schema() != &schema) { + return Err(Error::Invalid("memory source batch schema mismatch".into())); + } + Ok(Self { schema, batches }) + } +} +impl RawSource for MemorySource { + fn boundedness(&self) -> crate::plan::Boundedness { + crate::plan::Boundedness::Bounded + } + fn schema(&self) -> SchemaRef { + self.schema.clone() + } + fn scan(&self, context: RunContext) -> Result, Error> { + Ok(stream::iter(self.batches.iter()) + .map(move |batch| { + if context.is_cancelled() { + return Err(Error::Cancelled); + } + let _allocation = context.reserve(batch.bytes())?; + Ok(batch.clone()) + }) + .boxed_local()) + } +} diff --git a/crates/asap-physical-operators/src/unified_sources/mod.rs b/crates/asap-physical-operators/src/unified_sources/mod.rs new file mode 100644 index 000000000..9dbe62c15 --- /dev/null +++ b/crates/asap-physical-operators/src/unified_sources/mod.rs @@ -0,0 +1,177 @@ +//! Raw data access. Connectors provide rows; Scan owns Planner predicate semantics. +use crate::{ + expressions::unified_planner::CompiledExpression, + plan::PhysicalOperator, + runtime::{Input, OutputStream, RunContext}, + values::{Batch, SchemaRef, Value}, + Error, +}; +use futures::{stream, StreamExt}; +use planner_types::ir::{NonASAPOp, OperatorNode}; +use planner_types::{ + post_asap::FieldDataType as SummaryFamilyType, + pre_asap::{DataType, Source}, +}; +use std::sync::Arc; + +/// A bound data source. Metadata must be stable for the lifetime of the binding. +/// Each scan opens an independent cursor. Connectors return raw, unfiltered rows +/// and must honor cancellation and bound their own I/O buffers. Dropping a cursor +/// must release its resources. A connector error is never an empty successful scan. +pub trait RawSource { + fn schema(&self) -> SchemaRef; + /// Declare a finite snapshot/window explicitly; execution scope alone does not bound a cursor. + fn boundedness(&self) -> crate::plan::Boundedness { + crate::plan::Boundedness::Unknown + } + fn scan(&self, context: RunContext) -> Result, Error>; +} + +/// Explicit source identities; no implicit network discovery or fallback. +#[derive(Default)] +pub struct DataSources { + sources: Vec<(Source, Arc)>, +} +impl DataSources { + pub fn register(&mut self, identity: Source, source: Arc) -> Result<(), Error> { + if self.sources.iter().any(|(key, _)| key == &identity) { + return Err(Error::Invalid("duplicate data source".into())); + } + crate::values::validate_schema(&source.schema())?; + self.sources.push((identity, source)); + Ok(()) + } + pub fn bind(&self, expression: &OperatorNode) -> Result { + let Some(NonASAPOp::Scan { + source, + predicates, + schema, + }) = expression.non_asap() + else { + return Err(Error::Invalid( + "raw Scan requires a Planner Scan leaf".into(), + )); + }; + let output = Arc::new(schema.clone()); + crate::values::validate_schema(&output)?; + let reader = self + .sources + .iter() + .find(|(key, _)| key == source) + .map(|(_, reader)| reader.clone()) + .ok_or_else(|| Error::Invalid(format!("unbound raw source: {source:?}")))?; + if reader.schema() != output { + return Err(Error::Invalid( + "raw source differs from Planner Scan schema".into(), + )); + } + let predicates = predicates + .iter() + .map(|predicate| { + let predicate = CompiledExpression::compile(&predicate.0, &output)?; + if predicate.dtype().0 != DataType::Bool { + return Err(Error::Invalid("Scan predicate must be boolean".into())); + } + Ok(predicate) + }) + .collect::, Error>>()?; + Ok(Scan { + reader, + output, + predicates, + }) + } +} + +pub struct Scan { + reader: Arc, + output: SchemaRef, + predicates: Vec, +} +impl PhysicalOperator for Scan { + fn properties(&self, _: &[crate::plan::PlanProperties]) -> crate::plan::PlanProperties { + crate::plan::PlanProperties { + boundedness: self.reader.boundedness(), + emission: crate::plan::Emission::Incremental, + } + } + + fn name(&self) -> &str { + "Scan" + } + fn input_schemas(&self) -> Vec { + vec![] + } + fn output_schema(&self) -> SchemaRef { + self.output.clone() + } + fn output_bytes(&self, batch: &Batch) -> usize { + batch.bytes() + } + fn start<'a>( + &'a self, + inputs: Vec>, + context: RunContext, + ) -> Result, Error> { + if !inputs.is_empty() { + return Err(Error::Invalid("Scan cannot have inputs".into())); + } + if context.is_cancelled() { + return Err(Error::Cancelled); + } + // Opening is lazy: validation and construction of a run perform no I/O. + let opening = context.clone(); + let stream = stream::once(async move { + if opening.is_cancelled() { + return Err(Error::Cancelled); + } + self.reader.scan(opening) + }); + use futures::TryStreamExt; + Ok(stream + .try_flatten() + .map(move |batch| { + if context.is_cancelled() { + return Err(Error::Cancelled); + } + let batch = batch?; + if batch.schema() != &self.output { + return Err(Error::Invalid( + "connector returned a different Scan schema".into(), + )); + } + if self.predicates.is_empty() { + return Ok(batch); + } + let _workspace = + context.reserve(batch.bytes().checked_mul(2).ok_or(Error::MemoryLimit)?)?; + let mut rows = Vec::new(); + for row in batch.rows() { + if context.is_cancelled() { + return Err(Error::Cancelled); + } + let mut keep = true; + for predicate in &self.predicates { + match predicate.evaluate(row)? { + Value::Bool(true) => {} + Value::Bool(false) | Value::Null => { + keep = false; + break; + } + _ => { + return Err(Error::Invalid("Scan predicate is not boolean".into())) + } + } + } + if keep { + rows.push(row.clone()); + } + } + Batch::try_new(self.output.clone(), rows) + }) + .boxed_local()) + } +} + +mod memory; +pub use memory::MemorySource; diff --git a/crates/asap-physical-operators/tests/unified_common/mod.rs b/crates/asap-physical-operators/tests/unified_common/mod.rs new file mode 100644 index 000000000..35ba496b4 --- /dev/null +++ b/crates/asap-physical-operators/tests/unified_common/mod.rs @@ -0,0 +1,15 @@ +#![allow(dead_code)] +use planner_types::ir::export::PhysicalASAPDAG; +use planner_types::ir::{apply_lifecycle_timings, LifecycleAssignment, OperatorNode, TimingMemo}; +use std::rc::Rc; + +pub fn compile_physical_asap_dag( + root: &Rc, +) -> Result> { + let root = apply_lifecycle_timings( + root, + &LifecycleAssignment::default(), + &mut TimingMemo::default(), + )?; + Ok(planner_types::ir::export::compile_physical_asap_dag(&root)?) +} diff --git a/crates/asap-physical-operators/tests/unified_promql_fallback.rs b/crates/asap-physical-operators/tests/unified_promql_fallback.rs new file mode 100644 index 000000000..016c4f3a9 --- /dev/null +++ b/crates/asap-physical-operators/tests/unified_promql_fallback.rs @@ -0,0 +1,1611 @@ +//! A retained PromQL sub-DAG (`Fallback`) compiles from its typed expression. +//! The deployment supplies only its selector's raw series; expected values are +//! hand-computed with Prometheus semantics. +#[path = "unified_common/mod.rs"] +mod common; +use asap_physical_operators::{ + operators::Operator, + runtime::{Limits, RunContext, Scope}, + unified_physical_planner::{ + compile, promql_fallback, promql_rows, CompiledPhysicalDAG, InputContract, + }, + values::{Batch, Value}, +}; +use common::compile_physical_asap_dag; +use futures::{executor::block_on, StreamExt}; +use planner_types::ir::export::PhysicalASAPDAG; +use planner_types::{ + post_asap::execution_data_state::lift_plain, types::AccuracyTarget, workload::*, +}; +use std::{collections::BTreeMap, rc::Rc}; + +/// Bare selectors look back one ingestion interval: 60s. +fn parse(query: &str) -> Rc { + parse_with(query, AccuracyTarget::Exact) +} + +fn parse_with(query: &str, accuracy: AccuracyTarget) -> Rc { + match parse_root(query, accuracy) { + planner_types::ir::QueryRoot::Operator(node) => node, + _ => panic!("expected operator query"), + } +} + +fn parse_root(query: &str, accuracy: AccuracyTarget) -> planner_types::ir::QueryRoot { + let workload = PlanningWorkload { + query_workload: QueryWorkload { + language: QueryLanguage::PromQL, + query_batch: Some(vec![BatchEntry { + query: Query(query.into()), + requirements: QueryRequirements { + accuracy: AccuracyRequirement::Explicit(accuracy), + ..Default::default() + }, + predictability: Predictability::Unknown, + invocations: 1, + execute_at: None, + time_selection: TimeSelection::default(), + }]), + repeating_queries: None, + }, + data_workload: Some(DataWorkload { + data_ingestion_interval: Evidence { + value: Some(DurationMs(60_000)), + ..Default::default() + }, + ..Default::default() + }), + }; + asap_frontend_promql::unified::lower_promql_query_workload(&workload, 0) + .unwrap() + .remove(0) +} + +fn lower(query: &str) -> Rc { + promql_rows::with_series_identity(&parse(query)).unwrap() +} + +/// The whole query retained as one pre-ASAP node. +fn fallback_dag(expression: Rc) -> PhysicalASAPDAG { + compile_physical_asap_dag(&expression).unwrap() +} + +/// `(labels, seconds, value)`. `labels` is `k=v,...`, or a bare `job` value. +type Sample = (&'static str, i64, f64); + +fn labels(spec: &str) -> BTreeMap { + if !spec.contains('=') { + return BTreeMap::from([("job".into(), spec.into())]); + } + spec.split(',') + .map(|pair| { + let (k, v) = pair.split_once('=').unwrap(); + (k.to_string(), v.to_string()) + }) + .collect() +} + +/// The metric a selector reads. +fn metric(selector: &planner_types::ir::OperatorNode) -> String { + match selector.expect_non_asap() { + planner_types::ir::NonASAPOp::Scan { + source: planner_types::pre_asap::Source::TimeSeries { metric }, + .. + } => metric.clone(), + planner_types::ir::NonASAPOp::TimeRange { child, .. } + | planner_types::ir::NonASAPOp::TimeShift { child, .. } => metric(child), + other => panic!("not a selector: {other:?}"), + } +} + +fn compile_query(query: &str) -> Result { + let expression = lower(query); + compile_dag(&expression, &fallback_dag(expression.clone())) +} + +/// Compile a DAG whose root is the Fallback computing `expression`. +fn compile_dag( + expression: &planner_types::ir::OperatorNode, + dag: &PhysicalASAPDAG, +) -> Result { + let root = u64::from(dag.root.0); + let inputs = promql_fallback::raw_series(expression) + .map_err(|e| e.to_string())? + .into_iter() + .enumerate() + .map(|(i, (_, schema))| { + ( + promql_fallback::raw_series_input(root, i), + InputContract::bounded(schema), + ) + }) + .collect(); + let program = compile(dag, inputs, &[root]).map_err(|e| e.to_string())?; + Ok(serde_json::from_slice(&serde_json::to_vec(&program).unwrap()).unwrap()) +} + +/// Evaluate at `at` seconds over samples of each named metric; returns +/// `(output labels, timestamp ms, value)` rows in order. +#[allow(clippy::type_complexity)] +fn evaluate( + query: &str, + metrics: &[(&str, &[Sample])], + at: i64, +) -> Result, i64, f64)>, String> { + match parse_root(query, AccuracyTarget::Exact) { + planner_types::ir::QueryRoot::Operator(expression) => { + let expression = + promql_rows::with_series_identity(&expression).map_err(|e| e.to_string())?; + evaluate_dag(&expression, &fallback_dag(expression.clone()), metrics, at) + } + planner_types::ir::QueryRoot::Scalar(expr) => { + let expr = expr + .map_operator_refs(&mut |node| promql_rows::with_series_identity(node).unwrap()); + let (program, selectors) = + promql_fallback::compile_scalar_root(&expr).map_err(|e| e.to_string())?; + execute_program(program, selectors, metrics, at, None) + } + } +} + +#[allow(clippy::type_complexity)] +fn evaluate_dag( + expression: &planner_types::ir::OperatorNode, + dag: &PhysicalASAPDAG, + metrics: &[(&str, &[Sample])], + at: i64, +) -> Result, i64, f64)>, String> { + evaluate_dag_with_range(expression, dag, metrics, at, None) +} + +#[allow(clippy::type_complexity)] +fn evaluate_dag_with_range( + expression: &planner_types::ir::OperatorNode, + dag: &PhysicalASAPDAG, + metrics: &[(&str, &[Sample])], + at: i64, + bounds: Option<(i64, i64)>, +) -> Result, i64, f64)>, String> { + let program = compile_dag(expression, dag)?; + let selectors = promql_fallback::raw_series(expression).unwrap(); + execute_program(program, selectors, metrics, at, bounds) +} + +#[allow(clippy::type_complexity)] +fn execute_program( + program: CompiledPhysicalDAG, + selectors: Vec, + metrics: &[(&str, &[Sample])], + at: i64, + bounds: Option<(i64, i64)>, +) -> Result, i64, f64)>, String> { + let mut sources = BTreeMap::new(); + for (i, (selector, schema)) in selectors.into_iter().enumerate() { + let name = metric(&selector); + let rows = metrics + .iter() + .filter(|(m, _)| *m == name) + .flat_map(|(_, samples)| samples.iter()) + .map(|(spec, seconds, value)| { + let mut labels = labels(spec); + // A sample may supply its own `__name__`, as a series of another metric. + labels.entry("__name__".into()).or_insert(name.clone()); + promql_rows::series_row(&schema, &labels, seconds * 1000, *value).unwrap() + }) + .collect(); + let batch = Batch::try_new(schema.clone(), rows).unwrap(); + sources.insert( + promql_fallback::raw_series_input(program.roots()[0], i), + Box::new(Operator::source(schema, vec![batch]).unwrap()) as _, + ); + } + let dag = program.instantiate(sources).map_err(|e| e.to_string())?; + let context = RunContext::new( + Scope::Query { + evaluation_time_ms: at * 1000, + revision: 0, + }, + Limits::default(), + ) + .unwrap(); + let context = match bounds { + Some((start, end)) => context + .with_query_range(start * 1000, end * 1000) + .map_err(|e| e.to_string())?, + None => context, + }; + block_on(async { + let mut stream = dag + .execute(program.roots(), context) + .map_err(|e| e.to_string())? + .remove(0); + let mut rows = Vec::new(); + while let Some(batch) = stream.next().await { + let batch = batch.map_err(|e| e.to_string())?; + let schema = batch.schema().clone(); + for row in batch.rows() { + let mut labels = BTreeMap::new(); + let mut time = -1; + let mut value = None; + for (field, cell) in schema.fields.iter().zip(row) { + match (field.name.as_str(), cell) { + (promql_rows::SERIES_IDENTITY_COLUMN, Value::Utf8(id)) => { + labels = promql_rows::decode_series_identity(id).unwrap() + } + (_, Value::Utf8(_) | Value::Null) => {} + (_, Value::Timestamp(t)) => time = *t, + (_, Value::Float64(v)) => value = Some(*v), + (_, Value::Int64(v)) => value = Some(*v as f64), + other => return Err(format!("unexpected cell {other:?}")), + } + } + if !schema + .fields + .iter() + .any(|f| f.name == promql_rows::SERIES_IDENTITY_COLUMN) + { + for (field, cell) in schema.fields.iter().zip(row) { + if let Value::Utf8(label) = cell { + if !label.is_empty() { + labels.insert(field.name.clone(), label.to_string()); + } + } + } + } + rows.push((labels, time, value.ok_or("missing value")?)); + } + } + Ok(rows) + }) +} + +/// Evaluate at `at` seconds over metric `m`; returns `(job or "", timestamp ms, value)`. +fn run(query: &str, samples: &[Sample], at: i64) -> Result, String> { + Ok(evaluate(query, &[("m", samples)], at)? + .into_iter() + .map(|(labels, time, value)| (labels.get("job").cloned().unwrap_or_default(), time, value)) + .collect()) +} + +/// Output rows as `(k=v,... sorted, value)`, including any `__name__`. +fn labeled(query: &str, metrics: &[(&str, &[Sample])], at: i64) -> Vec<(String, f64)> { + let mut rows = evaluate(query, metrics, at) + .unwrap_or_else(|e| panic!("{query}: {e}")) + .into_iter() + .map(|(labels, _, value)| { + let spec = labels + .iter() + .map(|(k, v)| format!("{k}={v}")) + .collect::>() + .join(","); + (spec, value) + }) + .collect::>(); + rows.sort_by(|a, b| a.0.cmp(&b.0)); + rows +} + +fn values(query: &str, samples: &[Sample], at: i64) -> Vec<(String, f64)> { + run(query, samples, at) + .unwrap_or_else(|e| panic!("{query}: {e}")) + .into_iter() + .map(|(job, _, value)| (job, value)) + .collect() +} + +fn one(query: &str, samples: &[Sample], at: i64) -> f64 { + match values(query, samples, at).as_slice() { + [(_, value)] => *value, + other => panic!("{query}: expected one sample, got {other:?}"), + } +} + +const COUNTER: &[Sample] = &[ + ("a", 60, 10.), + ("a", 120, 20.), + ("a", 180, 5.), + ("a", 240, 15.), +]; + +// rate/increase correct the reset at 180s and extrapolate half an interval at +// most; delta treats the same samples as a gauge. +#[test] +fn range_functions_follow_prometheus_extrapolation_and_resets() { + // Reset-corrected increase is 25 over 180s of samples; 60s on each side extrapolates. + let increase = 25. * (180. + 60. + 60.) / 180.; + assert!((one("increase(m[5m])", COUNTER, 300) - increase).abs() < 1e-9); + assert!((one("rate(m[5m])", COUNTER, 300) - increase / 300.).abs() < 1e-12); + let delta = 5. * (180. + 60. + 60.) / 180.; + assert!((one("delta(m[5m])", COUNTER, 300) - delta).abs() < 1e-9); + // Fewer than two samples yield no rate. + assert!(values("rate(m[2m])", COUNTER, 300).is_empty()); + for (query, expected) in [ + ("sum_over_time(m[5m])", 50.), + ("avg_over_time(m[5m])", 12.5), + ("min_over_time(m[5m])", 5.), + ("max_over_time(m[5m])", 20.), + ("count_over_time(m[5m])", 4.), + ] { + assert_eq!(one(query, COUNTER, 300), expected, "{query}"); + } +} + +// Ranges are left-open: a sample at `t - range` is excluded, one at `t` is included. +#[test] +fn ranges_exclude_their_start_and_offsets_shift_them() { + let samples = &[("a", 60, 1.), ("a", 90, 1.), ("a", 120, 1.), ("a", 150, 1.)]; + assert_eq!(one("count_over_time(m[1m])", samples, 120), 2.); + // offset 1m reads (60s, 120s] at 180s; output keeps the evaluation time. + let rows = run("count_over_time(m[1m] offset 1m)", samples, 180).unwrap(); + assert_eq!(rows, vec![("a".into(), 180_000, 2.)]); +} + +// A bare selector takes the latest sample within the lookback; a stale marker +// hides the series rather than exposing an older value. +#[test] +fn instant_selection_uses_lookback_and_stale_markers() { + let stale = f64::from_bits(0x7ff0_0000_0000_0002); + let samples = &[("a", 0, 1.), ("a", 30, 2.), ("b", 30, 3.), ("b", 50, stale)]; + assert_eq!(values("m", samples, 60), vec![("a".into(), 2.)]); + // The lookback (30s, 90s] excludes the sample at 30s. + assert!(values("m", samples, 90).is_empty()); + // Range functions skip stale markers. + assert_eq!( + values("sum_over_time(m[1m])", samples, 60), + vec![("a".into(), 2.), ("b".into(), 3.)] + ); +} + +// NaN samples follow Prometheus: min/max skip them, sums propagate them. +#[test] +fn nan_samples() { + let samples = &[("a", 10, f64::NAN), ("a", 20, 3.), ("a", 30, 1.)]; + assert_eq!(one("max_over_time(m[1m])", samples, 60), 3.); + assert_eq!(one("min_over_time(m[1m])", samples, 60), 1.); + assert!(one("sum_over_time(m[1m])", samples, 60).is_nan()); +} + +// Aggregation over no series is an empty vector, not one zero or null row; +// sort_desc orders the selected series. +#[test] +fn cross_series_aggregates_and_empty_inputs() { + let samples = &[("a", 50, 1.), ("b", 40, 2.), ("b", 55, 4.)]; + assert_eq!(values("sum(m)", samples, 60), vec![(String::new(), 5.)]); + assert_eq!(values("count(m)", samples, 60), vec![(String::new(), 2.)]); + assert_eq!( + values("max by (job) (m)", samples, 60), + vec![("a".into(), 1.), ("b".into(), 4.)] + ); + assert_eq!( + values("sort_desc(m)", samples, 60), + vec![("b".into(), 4.), ("a".into(), 1.)] + ); + // topk by (job) keeps the top series of each job, not one overall. + let jobs = &[("a", 50, 1.), ("b", 50, 2.)]; + let mut top = values("topk by (job) (1, m)", jobs, 60); + top.sort_by(|x, y| x.0.cmp(&y.0)); + assert_eq!(top, vec![("a".into(), 1.), ("b".into(), 2.)]); + assert_eq!(values("topk(1, m)", jobs, 60), vec![("b".into(), 2.)]); + for query in ["sum(m)", "count(m)", "max(m)", "sum by (job) (rate(m[5m]))"] { + assert!(values(query, &[], 60).is_empty(), "{query}"); + } +} + +// scalar() is the single series' value and NaN otherwise; vector() needs no input. +#[test] +fn scalar_and_vector_bridges() { + assert_eq!(one("scalar(m)", &[("a", 50, 7.)], 60), 7.); + assert!(one("scalar(m)", &[("a", 50, 7.), ("b", 50, 8.)], 60).is_nan()); + assert!(one("scalar(m)", &[], 60).is_nan()); + assert_eq!( + run("vector(3)", &[], 60).unwrap(), + vec![(String::new(), 60_000, 3.)] + ); + assert_eq!( + values("2 - m", &[("a", 50, 7.)], 60), + vec![("a".into(), -5.)] + ); + assert_eq!( + values("m * 2", &[("a", 50, 7.)], 60), + vec![("a".into(), 14.)] + ); +} + +// Subquery steps are absolute multiples of the resolution in the left-open +// range; each step evaluates the operand, and the outer function reduces them. +#[test] +fn subqueries_evaluate_their_operand_on_the_aligned_grid() { + // Steps 60..300: selections 1, 7, 3, (none at 240s), 4. + let samples = &[ + ("a", 50, 1.), + ("a", 110, 7.), + ("a", 170, 3.), + ("a", 290, 4.), + ]; + assert_eq!(one("max_over_time(m[5m:1m])", samples, 300), 7.); + assert_eq!(one("count_over_time(m[5m:1m])", samples, 300), 4.); + // At 190s the steps are 60, 120, 180 (not 70, 130, 190): counts 1 + 2 + 2. + let samples = &[("a", 30, 1.), ("a", 90, 1.), ("a", 150, 1.), ("a", 185, 1.)]; + assert_eq!( + one("sum_over_time(count_over_time(m[2m])[3m:1m])", samples, 190), + 5. + ); + // offset 1m moves the grid to (-50s, 130s]: steps 0, 60, 120 count 0 + 1 + 2. + assert_eq!( + one( + "sum_over_time(count_over_time(m[2m])[3m:1m] offset 1m)", + samples, + 190 + ), + 3. + ); +} + +// Subquery work is bounded by the query: at most 100000 steps. +#[test] +fn dense_subquery_grids_are_rejected() { + assert!(compile_query("max_over_time(m[100s:1ms])").is_ok()); + assert!(compile_query("max_over_time(m[30d:1ms])").is_err()); +} + +// The deployment must supply the selector's raw rows under the documented slot +// with the exact selector schema; unsupported shapes stay rejected. +#[test] +fn raw_series_contract_is_explicit() { + let expression = lower("rate(m[5m])"); + let dag = fallback_dag(expression.clone()); + let root = u64::from(dag.root.0); + let [(selector, schema)] = promql_fallback::raw_series(&expression) + .unwrap() + .try_into() + .unwrap(); + assert!(matches!( + selector.expect_non_asap(), + planner_types::ir::NonASAPOp::TimeRange { .. } + )); + let missing = compile(&dag, BTreeMap::new(), &[root]).err().unwrap(); + assert!(missing.to_string().contains("raw series input")); + let mut wrong = (*schema).clone(); + wrong.fields.pop(); + let wrong = compile( + &dag, + BTreeMap::from([( + promql_fallback::raw_series_input(root, 0), + InputContract::bounded(std::sync::Arc::new(wrong)), + )]), + &[root], + ); + assert!(wrong.is_err()); + // A consumed bare selector is raw range rows for its consumer; it is not + // turned into instant selection. + let selector = lower("m"); + let _schema = lift_plain(&selector.schema.clone()); + let consumed = planner_types::ir::OperatorNode::new_shared( + planner_types::ir::Operator::NonASAP(planner_types::ir::NonASAPOp::Limit { + n: Some(1), + offset: 0, + partition_by: Default::default(), + child: selector.clone(), + }), + ) + .unwrap(); + let consumed = fallback_dag(consumed.clone()); + let raw = promql_fallback::raw_series(&selector).unwrap().remove(0).1; + assert!(compile( + &consumed, + BTreeMap::from([( + promql_fallback::raw_series_input(u64::from(consumed.root.0), 0), + InputContract::bounded(raw) + )]), + &[u64::from(consumed.root.0)] + ) + .is_ok()); + // Implicit subquery resolution belongs to the deployment's evaluation interval. + assert!(compile_query("max_over_time(m[5m:])").is_err()); +} + +// irate/idelta use the last two samples (irate corrects a reset to the last +// value); changes/resets count value changes and decreases; quantile_over_time +// interpolates; all skip stale markers. +#[test] +fn instant_and_counting_range_functions() { + // COUNTER in (0s, 300s]: 10, 20, 5, 15. + assert!((one("irate(m[5m])", COUNTER, 300) - 10. / 60.).abs() < 1e-12); + assert_eq!(one("idelta(m[5m])", COUNTER, 300), 10.); + // At 200s the last pair 20 -> 5 is a reset: irate uses 5 as the increase. + assert!((one("irate(m[5m])", COUNTER, 200) - 5. / 60.).abs() < 1e-12); + assert_eq!(one("idelta(m[5m])", COUNTER, 200), -15.); + assert!(values("irate(m[1m])", COUNTER, 300).is_empty()); + assert_eq!(one("changes(m[5m])", COUNTER, 300), 3.); + assert_eq!(one("resets(m[5m])", COUNTER, 300), 1.); + assert_eq!(one("changes(m[2m])", COUNTER, 300), 0.); + // NaN to NaN is not a change; any other transition involving NaN is. + let flat = &[ + ("a", 10, 1.), + ("a", 20, 1.), + ("a", 30, 2.), + ("a", 40, f64::NAN), + ("a", 50, f64::NAN), + ("a", 55, 1.), + ]; + assert_eq!(one("changes(m[1m])", flat, 60), 3.); + let stale = f64::from_bits(0x7ff0_0000_0000_0002); + let ended = &[("a", 240, 15.), ("a", 250, stale)]; + assert_eq!(one("last_over_time(m[5m])", ended, 300), 15.); + assert!(values("m", ended, 300).is_empty()); + // Sorted 5, 10, 15, 20: rank 1.5 and 0.75; outside [0, 1] is +-Inf. + assert_eq!(one("quantile_over_time(0.5, m[5m])", COUNTER, 300), 12.5); + assert_eq!(one("quantile_over_time(0.25, m[5m])", COUNTER, 300), 8.75); + assert_eq!( + one("quantile_over_time(2, m[5m])", COUNTER, 300), + f64::INFINITY + ); + assert_eq!( + one("quantile_over_time(-1, m[5m])", COUNTER, 300), + f64::NEG_INFINITY + ); +} + +// `@ ` evaluates the selector or subquery at `t`, minus any offset, and the +// result keeps the query's evaluation time. +#[test] +fn at_modifier_fixes_the_evaluation_instant() { + let samples = &[("a", 60, 1.), ("a", 120, 2.), ("a", 180, 3.)]; + assert_eq!( + run("m @ 120", samples, 1000).unwrap(), + vec![("a".into(), 1_000_000, 2.)] + ); + assert!(values("m", samples, 1000).is_empty()); + assert_eq!(one("count_over_time(m[2m] @ 180)", samples, 1000), 2.); + assert_eq!(one("m @ 180 offset 1m", samples, 1000), 2.); + // The subquery grid is (60s, 180s]: steps 120 and 180 select 2 and 3. + assert_eq!(one("max_over_time(m[2m:1m] @ 180)", samples, 1000), 3.); + assert_eq!( + one("sum_over_time(m[2m:1m] @ 180 offset 1m)", samples, 1000), + 3. + ); + // An inner @ pins every step to the same instant. + assert_eq!(one("sum_over_time((m @ 60)[2m:1m])", samples, 180), 2.); + // start() and end() depend on the range query, which is the deployment's. + assert!(evaluate("m @ start()", &[], 60) + .unwrap_err() + .contains("query range bounds")); +} + +const A: &[Sample] = &[("job=x", 50, 10.), ("job=y", 50, 20.), ("job=w", 50, 0.)]; +const B: &[Sample] = &[("job=x", 50, 2.), ("job=z", 50, 5.), ("job=w", 50, 0.)]; + +// Vector-vector arithmetic matches series one-to-one on label sets without +// the metric name, and the result drops the metric name. +#[test] +fn vector_arithmetic_matches_label_sets() { + let metrics = &[("a", A), ("b", B)]; + let quotient = labeled("a / b", metrics, 60); + assert_eq!(quotient.len(), 2); + assert_eq!(quotient[0].0, "job=w"); + assert!(quotient[0].1.is_nan(), "0 / 0 is NaN"); + assert_eq!(quotient[1], ("job=x".into(), 5.)); + // Each selector reads its own raw rows, even a repeated metric. + assert_eq!( + labeled("(a - b) * a", metrics, 60), + vec![("job=w".into(), 0.), ("job=x".into(), 80.)] + ); + assert_eq!( + labeled("sum by (job) (a) - sum by (job) (b)", metrics, 60), + vec![("job=w".into(), 0.), ("job=x".into(), 8.)] + ); + // Rates of two counters over their own windows. + let up: &[Sample] = &[("job=x", 0, 0.), ("job=x", 60, 60.)]; + let down: &[Sample] = &[("job=x", 0, 0.), ("job=x", 60, 30.)]; + assert_eq!( + labeled("rate(a[2m]) / rate(b[2m])", &[("a", up), ("b", down)], 60), + vec![("job=x".into(), 2.)] + ); +} + +// on() keeps only the listed labels and ignoring() drops them; a duplicate +// match group is an error unless the left duplicates never match. +#[test] +fn on_and_ignoring_select_the_matching_labels() { + let a: &[Sample] = &[("job=x,inst=1", 50, 10.)]; + let b: &[Sample] = &[("job=x,inst=2", 50, 4.)]; + let metrics = &[("a", a), ("b", b)]; + assert!(labeled("a - b", metrics, 60).is_empty()); + assert_eq!( + labeled("a - on(job) b", metrics, 60), + vec![("job=x".into(), 6.)] + ); + assert_eq!( + labeled("a - ignoring(inst) b", metrics, 60), + vec![("job=x".into(), 6.)] + ); + let pair: &[Sample] = &[("job=x,inst=1", 50, 1.), ("job=x,inst=2", 50, 2.)]; + let other: &[Sample] = &[("job=y", 50, 1.)]; + assert!(evaluate("a + on(job) b", &[("a", a), ("b", pair)], 60).is_err()); + assert!(evaluate("a + on(job) b", &[("a", pair), ("b", b)], 60).is_err()); + assert!(labeled("a + on(job) b", &[("a", pair), ("b", other)], 60).is_empty()); +} + +// without() groups by every label except the listed ones and the metric name. +#[test] +fn without_grouping_drops_labels_and_the_name() { + let a: &[Sample] = &[ + ("job=x,inst=1", 50, 1.), + ("job=x,inst=2", 50, 2.), + ("job=y,inst=1", 50, 4.), + ]; + let metrics = &[("a", a)]; + assert_eq!( + labeled("sum without (inst) (a)", metrics, 60), + vec![("job=x".into(), 3.), ("job=y".into(), 4.)] + ); + assert_eq!( + labeled("count without (inst) (a)", metrics, 60), + vec![("job=x".into(), 2.), ("job=y".into(), 1.)] + ); + assert_eq!( + labeled("max without (job, inst) (a)", metrics, 60), + vec![(String::new(), 4.)] + ); + assert!(labeled("sum without (inst) (a)", &[], 60).is_empty()); + // Series equal without the name share a group rather than colliding. + let named: &[Sample] = &[ + ("job=x,inst=1", 50, 1.), + ("__name__=b,job=x,inst=1", 50, 2.), + ]; + assert_eq!( + labeled("sum without (inst) (a)", &[("a", named)], 60), + vec![("job=x".into(), 3.)] + ); +} + +// Arithmetic with a literal drops the metric name; series that then share a +// label set are an error, as in Prometheus. +#[test] +fn literal_arithmetic_drops_the_name_and_rejects_equal_label_sets() { + let a: &[Sample] = &[ + ("job=x,inst=1", 50, 1.), + ("__name__=b,job=x,inst=2", 50, 2.), + ]; + assert_eq!( + labeled("a * 2", &[("a", a)], 60), + vec![("inst=1,job=x".into(), 2.), ("inst=2,job=x".into(), 4.)] + ); + let equal: &[Sample] = &[("job=x", 50, 1.), ("__name__=b,job=x", 50, 2.)]; + let error = evaluate("a * 2", &[("a", equal)], 60).unwrap_err(); + assert!(error.contains("same labelset"), "{error}"); +} + +// An empty label value is an absent label, and an empty side yields an empty +// result before any duplicate check, as in Prometheus. +#[test] +fn empty_labels_and_empty_sides_match_prometheus() { + let a: &[Sample] = &[("job=x,env=", 50, 3.)]; + let b: &[Sample] = &[("job=x", 50, 1.)]; + assert_eq!( + labeled("a + b", &[("a", a), ("b", b)], 60), + vec![("job=x".into(), 4.)] + ); + let pair: &[Sample] = &[("job=x,inst=1", 50, 1.), ("job=x,inst=2", 50, 2.)]; + assert!(labeled("a + on(job) b", &[("b", pair)], 60).is_empty()); + assert!(labeled("b + on(job) a", &[("b", pair)], 60).is_empty()); + assert!(labeled("a - time()", &[], 60).is_empty()); +} + +// Sums and averages use Prometheus' Kahan-Neumaier compensation, and an +// average whose running sum overflows switches to an incremental mean. +#[test] +fn sums_and_averages_are_compensated_like_prometheus() { + let cancel = &[("a", 10, 1e100), ("a", 20, 1.), ("a", 30, -1e100)]; + assert_eq!(one("sum_over_time(m[1m])", cancel, 60), 1.); + assert_eq!(one("avg_over_time(m[1m])", cancel, 60), 1. / 3.); + let huge = &[("a", 10, 1.7e308), ("a", 20, 1.7e308)]; + assert_eq!(one("avg_over_time(m[1m])", huge, 60), 1.7e308); + assert_eq!(one("sum_over_time(m[1m])", huge, 60), f64::INFINITY); + let infinite = &[("a", 10, f64::INFINITY), ("a", 20, 1.)]; + assert_eq!(one("sum_over_time(m[1m])", infinite, 60), f64::INFINITY); + assert_eq!(one("avg_over_time(m[1m])", infinite, 60), f64::INFINITY); + let opposite = &[("a", 10, f64::INFINITY), ("a", 20, f64::NEG_INFINITY)]; + assert!(one("sum_over_time(m[1m])", opposite, 60).is_nan()); + assert!(one("avg_over_time(m[1m])", opposite, 60).is_nan()); + let cancel = &[("a", 50, 1e100), ("b", 50, 1.), ("c", 50, -1e100)]; + assert_eq!(one("sum(m)", cancel, 60), 1.); + assert_eq!(one("avg(m)", cancel, 60), 1. / 3.); + let huge = &[("a", 50, 1.7e308), ("b", 50, 1.7e308)]; + assert_eq!(one("avg(m)", huge, 60), 1.7e308); +} + +/// `(k=v,... sorted, value)` rows for readable expectations. +fn rows(pairs: &[(&str, f64)]) -> Vec<(String, f64)> { + let mut rows = pairs + .iter() + .map(|(spec, value)| (spec.to_string(), *value)) + .collect::>(); + rows.sort_by(|a, b| a.0.cmp(&b.0)); + rows +} + +/// `labeled`, with NaN values rendered comparable. +fn labeled_nan(query: &str, metrics: &[(&str, &[Sample])], at: i64) -> Vec<(String, String)> { + labeled(query, metrics, at) + .into_iter() + .map(|(labels, value)| (labels, format!("{value:?}"))) + .collect() +} + +const C: &[Sample] = &[ + ("job=x", 50, 10.), + ("job=y", 50, 20.), + ("job=w", 50, 0.), + ("job=n", 50, f64::NAN), +]; + +// A comparison with a scalar keeps the matching series with their value and +// metric name, whichever side the scalar is on; `bool` yields 1 or 0 for every +// series and drops the name. NaN compares unequal to everything. +#[test] +fn scalar_comparisons_filter_or_return_bool() { + let metrics = &[("a", C)]; + let kept = rows(&[("__name__=a,job=x", 10.), ("__name__=a,job=y", 20.)]); + assert_eq!(labeled("a > 5", metrics, 60), kept); + assert_eq!(labeled("5 < a", metrics, 60), kept); + assert_eq!( + labeled("a <= 10", metrics, 60), + rows(&[("__name__=a,job=w", 0.), ("__name__=a,job=x", 10.)]) + ); + assert_eq!( + labeled("a > bool 5", metrics, 60), + rows(&[("job=n", 0.), ("job=w", 0.), ("job=x", 1.), ("job=y", 1.)]) + ); + assert_eq!( + labeled("10 == bool a", metrics, 60), + rows(&[("job=n", 0.), ("job=w", 0.), ("job=x", 1.), ("job=y", 0.)]) + ); + // scalar() of no series is NaN. + assert_eq!(labeled("a != scalar(b)", metrics, 60).len(), 4); + assert!(labeled("a == scalar(b)", metrics, 60).is_empty()); + assert!(labeled("a > 5", &[], 60).is_empty()); + // Only `bool` drops the name, so only it can make label sets collide. + let equal: &[Sample] = &[("job=x", 50, 1.), ("__name__=b,job=x", 50, 2.)]; + assert_eq!(labeled("a > 0", &[("a", equal)], 60).len(), 2); + let error = evaluate("a > bool 0", &[("a", equal)], 60).unwrap_err(); + assert!(error.contains("same labelset"), "{error}"); +} + +// Vector comparisons match one-to-one like arithmetic. A filter keeps the +// left series, name included, unless `on` reduces its labels; `bool` drops the +// name. A left duplicate is an error only if more than one of it is kept. +#[test] +fn vector_comparisons_match_one_to_one() { + let metrics = &[("a", A), ("b", B)]; + assert_eq!( + labeled("a > b", metrics, 60), + rows(&[("__name__=a,job=x", 10.)]) + ); + assert_eq!( + labeled("a >= b", metrics, 60), + rows(&[("__name__=a,job=w", 0.), ("__name__=a,job=x", 10.)]) + ); + assert_eq!( + labeled("a > bool b", metrics, 60), + rows(&[("job=w", 0.), ("job=x", 1.)]) + ); + assert!(labeled("a < b", metrics, 60).is_empty()); + let a: &[Sample] = &[("job=x,inst=1", 50, 10.)]; + let b: &[Sample] = &[("job=x,inst=2", 50, 4.)]; + let metrics = &[("a", a), ("b", b)]; + assert_eq!( + labeled("a > on(job) b", metrics, 60), + rows(&[("job=x", 10.)]) + ); + assert_eq!( + labeled("a > ignoring(inst) b", metrics, 60), + rows(&[("__name__=a,job=x", 10.)]) + ); + let pair: &[Sample] = &[("job=x,inst=1", 50, 1.), ("job=x,inst=2", 50, 5.)]; + let metrics = &[("a", pair), ("b", b)]; + assert_eq!( + labeled("a > on(job) b", metrics, 60), + rows(&[("job=x", 5.)]) + ); + let error = evaluate("a > bool on(job) b", metrics, 60).unwrap_err(); + assert!(error.contains("many-to-one"), "{error}"); + let nan: &[Sample] = &[("job=x", 50, f64::NAN)]; + let metrics = &[("a", nan), ("b", nan)]; + assert_eq!(labeled("a == bool b", metrics, 60), rows(&[("job=x", 0.)])); + assert_eq!( + labeled_nan("a != b", metrics, 60), + vec![("__name__=a,job=x".into(), "NaN".into())] + ); +} + +const S: &[Sample] = &[ + ("job=x", 50, 1.), + ("job=y", 50, 2.), + ("job=z,inst=1", 50, 3.), +]; +const T: &[Sample] = &[ + ("job=x", 50, 10.), + ("job=w", 50, 20.), + ("job=z,inst=2", 50, 30.), +]; + +// Set operators match label sets many-to-many, ignoring the name by default, +// and return the original series unchanged. +#[test] +fn set_operators_match_label_sets() { + let metrics = &[("a", S), ("b", T)]; + assert_eq!( + labeled("a and b", metrics, 60), + rows(&[("__name__=a,job=x", 1.)]) + ); + assert_eq!( + labeled("a and on(job) b", metrics, 60), + rows(&[("__name__=a,job=x", 1.), ("__name__=a,inst=1,job=z", 3.)]) + ); + assert_eq!( + labeled("a and ignoring(inst) b", metrics, 60), + labeled("a and on(job) b", metrics, 60) + ); + assert_eq!( + labeled("a or b", metrics, 60), + rows(&[ + ("__name__=a,job=x", 1.), + ("__name__=a,job=y", 2.), + ("__name__=a,inst=1,job=z", 3.), + ("__name__=b,job=w", 20.), + ("__name__=b,inst=2,job=z", 30.), + ]) + ); + assert_eq!( + labeled("a or on(job) b", metrics, 60), + rows(&[ + ("__name__=a,job=x", 1.), + ("__name__=a,job=y", 2.), + ("__name__=a,inst=1,job=z", 3.), + ("__name__=b,job=w", 20.), + ]) + ); + assert_eq!( + labeled("a unless b", metrics, 60), + rows(&[("__name__=a,job=y", 2.), ("__name__=a,inst=1,job=z", 3.)]) + ); + assert_eq!( + labeled("a unless on(job) b", metrics, 60), + rows(&[("__name__=a,job=y", 2.)]) + ); + assert_eq!(labeled("a and on() b", metrics, 60).len(), 3); + // Empty sides, and duplicates on either side, which set operators allow. + let a_only = &[("a", S)]; + assert!(labeled("a and b", a_only, 60).is_empty()); + assert_eq!(labeled("a unless b", a_only, 60).len(), 3); + assert_eq!(labeled("b or a", a_only, 60).len(), 3); + let pair: &[Sample] = &[("job=x,inst=1", 50, 1.), ("job=x,inst=2", 50, f64::NAN)]; + assert_eq!( + labeled_nan("a and on(job) b", &[("a", pair), ("b", pair)], 60), + vec![ + ("__name__=a,inst=1,job=x".into(), "1.0".into()), + ("__name__=a,inst=2,job=x".into(), "NaN".into()), + ] + ); +} + +const MANY: &[Sample] = &[ + ("job=x,inst=1", 50, 2.), + ("job=x,inst=2", 50, 3.), + ("job=y,inst=1", 50, 4.), +]; +const ONE: &[Sample] = &[("job=x,team=t1", 50, 10.), ("job=y", 50, 100.)]; + +// group_left/group_right match many series to one; the result keeps the many +// side's labels plus the listed labels of the one side, which a missing label +// removes. A filter keeps the left value. +#[test] +fn group_modifiers_match_many_to_one() { + let metrics = &[("a", MANY), ("info", ONE)]; + assert_eq!( + labeled("a * on(job) group_left(team) info", metrics, 60), + rows(&[ + ("inst=1,job=x,team=t1", 20.), + ("inst=2,job=x,team=t1", 30.), + ("inst=1,job=y", 400.), + ]) + ); + assert_eq!( + labeled("info - on(job) group_right a", metrics, 60), + rows(&[ + ("inst=1,job=x", 8.), + ("inst=2,job=x", 7.), + ("inst=1,job=y", 96.) + ]) + ); + assert_eq!( + labeled("info > on(job) group_right a", metrics, 60), + rows(&[ + ("__name__=a,inst=1,job=x", 10.), + ("__name__=a,inst=2,job=x", 10.), + ("__name__=a,inst=1,job=y", 100.), + ]) + ); + assert_eq!( + labeled("a > bool ignoring(inst, team) group_left info", metrics, 60), + rows(&[ + ("inst=1,job=x", 0.), + ("inst=2,job=x", 0.), + ("inst=1,job=y", 0.) + ]) + ); + // Two "one" series for a match group, or two results with equal labels. + let two: &[Sample] = &[("job=x,team=t1", 50, 1.), ("job=x,team=t2", 50, 2.)]; + let error = evaluate( + "a * on(job) group_left info", + &[("a", MANY), ("info", two)], + 60, + ) + .unwrap_err(); + assert!(error.contains("duplicate series"), "{error}"); + let error = evaluate( + "info * on(job) group_right a", + &[("a", two), ("info", MANY)], + 60, + ) + .unwrap_err(); + assert!(error.contains("left hand-side"), "{error}"); + let named: &[Sample] = &[("job=x", 50, 1.), ("__name__=c,job=x", 50, 2.)]; + let error = evaluate( + "a * on(job) group_left info", + &[("a", named), ("info", ONE)], + 60, + ) + .unwrap_err(); + assert!(error.contains("unique matches"), "{error}"); + assert!(labeled("a * on(job) group_left info", &[("a", MANY)], 60).is_empty()); +} + +// A non-literal scalar applies like a literal; scalar-scalar arithmetic yields +// a scalar; and a literal applies to aggregated rows whose value has another name. +#[test] +fn scalar_operands_and_aggregates() { + let three: &[Sample] = &[("job=b", 50, 3.)]; + let metrics = &[("a", A), ("b", three)]; + assert_eq!( + labeled("a * scalar(b)", metrics, 60), + rows(&[("job=w", 0.), ("job=x", 30.), ("job=y", 60.)]) + ); + assert_eq!( + labeled("a > scalar(b)", metrics, 60), + rows(&[("__name__=a,job=x", 10.), ("__name__=a,job=y", 20.)]) + ); + assert_eq!(labeled("scalar(b) * 2", metrics, 60), rows(&[("", 6.)])); + assert_eq!( + labeled("scalar(b) > bool 2", metrics, 60), + rows(&[("", 1.)]) + ); + // scalar() of several series is NaN. + assert!(labeled("scalar(a) - 1", metrics, 60)[0].1.is_nan()); + assert_eq!( + labeled("sum by (job) (a) * 2", metrics, 60), + rows(&[("job=w", 0.), ("job=x", 20.), ("job=y", 40.)]) + ); + assert_eq!( + labeled("sum by (job) (a) > bool 5", metrics, 60), + rows(&[("job=w", 0.), ("job=x", 1.), ("job=y", 1.)]) + ); +} + +// Range functions other than last_over_time drop the metric name, so series +// that then share a label set are an error, as in Prometheus. +#[test] +fn range_functions_drop_the_name_and_reject_equal_label_sets() { + let equal: &[Sample] = &[ + ("job=x", 10, 1.), + ("job=x", 50, 2.), + ("__name__=b,job=x", 10, 1.), + ("__name__=b,job=x", 50, 4.), + ]; + let error = evaluate("rate(a[1m])", &[("a", equal)], 60).unwrap_err(); + assert!(error.contains("same labelset"), "{error}"); + assert_eq!( + labeled("last_over_time(a[1m])", &[("a", equal)], 60), + rows(&[("__name__=a,job=x", 2.), ("__name__=b,job=x", 4.)]) + ); + assert_eq!( + labeled("max_over_time(a[1m])", &[("a", &equal[..2])], 60), + rows(&[("job=x", 2.)]) + ); +} + +// Scalar-valued expressions are scalars too; `or vector(0)` fills an empty +// aggregate; a range function inside a subquery drops the name. +#[test] +fn scalar_expressions_or_vector_and_subquery_names() { + let three: &[Sample] = &[("job=b", 50, 3.)]; + let metrics = &[("a", A), ("b", three)]; + assert_eq!( + labeled("a + (scalar(b) * 2)", metrics, 60), + rows(&[("job=w", 6.), ("job=x", 16.), ("job=y", 26.)]) + ); + assert_eq!( + labeled("a + -scalar(b)", metrics, 60), + rows(&[("job=w", -3.), ("job=x", 7.), ("job=y", 17.)]) + ); + assert_eq!( + labeled("sum(a) or vector(0)", metrics, 60), + rows(&[("", 30.)]) + ); + assert_eq!(labeled("sum(a) or vector(0)", &[], 60), rows(&[("", 0.)])); + let counter: &[Sample] = &[("job=x", 0, 0.), ("job=x", 30, 3.), ("job=x", 60, 6.)]; + let result = labeled("last_over_time(rate(a[1m])[2m:1m])", &[("a", counter)], 60); + assert_eq!(result.len(), 1); + assert_eq!(result[0].0, "job=x"); +} + +/// Instant `x_bucket` samples at 50s: `(labels without le, [(le, count)])`. +fn buckets(series: &[(&'static str, &[(&'static str, f64)])]) -> Vec { + series + .iter() + .flat_map(|(labels, buckets)| { + buckets.iter().map(move |(le, count)| { + let spec = if labels.is_empty() { + format!("le={le}") + } else { + format!("{labels},le={le}") + }; + (&*Box::leak(spec.into_boxed_str()), 50, *count) + }) + }) + .collect() +} + +fn quantile(query: &str, samples: &[Sample]) -> Vec<(String, f64)> { + labeled(query, &[("x_bucket", samples)], 60) +} + +const HISTOGRAM: &[(&str, f64)] = &[("1", 2.), ("2", 6.), ("4", 8.), ("+Inf", 10.)]; + +// histogram_quantile interpolates linearly within the bucket holding rank q·count, +// returns the highest finite bound for the +Inf bucket, and maps q outside +// [0, 1] to ∓Inf and a NaN q to NaN. Output labels drop le and __name__. +#[test] +fn histogram_quantile_interpolates_classic_buckets() { + let samples = buckets(&[("job=a", HISTOGRAM)]); + for (q, expected) in [ + ("0", 0.), + ("0.1", 0.5), + ("0.5", 1.75), + ("0.9", 4.), + ("1", 4.), + ("-0.5", f64::NEG_INFINITY), + ("1.5", f64::INFINITY), + ] { + let query = format!("histogram_quantile({q}, x_bucket)"); + assert_eq!( + quantile(&query, &samples), + vec![("job=a".into(), expected)], + "{query}" + ); + } + let nan = quantile("histogram_quantile(NaN, x_bucket)", &samples); + assert!(matches!(nan.as_slice(), [(labels, v)] if labels == "job=a" && v.is_nan())); +} + +// Each label set other than le is its own histogram. Degenerate histograms +// yield NaN: no +Inf bucket, fewer than two buckets, or zero observations. +#[test] +fn histogram_quantile_groups_series_and_rejects_degenerate_histograms() { + let samples = buckets(&[ + ("job=a", HISTOGRAM), + ("job=b", &[("1", 1.), ("2", 2.)]), + ("job=c", &[("+Inf", 5.)]), + ("job=d", &[("1", 0.), ("+Inf", 0.)]), + ("job=e,inst=1", HISTOGRAM), + ]); + let rows = quantile("histogram_quantile(0.5, x_bucket)", &samples); + let labels: Vec<_> = rows.iter().map(|(l, _)| l.as_str()).collect(); + assert_eq!( + labels, + vec!["inst=1,job=e", "job=a", "job=b", "job=c", "job=d"] + ); + assert_eq!(rows[0].1, 1.75); + assert_eq!(rows[1].1, 1.75); + assert!(rows[2..].iter().all(|(_, v)| v.is_nan()), "{rows:?}"); +} + +// Buckets sort by bound, unparsable or missing le values are skipped, equal +// bounds merge, and decreasing cumulative counts are raised to be monotonic. +#[test] +fn histogram_quantile_normalizes_buckets_like_prometheus() { + let unordered = buckets(&[("job=a", &[("+Inf", 10.), ("4", 8.), ("1", 2.), ("2", 6.)])]); + assert_eq!( + quantile("histogram_quantile(0.5, x_bucket)", &unordered), + vec![("job=a".into(), 1.75)] + ); + let mut invalid = buckets(&[("job=a", &[("abc", 100.), ("1", 2.), ("+Inf", 4.)])]); + invalid.push(("job=a", 50, 100.)); + assert_eq!( + quantile("histogram_quantile(0.5, x_bucket)", &invalid), + vec![("job=a".into(), 1.)] + ); + // Go's ParseFloat rejects an out-of-range bound rather than rounding it to +Inf. + let overflow = buckets(&[("job=a", &[("1", 1.), ("1e400", 2.)])]); + let rows = quantile("histogram_quantile(0.5, x_bucket)", &overflow); + assert!( + matches!(rows.as_slice(), [(_, v)] if v.is_nan()), + "{rows:?}" + ); + let duplicate = buckets(&[("job=a", &[("1", 1.), ("1.0", 1.), ("+Inf", 4.)])]); + assert_eq!( + quantile("histogram_quantile(0.5, x_bucket)", &duplicate), + vec![("job=a".into(), 1.)] + ); + // Counts [6, 2→6, 8, 8]: rank 7 lies in (2, 4], 1 of its 2 observations in. + let decreasing = buckets(&[("job=a", &[("1", 6.), ("2", 2.), ("4", 8.), ("+Inf", 8.)])]); + assert_eq!( + quantile("histogram_quantile(0.875, x_bucket)", &decreasing), + vec![("job=a".into(), 3.)] + ); +} + +// A lowest bucket with a non-positive bound is returned as is, not +// interpolated from zero. +#[test] +fn histogram_quantile_non_positive_lowest_bucket() { + let samples = buckets(&[("job=a", &[("-1", 2.), ("1", 4.), ("+Inf", 4.)])]); + for (q, expected) in [("0.25", -1.), ("0.75", 0.)] { + let query = format!("histogram_quantile({q}, x_bucket)"); + assert_eq!( + quantile(&query, &samples), + vec![("job=a".into(), expected)], + "{query}" + ); + } +} + +// The common shapes: an aggregated rate keeps its by labels other than le, and +// a per-series rate keeps every label but le and __name__. +#[test] +fn histogram_quantile_over_rates_and_sums() { + // Each counter grows by c per minute, so its rate is c/60. + let counter = |labels: &'static str, le: &str, c: f64| { + let spec: &'static str = Box::leak(format!("{labels},le={le}").into_boxed_str()); + (60..=240) + .step_by(60) + .map(move |t| (spec, t as i64, c * (t / 60) as f64)) + .collect::>() + }; + let mut samples = Vec::new(); + for inst in ["job=a,inst=1", "job=a,inst=2"] { + for (le, count) in HISTOGRAM { + samples.extend(counter(inst, le, *count)); + } + } + let metrics = &[("x_bucket", samples.as_slice())]; + let close = |rows: Vec<(String, f64)>, expected: &[(&str, f64)]| { + assert_eq!(rows.len(), expected.len(), "{rows:?}"); + for ((labels, v), (want, w)) in rows.iter().zip(expected) { + assert_eq!(labels, want); + assert!((v - w).abs() < 1e-9, "{labels}: {v} vs {w}"); + } + }; + close( + labeled( + "histogram_quantile(0.5, sum by (le, job) (rate(x_bucket[5m])))", + metrics, + 300, + ), + &[("job=a", 1.75)], + ); + close( + labeled( + "histogram_quantile(0.5, sum by (le) (x_bucket))", + metrics, + 250, + ), + &[("", 1.75)], + ); + close( + labeled("histogram_quantile(0.5, rate(x_bucket[5m]))", metrics, 300), + &[("inst=1,job=a", 1.75), ("inst=2,job=a", 1.75)], + ); +} + +// Histograms that differ only in __name__ collide once it is dropped, which +// Prometheus reports as an error rather than merging them. +#[test] +fn histogram_quantile_rejects_equal_output_label_sets() { + let mut samples = buckets(&[("job=a", HISTOGRAM)]); + samples.extend(buckets(&[("__name__=y_bucket,job=a", HISTOGRAM)])); + let error = evaluate( + "histogram_quantile(0.5, x_bucket)", + &[("x_bucket", &samples)], + 60, + ) + .unwrap_err(); + assert!(error.contains("same labelset"), "{error}"); +} + +// time() uses the query evaluation instant in seconds in scalar and vector operands. +#[test] +fn evaluation_time_operands_use_runtime_scope() { + assert_eq!(labeled("time()", &[], 60), rows(&[("", 60.)])); + assert_eq!(labeled("vector(time())", &[], 60), rows(&[("", 60.)])); + assert_eq!( + labeled("a + time()", &[("a", A)], 60), + rows(&[("job=w", 60.), ("job=x", 70.), ("job=y", 80.)]) + ); + assert_eq!( + labeled("time() - scalar(b)", &[("b", &[("job=x", 60, 3.)])], 61), + rows(&[("", 58.)]) + ); +} + +// Non-finite scalar operands survive both logical and physical plan JSON round trips. +#[test] +fn nonfinite_literals_round_trip_in_plans() { + for (query, expected) in [ + ("vector(NaN)", f64::NAN), + ("vector(+Inf)", f64::INFINITY), + ("vector(-Inf)", f64::NEG_INFINITY), + ] { + let expression = lower(query); + let json = serde_json::to_vec(&expression).unwrap(); + let restored: Rc = serde_json::from_slice(&json).unwrap(); + let result = evaluate_dag(&restored, &fallback_dag(restored.clone()), &[], 60).unwrap(); + assert_eq!(result.len(), 1); + if expected.is_nan() { + assert!(result[0].2.is_nan()); + } else { + assert_eq!(result[0].2, expected); + } + } +} + +// Classic histogram results remain aggregatable and support multi-quantile label branches. +#[test] +fn histogram_quantiles_and_nested_aggregation() { + let samples = buckets(&[("job=a", HISTOGRAM), ("job=b", HISTOGRAM)]); + assert_eq!( + quantile("sum(histogram_quantile(0.5, x_bucket))", &samples), + rows(&[("", 3.5)]) + ); + assert_eq!( + quantile("histogram_quantiles(x_bucket, \"q\", 0.5, 0.9)", &samples), + rows(&[ + ("job=a,q=0.5", 1.75), + ("job=a,q=0.9", 4.0), + ("job=b,q=0.5", 1.75), + ("job=b,q=0.9", 4.0) + ]) + ); +} + +// Relabeling anchors regexes, expands captures, preserves nonmatches and removes empty labels. +#[test] +fn label_replace_preserves_promql_labels() { + let samples: &[Sample] = &[ + ("job=api:one,team=old", 50, 1.0), + ("job=other,team=old", 50, 2.0), + ]; + assert_eq!( + labeled( + "label_replace(a, \"team\", \"$1\", \"job\", \"(.*):.*\")", + &[("a", samples)], + 60 + ), + rows(&[ + ("__name__=a,job=api:one,team=api", 1.0), + ("__name__=a,job=other,team=old", 2.0) + ]) + ); + assert_eq!( + labeled( + "label_replace(a, \"team\", \"\", \"job\", \".*\")", + &[("a", samples)], + 60 + ), + rows(&[ + ("__name__=a,job=api:one", 1.0), + ("__name__=a,job=other", 2.0) + ]) + ); +} + +// Binary results over aggregates retain labels contributed by the other operand. +#[test] +fn binary_aggregates_accept_additional_labels() { + let a: &[Sample] = &[("job=x", 50, 2.0)]; + let info: &[Sample] = &[("job=x,team=blue", 50, 3.0)]; + assert_eq!( + labeled( + "sum by(job)(a) * on(job) group_left(team) info", + &[("a", a), ("info", info)], + 60 + ), + rows(&[("job=x,team=blue", 6.0)]) + ); + assert_eq!( + labeled("sum by(job)(a) or info", &[("a", a), ("info", info)], 60), + rows(&[("job=x", 2.0), ("__name__=info,job=x,team=blue", 3.0)]) + ); +} + +// Relabeling handles missing sources and named captures, and rejects label-set collisions. +#[test] +fn label_replace_missing_labels_named_captures_and_duplicates() { + let a: &[Sample] = &[("job=api:one", 50, 2.)]; + assert_eq!( + labeled( + r#"label_replace(a, "team", "${part}", "job", "(?P.*):.*")"#, + &[("a", a)], + 60 + ), + rows(&[("__name__=a,job=api:one,team=api", 2.)]) + ); + assert_eq!( + labeled( + r#"label_replace(a, "team", "unknown", "missing", "^$")"#, + &[("a", a)], + 60 + ), + rows(&[("__name__=a,job=api:one,team=unknown", 2.)]) + ); + assert!(evaluate(r#"label_replace(a, "", "x", "job", ".*")"#, &[("a", a)], 60).is_err()); + let duplicate: &[Sample] = &[("job=a", 50, 1.), ("job=b", 50, 2.)]; + assert!(evaluate( + r#"label_replace(a, "job", "same", "job", ".*")"#, + &[("a", duplicate)], + 60 + ) + .unwrap_err() + .contains("same labelset")); +} + +// Right-side grouped rows and group_right labels survive an aggregated left schema. +#[test] +fn grouped_binary_right_rows_preserve_all_labels() { + let a: &[Sample] = &[("job=x", 50, 2.)]; + let info: &[Sample] = &[("job=x,team=blue", 50, 3.)]; + let metrics = &[("a", a), ("info", info)]; + assert_eq!( + labeled("sum by(job)(a) * on(job) group_right info", metrics, 60), + rows(&[("job=x,team=blue", 6.)]) + ); + assert_eq!( + labeled("sum by(job)(a) or sum by(job,team)(info)", metrics, 60), + rows(&[("job=x", 2.), ("job=x,team=blue", 3.)]) + ); + let samples = buckets(&[("job=a", HISTOGRAM)]); + assert!(evaluate( + r#"histogram_quantiles(x_bucket, "q", 0.5, 0.5)"#, + &[("x_bucket", &samples)], + 60 + ) + .unwrap_err() + .contains("same labelset")); +} + +// Range-bound anchors compile without freezing the evaluation instant into the plan. +#[test] +fn range_bound_anchors_compile() { + for query in [ + "a @ start()", + "sum_over_time(a[1m] @ end())", + "max_over_time(a[2m:1m] @ start())", + ] { + assert!(compile_query(query).is_ok(), "{query}"); + } +} + +// Stored programs resolve outer range anchors per run, including offsets and subquery grids. +#[test] +fn range_bound_anchors_use_outer_query_bounds() { + let samples: &[Sample] = &[ + ("job=a", 30, 1.), + ("job=a", 60, 2.), + ("job=a", 90, 3.), + ("job=a", 120, 4.), + ]; + for (query, expected) in [ + ("a @ start()", 2.), + ("a @ end()", 4.), + ("a @ start() offset 30s", 1.), + ("sum_over_time(a[1m] @ end())", 7.), + ("max_over_time(a[2m:1m] @ start())", 2.), + ("max_over_time(a[2m:1m] @ end())", 4.), + ("max_over_time(a[2m:1m] @ end() offset 1m)", 2.), + ("max_over_time(a @ end()[2m:1m])", 4.), + ] { + let expression = lower(query); + let dag = fallback_dag(expression.clone()); + let output = + evaluate_dag_with_range(&expression, &dag, &[("a", samples)], 90, Some((60, 120))) + .unwrap(); + assert_eq!(output.len(), 1, "{query}"); + assert_eq!(output[0].2, expected, "{query}"); + assert_eq!(output[0].1, 90_000, "{query}"); + let error = evaluate_dag(&expression, &dag, &[("a", samples)], 90).unwrap_err(); + assert!(error.contains("query range bounds"), "{query}: {error}"); + } +} + +// Regression functions use float samples per series; prediction is anchored +// at the evaluation time even when offset or @ selects an older window. +#[test] +fn regression_range_functions_use_evaluation_time_and_drop_names() { + let samples: &[Sample] = &[("job=x", 10, 3.), ("job=x", 30, 7.), ("job=x", 50, 11.)]; + for (query, at, expected) in [ + ("deriv(a[1m])", 60, 0.2), + ("predict_linear(a[1m], 10)", 60, 15.), + ("predict_linear(a[1m] offset 30s, 10)", 90, 21.), + ("predict_linear(a[1m] @ 60, 10)", 90, 21.), + ] { + let result = labeled(query, &[("a", samples)], at); + assert_eq!(result.len(), 1, "{query}"); + assert_eq!(result[0].0, "job=x"); + assert!( + (result[0].1 - expected).abs() < 1e-12, + "{query}: {result:?}" + ); + } + let constant: &[Sample] = &[("job=x", 10, 1e300), ("job=x", 50, 1e300)]; + assert_eq!( + labeled("deriv(a[1m])", &[("a", constant)], 60), + rows(&[("job=x", 0.)]) + ); + assert_eq!( + labeled("predict_linear(a[1m], 10)", &[("a", constant)], 60), + rows(&[("job=x", 1e300)]) + ); + assert!(labeled("deriv(a[1m])", &[("a", &samples[..1])], 60).is_empty()); + let infinite: &[Sample] = &[("job=x", 10, f64::INFINITY), ("job=x", 50, f64::INFINITY)]; + assert!(labeled("deriv(a[1m])", &[("a", infinite)], 60)[0] + .1 + .is_nan()); +} + +// An `@`-pinned range function is step-invariant, as in Prometheus: it is +// evaluated once, at the query start or at the subquery's first step, so +// predict_linear's anchor does not move with each evaluation step. +#[test] +fn pinned_range_functions_are_step_invariant() { + // `a` rises by one per second, sampled every 10 s. + let rising: Vec = (0..=30) + .map(|i| ("job=x", i * 10, (i * 10) as f64)) + .collect(); + // (query, range-query bounds, Prometheus value at T = 300 s). + for (query, bounds, expected) in [ + // Subquery grid (180, 300] steps 240 and 300; one evaluation at 240. + ( + "max_over_time(predict_linear(a[1m] @ 100, 0)[2m:1m])", + None, + 240., + ), + // A range query starting at 240 evaluates its grid from (120, 240]: at 180. + ( + "max_over_time(predict_linear(a[1m] @ 100, 0)[2m:1m])", + Some((240, 300)), + 180., + ), + ( + "max_over_time(predict_linear(a[1m] @ start(), 0)[2m:1m])", + Some((240, 300)), + 180., + ), + // A top-level pinned call is evaluated at the query start. + ("predict_linear(a[1m] @ 100, 0)", Some((240, 300)), 240.), + ("predict_linear(a[1m] @ 100, 0)", None, 300.), + // Functions that do not read the evaluation time are unchanged. + ("max_over_time(deriv(a[1m] @ 100)[2m:1m])", None, 1.), + ("max_over_time(rate(a[1m] @ 100)[2m:1m])", None, 1.), + ("max_over_time(a @ 100[2m:1m])", None, 100.), + // Without `@`, the anchor is each step: the latest step, 300, wins. + ("max_over_time(predict_linear(a[1m], 0)[2m:1m])", None, 300.), + ] { + let expression = lower(query); + let dag = fallback_dag(expression.clone()); + let output = evaluate_dag_with_range(&expression, &dag, &[("a", &rising)], 300, bounds) + .unwrap_or_else(|e| panic!("{query}: {e}")); + assert_eq!(output.len(), 1, "{query}"); + assert!( + (output[0].2 - expected).abs() < 1e-9, + "{query} {bounds:?}: {} != {expected}", + output[0].2 + ); + } +} + +// Dropping an inner range function's metric name rejects equal labels within +// each subquery step, while allowing that labelset at different steps. +#[test] +fn subquery_label_uniqueness_is_checked_per_evaluation_step() { + let equal: &[Sample] = &[ + ("job=x", 10, 1.), + ("job=x", 50, 2.), + ("__name__=b,job=x", 10, 1.), + ("__name__=b,job=x", 50, 4.), + ]; + let query = "last_over_time(rate(a[1m])[2m:1m])"; + let error = evaluate(query, &[("a", equal)], 60).unwrap_err(); + assert!(error.contains("same labelset"), "{error}"); + let disjoint: &[Sample] = &[ + ("job=x", -50, 1.), + ("job=x", -10, 2.), + ("__name__=b,job=x", 10, 3.), + ("__name__=b,job=x", 50, 5.), + ]; + let result = labeled(query, &[("a", disjoint)], 60); + assert_eq!(result.len(), 1); + assert_eq!(result[0].0, "job=x"); + assert!((result[0].1 - 0.05).abs() < 1e-12); +} + +// Non-finite histogram quantile parameters survive the logical DAG JSON boundary too. +#[test] +fn logical_nonfinite_quantile_parameter_round_trips() { + let expression = lower("histogram_quantile(NaN, x_bucket)"); + let restored: Rc = + serde_json::from_slice(&serde_json::to_vec(&expression).unwrap()).unwrap(); + let samples = buckets(&[("job=a", HISTOGRAM)]); + let result = evaluate_dag( + &restored, + &fallback_dag(restored.clone()), + &[("x_bucket", &samples)], + 60, + ) + .unwrap(); + assert_eq!(result.len(), 1); + assert!(result[0].2.is_nan()); +} + +/// The proposal's pointwise projections preserve names only for unary minus. +#[test] +fn pointwise_projection_names_and_dynamic_parameters() { + let samples = [("job=a", 300, -2.5)]; + assert_eq!( + labeled("-m", &[("m", &samples)], 300), + [("__name__=m,job=a".into(), 2.5)] + ); + assert_eq!( + labeled("abs(m)", &[("m", &samples)], 300), + [("job=a".into(), 2.5)] + ); + assert_eq!( + run("round(m, scalar(vector(2)))", &samples, 300).unwrap(), + [("a".into(), 300_000, -2.0)] + ); + assert_eq!( + run("clamp(m, time()-301, time())", &samples, 300).unwrap(), + [("a".into(), 300_000, -1.0)] + ); + assert!(run("clamp(m, 2, 1)", &samples, 300).unwrap().is_empty()); + assert_eq!( + run("year(m)", &[("a", 300, 0.0)], 300).unwrap(), + [("a".into(), 300_000, 1970.0)] + ); + assert_eq!( + run("hour()", &[], 3600).unwrap(), + [("".into(), 3_600_000, 1.0)] + ); +} + +/// Execute every PromQL root/conversion example in the scalar design document. +#[test] +fn scalar_design_document_examples_execute() { + let samples = [("job=a", 300, 1.0), ("job=b", 300, 2.0)]; + for (query, expected) in [ + ("2", 2.0), + ("time()", 300.0), + ("vector(time())", 300.0), + ("scalar(sum(up)) + 1", 4.0), + ] { + let root = parse_root(query, AccuracyTarget::Exact); + root.validate_structure().unwrap(); + let output = evaluate(query, &[("up", &samples)], 300).unwrap(); + assert_eq!(output.len(), 1, "{query}"); + assert_eq!(output[0].2, expected, "{query}"); + } + assert_eq!( + labeled("up * 2", &[("up", &samples)], 300), + [("job=a".into(), 2.0), ("job=b".into(), 4.0)] + ); +} diff --git a/crates/types/src/post_asap/expr.rs b/crates/types/src/post_asap/expr.rs index 7de1e81db..bd45748aa 100644 --- a/crates/types/src/post_asap/expr.rs +++ b/crates/types/src/post_asap/expr.rs @@ -283,3 +283,21 @@ pub struct BinaryOperator { /// re-parsing PromQL. pub vector_match: Option, } + +impl BinaryOperator { + pub fn from_logical(operator: &crate::ir::BinaryOperator, return_bool: bool) -> Self { + use crate::pre_asap::BinaryOpKind as L; + Self { + kind: match &operator.kind { + L::Arithmetic(op) => BinaryOpKind::Arithmetic(op.clone()), + L::Compare(op) if return_bool => BinaryOpKind::CompareBool(op.clone()), + L::Compare(op) => BinaryOpKind::Compare(op.clone()), + L::CompareBool(op) => BinaryOpKind::CompareBool(op.clone()), + L::Set(op) => BinaryOpKind::Set(op.clone()), + }, + vector_match: operator.vector_match.clone(), + checked_relative_division: operator.checked_relative_division, + checked_finite_division: operator.checked_finite_division, + } + } +} From b6960029b3cff61af7b83124d447bd982c534d78 Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Sat, 3 Oct 2026 20:11:04 +0000 Subject: [PATCH 4/6] feat(ir): give physical ASAP DAGs one root per batch query Match the logical export: a batch is one DAG whose roots are its queries, with shared sub-DAGs exported once. The runtime compiles all roots. Co-Authored-By: Claude Opus 5.5 --- .../unified_physical_planner/candidates.rs | 6 +- .../unified_physical_planner/promql_rows.rs | 13 ++-- .../tests/unified_promql_fallback.rs | 12 ++-- crates/types/src/ir/physical_export.rs | 64 ++++++++++++++----- crates/types/tests/physical_export.rs | 22 +++++++ 5 files changed, 91 insertions(+), 26 deletions(-) diff --git a/crates/asap-physical-operators/src/unified_physical_planner/candidates.rs b/crates/asap-physical-operators/src/unified_physical_planner/candidates.rs index 248f5d40f..406013eb9 100644 --- a/crates/asap-physical-operators/src/unified_physical_planner/candidates.rs +++ b/crates/asap-physical-operators/src/unified_physical_planner/candidates.rs @@ -101,8 +101,10 @@ pub fn frontier_from_timing(dag: &PhysicalASAPDAG) -> Result, Error> .map(|node| (node.id, node.output_state.timing)) .collect::>(); let mut frontier = BTreeSet::new(); - if timing.get(&dag.root) == Some(&IngestionTime) { - frontier.insert(u64::from(dag.root.0)); + for root in &dag.roots { + if timing.get(root) == Some(&IngestionTime) { + frontier.insert(u64::from(root.0)); + } } for edge in &dag.edges { let (Some(&producer), Some(&consumer)) = diff --git a/crates/asap-physical-operators/src/unified_physical_planner/promql_rows.rs b/crates/asap-physical-operators/src/unified_physical_planner/promql_rows.rs index 46670f478..20992dfe7 100644 --- a/crates/asap-physical-operators/src/unified_physical_planner/promql_rows.rs +++ b/crates/asap-physical-operators/src/unified_physical_planner/promql_rows.rs @@ -115,7 +115,7 @@ pub fn compile_current_series_evaluation( u64::from(population.id.0), InputContract::bounded(Arc::new(population.output_schema.clone())), )]), - &[u64::from(dag.root.0)], + &dag.roots.iter().map(|r| u64::from(r.0)).collect::>(), ); } } @@ -178,7 +178,7 @@ pub fn compile_current_series_evaluation( compile( &dag, BTreeMap::from([(frontier, InputContract::bounded(schema))]), - &[u64::from(dag.root.0)], + &dag.roots.iter().map(|r| u64::from(r.0)).collect::>(), ) } @@ -229,7 +229,12 @@ pub fn compile_rate_ranking( let program = compile( &compiled.dag, BTreeMap::from([(id, InputContract::bounded(Arc::new(source.schema.clone())))]), - &[u64::from(compiled.dag.root.0)], + &compiled + .dag + .roots + .iter() + .map(|r| u64::from(r.0)) + .collect::>(), )?; Ok((source, program)) } @@ -297,7 +302,7 @@ pub fn compile_fixed_window_rate_aggregation( u64::from(source.id.0), InputContract::bounded(Arc::new(source.output_schema.clone())), )]), - &[u64::from(dag.root.0)], + &dag.roots.iter().map(|r| u64::from(r.0)).collect::>(), &[u64::from(heap.id.0)], ) } diff --git a/crates/asap-physical-operators/tests/unified_promql_fallback.rs b/crates/asap-physical-operators/tests/unified_promql_fallback.rs index 016c4f3a9..b3a00fc32 100644 --- a/crates/asap-physical-operators/tests/unified_promql_fallback.rs +++ b/crates/asap-physical-operators/tests/unified_promql_fallback.rs @@ -108,7 +108,7 @@ fn compile_dag( expression: &planner_types::ir::OperatorNode, dag: &PhysicalASAPDAG, ) -> Result { - let root = u64::from(dag.root.0); + let root = u64::from(dag.roots[0].0); let inputs = promql_fallback::raw_series(expression) .map_err(|e| e.to_string())? .into_iter() @@ -454,7 +454,7 @@ fn dense_subquery_grids_are_rejected() { fn raw_series_contract_is_explicit() { let expression = lower("rate(m[5m])"); let dag = fallback_dag(expression.clone()); - let root = u64::from(dag.root.0); + let root = u64::from(dag.roots[0].0); let [(selector, schema)] = promql_fallback::raw_series(&expression) .unwrap() .try_into() @@ -494,10 +494,14 @@ fn raw_series_contract_is_explicit() { assert!(compile( &consumed, BTreeMap::from([( - promql_fallback::raw_series_input(u64::from(consumed.root.0), 0), + promql_fallback::raw_series_input(u64::from(consumed.roots[0].0), 0), InputContract::bounded(raw) )]), - &[u64::from(consumed.root.0)] + &consumed + .roots + .iter() + .map(|r| u64::from(r.0)) + .collect::>() ) .is_ok()); // Implicit subquery resolution belongs to the deployment's evaluation interval. diff --git a/crates/types/src/ir/physical_export.rs b/crates/types/src/ir/physical_export.rs index 7aa065fe4..1dedcb9cd 100644 --- a/crates/types/src/ir/physical_export.rs +++ b/crates/types/src/ir/physical_export.rs @@ -90,7 +90,9 @@ pub struct PhysicalASAPDAG { pub edges: Vec, /// Semantic workload root. Physical query/precompute sinks are selected /// downstream by the control plane. - pub root: PhysicalASAPNodeId, + /// One root per query of the batch, in workload order. Scalar query roots + /// are not physical nodes yet. + pub roots: Vec, } /// Versioned transport envelope for a physical ASAP DAG. @@ -130,6 +132,8 @@ pub enum PhysicalASAPDAGValidationError { producer: PhysicalASAPNodeId, consumer: PhysicalASAPNodeId, }, + #[error("physical ASAP DAG has no query roots")] + NoRoots, #[error("physical ASAP DAG contains a cycle")] Cycle, #[error("physical ASAP node {0:?} is not reachable from the root")] @@ -218,8 +222,13 @@ impl PhysicalASAPDAG { } } } - if !nodes.contains_key(&self.root) { - return Err(PhysicalASAPDAGValidationError::MissingRoot(self.root)); + if self.roots.is_empty() { + return Err(PhysicalASAPDAGValidationError::NoRoots); + } + for root in &self.roots { + if !nodes.contains_key(root) { + return Err(PhysicalASAPDAGValidationError::MissingRoot(*root)); + } } let mut children: HashMap> = HashMap::new(); for edge in &self.edges { @@ -280,13 +289,11 @@ impl PhysicalASAPDAG { visited.insert(id); true } - if !visit( - self.root, - &children, - &mut HashSet::new(), - &mut HashSet::new(), - ) { - return Err(PhysicalASAPDAGValidationError::Cycle); + let mut visited = HashSet::new(); + for root in &self.roots { + if !visit(*root, &children, &mut HashSet::new(), &mut visited) { + return Err(PhysicalASAPDAGValidationError::Cycle); + } } fn mark( id: PhysicalASAPNodeId, @@ -301,7 +308,9 @@ impl PhysicalASAPDAG { } } let mut reachable = HashSet::new(); - mark(self.root, &children, &mut reachable); + for root in &self.roots { + mark(*root, &children, &mut reachable); + } if let Some(id) = nodes.keys().find(|id| !reachable.contains(id)) { return Err(PhysicalASAPDAGValidationError::UnreachableNode(*id)); } @@ -349,10 +358,29 @@ pub fn compile_physical_asap_dag( pub fn compile_physical_asap_dag_with_node_ids( root: &Rc, ) -> Result { - let (flat, nodes_by_id) = flatten(&[QueryRoot::Operator(Rc::clone(root))]); - let QueryRoot::Operator(root) = flat.roots[0] else { - unreachable!("an operator root flattens to an operator root") - }; + compile_physical_asap_workload_with_node_ids(std::slice::from_ref(root)) +} + +/// Export a timed batch as one DAG with one root per query. +pub fn compile_physical_asap_workload( + roots: &[Rc], +) -> Result { + Ok(compile_physical_asap_workload_with_node_ids(roots)?.dag) +} + +pub fn compile_physical_asap_workload_with_node_ids( + roots: &[Rc], +) -> Result { + let roots: Vec = roots.iter().cloned().map(QueryRoot::Operator).collect(); + let (flat, nodes_by_id) = flatten(&roots); + let roots = flat + .roots + .iter() + .map(|root| match root { + QueryRoot::Operator(id) => *id, + QueryRoot::Scalar(_) => unreachable!("only operator roots are flattened"), + }) + .collect(); let mut nodes: Vec = Vec::with_capacity(flat.nodes.len()); let mut edges = Vec::new(); for (id, flat_node) in flat.nodes.into_iter().enumerate() { @@ -388,7 +416,11 @@ pub fn compile_physical_asap_dag_with_node_ids( coverage: flat_node.coverage, }); } - let dag = PhysicalASAPDAG { nodes, edges, root }; + let dag = PhysicalASAPDAG { + nodes, + edges, + roots, + }; dag.validate() .expect("compiler emits a valid physical ASAP DAG"); Ok(PhysicalASAPDAGCompilation { diff --git a/crates/types/tests/physical_export.rs b/crates/types/tests/physical_export.rs index 8bc133b77..52f492f08 100644 --- a/crates/types/tests/physical_export.rs +++ b/crates/types/tests/physical_export.rs @@ -100,3 +100,25 @@ fn query_time_input_to_ingestion_is_rejected() { fn untimed_plan_is_rejected() { assert!(compile_physical_asap_dag(&plan()).is_err()); } + +/// Two queries reading one summary state export once, with one root per query. +#[test] +fn batch_shares_the_summary_and_keeps_one_root_per_query() { + use asap_types::ir::export::compile_physical_asap_workload; + let first = plan(); + let state = first.children()[0].clone(); + let second = OperatorNode::new_shared(Operator::ASAP(ASAPOp::FinalizeExactAccumulator { + child: state, + })) + .unwrap(); + let assignment = LifecycleAssignment::default_maintained(); + let mut memo = TimingMemo::new(); + let timed: Vec<_> = [first, second] + .iter() + .map(|root| apply_lifecycle_timings(root, &assignment, &mut memo).unwrap()) + .collect(); + let dag = compile_physical_asap_workload(&timed).unwrap(); + dag.validate().unwrap(); + assert_eq!(dag.roots.len(), 2); + assert_eq!(dag.nodes.len(), 4, "scan and summary are exported once"); +} From 156364c6052fbf8f9bb7b71b2e5b1b0a164dfc8a Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Mon, 5 Oct 2026 04:20:04 +0000 Subject: [PATCH 5/6] fix: integrate with #614 (DataFusion 54): let chrono resolve to DF 54's version DataFusion 54 requires chrono ^0.4.44, so the runtime crate's exact =0.4.39 pin no longer resolves; keep 0.4.39 as the minimum. Co-Authored-By: Claude Opus 5.5 --- crates/asap-physical-operators/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/asap-physical-operators/Cargo.toml b/crates/asap-physical-operators/Cargo.toml index 5d33b6086..a7130158b 100644 --- a/crates/asap-physical-operators/Cargo.toml +++ b/crates/asap-physical-operators/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] -chrono = { version = "=0.4.39", default-features = false, features = ["std"] } +chrono = { version = "0.4.39", default-features = false, features = ["std"] } futures = "0.3" planner-types = { package = "asap-types", path = "../types" } asap_sketchlib = { git = "https://github.com/ProjectASAP/asap_sketchlib", rev = "5f03ccbd798ed5fec62bdd839bcb331123cab369" } From 24d2158314635c613594cc9754c169c342cbc168 Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:21:31 +0000 Subject: [PATCH 6/6] refactor(runtime): read Operator payloads from the physical ASAP DAG Follow #537: the physical DAG now carries each node's operator as Operator (from ir::flat) instead of the removed wire mirror types. The runtime matches Operator::NonASAP / Operator::ASAP directly, and restore() rebuilds the in-memory DAG with map_children instead of a field-by-field conversion. Node ids are plain usize. Co-Authored-By: Claude Opus 5.5 --- .../unified_physical_planner/candidates.rs | 4 +- .../src/unified_physical_planner/logical.rs | 366 +----------------- .../src/unified_physical_planner/mod.rs | 163 ++++---- .../unified_physical_planner/precompute.rs | 45 ++- .../unified_physical_planner/promql_rows.rs | 58 ++- .../tests/unified_common/mod.rs | 4 +- .../tests/unified_promql_fallback.rs | 14 +- crates/types/tests/physical_export.rs | 2 +- 8 files changed, 160 insertions(+), 496 deletions(-) diff --git a/crates/asap-physical-operators/src/unified_physical_planner/candidates.rs b/crates/asap-physical-operators/src/unified_physical_planner/candidates.rs index 406013eb9..d617a835a 100644 --- a/crates/asap-physical-operators/src/unified_physical_planner/candidates.rs +++ b/crates/asap-physical-operators/src/unified_physical_planner/candidates.rs @@ -103,7 +103,7 @@ pub fn frontier_from_timing(dag: &PhysicalASAPDAG) -> Result, Error> let mut frontier = BTreeSet::new(); for root in &dag.roots { if timing.get(root) == Some(&IngestionTime) { - frontier.insert(u64::from(root.0)); + frontier.insert(*root as u64); } } for edge in &dag.edges { @@ -114,7 +114,7 @@ pub fn frontier_from_timing(dag: &PhysicalASAPDAG) -> Result, Error> }; match (producer == IngestionTime, consumer == IngestionTime) { (true, false) => { - frontier.insert(u64::from(edge.producer.0)); + frontier.insert(edge.producer as u64); } (false, true) => return Err(invalid("query-time node feeds an ingestion-time node")), _ => {} diff --git a/crates/asap-physical-operators/src/unified_physical_planner/logical.rs b/crates/asap-physical-operators/src/unified_physical_planner/logical.rs index dbc541895..e503827dd 100644 --- a/crates/asap-physical-operators/src/unified_physical_planner/logical.rs +++ b/crates/asap-physical-operators/src/unified_physical_planner/logical.rs @@ -1,369 +1,43 @@ //! Reconstruct shared operator references from the transport DAG for native lowering. use super::*; -use planner_types::ir::export::{EdgeRole, NonASAPOpKind as N, PhysicalASAPNodeId, WireScalarExpr}; -use planner_types::ir::{ - ASAPOp, NonASAPOp, Operator as LogicalOperator, OperatorNode, Predicate, ProjectItem, - ScalarExpr, SortKey as LogicalSortKey, -}; +use planner_types::ir::{ASAPOp, Operator as LogicalOperator, OperatorNode}; use std::rc::Rc; -pub(super) fn scalar( - expr: &WireScalarExpr, - id_of: &mut impl FnMut(PhysicalASAPNodeId) -> Rc, -) -> ScalarExpr { - fn boxed( - e: &WireScalarExpr, - id_of: &mut impl FnMut(PhysicalASAPNodeId) -> Rc, - ) -> Box { - Box::new(scalar(e, id_of)) - } - fn list( - es: &[WireScalarExpr], - id_of: &mut impl FnMut(PhysicalASAPNodeId) -> Rc, - ) -> Vec { - es.iter().map(|e| scalar(e, id_of)).collect() - } - match expr { - WireScalarExpr::Column(id) => ScalarExpr::Column(*id), - WireScalarExpr::Literal(v) => ScalarExpr::Literal(v.clone()), - WireScalarExpr::Negative { expr, semantics } => ScalarExpr::Negative { - expr: boxed(expr, id_of), - semantics: *semantics, - }, - WireScalarExpr::Compare { - left, - op, - right, - semantics, - } => ScalarExpr::Compare { - left: boxed(left, id_of), - op: op.clone(), - right: boxed(right, id_of), - semantics: *semantics, - }, - WireScalarExpr::BoolAnd(parts) => ScalarExpr::BoolAnd(list(parts, id_of)), - WireScalarExpr::BoolOr(parts) => ScalarExpr::BoolOr(list(parts, id_of)), - WireScalarExpr::Not(e) => ScalarExpr::Not(boxed(e, id_of)), - WireScalarExpr::IsNull(e) => ScalarExpr::IsNull(boxed(e, id_of)), - WireScalarExpr::IsNotNull(e) => ScalarExpr::IsNotNull(boxed(e, id_of)), - WireScalarExpr::Cast { expr, to, try_cast } => ScalarExpr::Cast { - expr: boxed(expr, id_of), - to: to.clone(), - try_cast: *try_cast, - }, - WireScalarExpr::InList { - expr, - list: items, - negated, - } => ScalarExpr::InList { - expr: boxed(expr, id_of), - list: list(items, id_of), - negated: *negated, - }, - WireScalarExpr::FunctionCall { name, args } => ScalarExpr::FunctionCall { - name: name.clone(), - args: list(args, id_of), - }, - WireScalarExpr::Arithmetic { - op, - left, - right, - semantics, - } => ScalarExpr::Arithmetic { - op: op.clone(), - left: boxed(left, id_of), - right: boxed(right, id_of), - semantics: *semantics, - }, - WireScalarExpr::Case { - operand, - branches, - else_expr, - } => ScalarExpr::Case { - operand: operand.as_ref().map(|e| boxed(e, id_of)), - branches: branches - .iter() - .map(|(w, t)| (scalar(w, id_of), scalar(t, id_of))) - .collect(), - else_expr: else_expr.as_ref().map(|e| boxed(e, id_of)), - }, - WireScalarExpr::CurrentTimestamp => ScalarExpr::CurrentTimestamp, - WireScalarExpr::EvalTimestamp => ScalarExpr::EvalTimestamp, - WireScalarExpr::PromqlScalarFromVector(node) => { - ScalarExpr::PromqlScalarFromVector(id_of(*node)) - } - WireScalarExpr::ScalarSubquery(node) => ScalarExpr::ScalarSubquery(id_of(*node)), - WireScalarExpr::Exists { subquery, negated } => ScalarExpr::Exists { - subquery: id_of(*subquery), - negated: *negated, - }, - WireScalarExpr::InSubquery { - expr, - subquery, - negated, - } => ScalarExpr::InSubquery { - expr: boxed(expr, id_of), - subquery: id_of(*subquery), - negated: *negated, - }, - } -} pub(super) fn restore(dag: &PhysicalASAPDAG) -> Result>, Error> { dag.validate().map_err(|e| invalid(e.to_string()))?; - let mut done = BTreeMap::new(); + let mut done: BTreeMap> = BTreeMap::new(); let mut remaining: Vec<_> = dag.nodes.iter().collect(); while !remaining.is_empty() { let before = remaining.len(); let mut next = Vec::new(); for node in remaining { - let mut edges: Vec<_> = dag.edges.iter().filter(|e| e.consumer == node.id).collect(); - if edges + if node + .payload + .children() .iter() - .any(|e| !done.contains_key(&u64::from(e.producer.0))) + .any(|child| !done.contains_key(&(**child as u64))) { next.push(node); continue; } - edges.sort_by_key(|e| match e.role { - EdgeRole::Left => 0, - EdgeRole::Input => 1, - EdgeRole::Right => 2, - EdgeRole::ScalarRef => 3, - }); - let inputs: Vec<_> = edges - .iter() - .filter(|e| e.role != EdgeRole::ScalarRef) - .map(|e| Rc::clone(&done[&u64::from(e.producer.0)])) - .collect(); - let input = |index: usize| { - inputs - .get(index) - .cloned() - .ok_or_else(|| invalid("operator is missing an input")) - }; - let mut missing = false; - let mut ref_node = |id: PhysicalASAPNodeId| { - if let Some(node) = done.get(&u64::from(id.0)) { - Rc::clone(node) - } else { - missing = true; - Rc::new(OperatorNode::with_schema( - LogicalOperator::NonASAP(NonASAPOp::Values { - rows: vec![], - schema: Default::default(), - }), - Default::default(), - )) - } - }; - let mut value = |expr: &WireScalarExpr| scalar(expr, &mut ref_node); - let operator = match &node.payload { - Payload::Relational { operator } => LogicalOperator::NonASAP(match operator { - N::Scan { - source, - predicates, - schema, - } => NonASAPOp::Scan { - source: source.clone(), - predicates: predicates.iter().map(|p| Predicate(value(&p.0))).collect(), - schema: schema.clone(), - }, - N::Values { rows, schema } => NonASAPOp::Values { - rows: rows - .iter() - .map(|r| r.iter().map(&mut value).collect()) - .collect(), - schema: schema.clone(), - }, - N::Filter { pred } => NonASAPOp::Filter { - pred: Predicate(value(&pred.0)), - child: input(0)?, - }, - N::Project { cols, qualifier } => NonASAPOp::Project { - cols: cols - .iter() - .map(|c| ProjectItem { - alias: c.alias.clone(), - expr: value(&c.expr), - }) - .collect(), - qualifier: qualifier.clone(), - child: input(0)?, - }, - N::Aggregate { - reduction, - measures, - output_names, - filters, - having, - } => NonASAPOp::Aggregate { - reduction: reduction.clone(), - measures: measures.clone(), - output_names: output_names.clone(), - filters: filters - .iter() - .map(|p| p.as_ref().map(|p| Predicate(value(&p.0)))) - .collect(), - having: having.as_ref().map(|p| Predicate(value(&p.0))), - child: input(0)?, - }, - N::Join { join_kind, pred } => NonASAPOp::Join { - kind: join_kind.clone(), - pred: Predicate(value(&pred.0)), - left: input(0)?, - right: input(1)?, - }, - N::SetOp { set_kind, all } => NonASAPOp::SetOp { - kind: set_kind.clone(), - all: *all, - left: input(0)?, - right: input(1)?, - }, - N::Concat { - discriminator_unique_key, - } => NonASAPOp::Concat { - children: inputs.clone(), - discriminator_unique_key: discriminator_unique_key.clone(), - }, - N::Dedup { cols } => NonASAPOp::Dedup { - cols: cols.clone(), - child: input(0)?, - }, - N::Sort { keys, partition_by } => NonASAPOp::Sort { - keys: keys - .iter() - .map(|k| LogicalSortKey { - expr: value(&k.expr), - ascending: k.ascending, - nulls_first: k.nulls_first, - }) - .collect(), - partition_by: partition_by.clone(), - child: input(0)?, - }, - N::Limit { - n, - offset, - partition_by, - } => NonASAPOp::Limit { - n: *n, - offset: *offset, - partition_by: partition_by.clone(), - child: input(0)?, - }, - N::BinaryOp { - operator, - return_bool, - } => NonASAPOp::BinaryOp { - operator: operator.clone(), - return_bool: *return_bool, - lhs: input(0)?, - rhs: input(1)?, - }, - N::SQLWindowFunc { - func, - args, - partition_by, - order_by, - frame, - output_name, - } => NonASAPOp::SQLWindowFunc { - func: func.clone(), - args: args.iter().map(&mut value).collect(), - partition_by: partition_by.clone(), - order_by: order_by - .iter() - .map(|k| LogicalSortKey { - expr: value(&k.expr), - ascending: k.ascending, - nulls_first: k.nulls_first, - }) - .collect(), - frame: frame.clone(), - output_name: output_name.clone(), - child: input(0)?, - }, - N::TimeRange { range, range_kind } => NonASAPOp::TimeRange { - range: *range, - kind: *range_kind, - child: input(0)?, - }, - N::TimeShift { shift } => NonASAPOp::TimeShift { - shift: *shift, - child: input(0)?, - }, - N::PromqlVectorFromScalar { expr } => { - NonASAPOp::PromqlVectorFromScalar(value(expr)) - } - N::PromqlRelabel { dst, value: expr } => NonASAPOp::PromqlRelabel { - dst: dst.clone(), - value: value(expr), - child: input(0)?, - }, - N::PromqlInfoEnrich { selector } => NonASAPOp::PromqlInfoEnrich { - selector: selector.clone(), - child: input(0)?, - }, - N::PromqlSeriesSample { by, sample_kind } => NonASAPOp::PromqlSeriesSample { - by: by.clone(), - kind: *sample_kind, - child: input(0)?, - }, - N::PromqlSubquery { range, resolution } => NonASAPOp::PromqlSubquery { - range: *range, - resolution: *resolution, - child: input(0)?, - }, - }), - Payload::SummaryAgg { - family, - input: update, - reduction, - grouping, - filter, - } => LogicalOperator::ASAP(ASAPOp::SummaryAgg { - child: input(0)?, - family: family.clone(), - input: update.clone(), - reduction: reduction.clone(), - grouping: grouping.clone(), - filter: filter.as_ref().map(|p| Predicate(value(&p.0))), - }), - Payload::SummaryEstimate { query } => { - LogicalOperator::ASAP(ASAPOp::SummaryEstimate { - summary_input: input(0)?, - query: query.clone(), - }) - } - Payload::FinalizeExactAccumulator => { - LogicalOperator::ASAP(ASAPOp::FinalizeExactAccumulator { child: input(0)? }) - } - Payload::MaintainPopulation { population } => { - LogicalOperator::ASAP(ASAPOp::MaintainPopulation { - child: input(0)?, - population: population.clone(), - }) - } - Payload::EvaluatePopulation { evaluation } => { - LogicalOperator::ASAP(ASAPOp::EvaluatePopulation { - child: input(0)?, - evaluation: evaluation.clone(), - }) - } - Payload::SummaryMerge => LogicalOperator::ASAP(ASAPOp::SummaryMerge { - children: inputs.clone(), - }), - _ => return Err(invalid("reserved ASAP operation has no native lowering")), - }; - if missing { - return Err(invalid( - "scalar reference is not a preceding DAG dependency", - )); + if matches!( + node.payload, + LogicalOperator::ASAP( + ASAPOp::SummarySubtract { .. } + | ASAPOp::SummaryDelete { .. } + | ASAPOp::SummaryJoin { .. } + | ASAPOp::Extension { .. } + ) + ) { + return Err(invalid("reserved ASAP operation has no native lowering")); } + let operator = node + .payload + .map_children(|child| Rc::clone(&done[&(*child as u64)])); let mut rebuilt = OperatorNode::with_schema(operator, node.output_schema.clone()); rebuilt.guarantee = node.guarantee.clone(); rebuilt.timing = Some(node.output_state.timing); - done.insert(u64::from(node.id.0), Rc::new(rebuilt)); + done.insert(node.id as u64, Rc::new(rebuilt)); } if next.len() == before { return Err(invalid("operator DAG is cyclic")); diff --git a/crates/asap-physical-operators/src/unified_physical_planner/mod.rs b/crates/asap-physical-operators/src/unified_physical_planner/mod.rs index 8d680fcc9..ad036e1a7 100644 --- a/crates/asap-physical-operators/src/unified_physical_planner/mod.rs +++ b/crates/asap-physical-operators/src/unified_physical_planner/mod.rs @@ -8,9 +8,9 @@ use crate::{ values::{Batch, SchemaRef}, Error, }; -use planner_types::ir::export::{ - NonASAPOpKind, PhysicalASAPDAG, PhysicalASAPDAGNode, PhysicalASAPOperatorPayload as Payload, - WireScalarExpr, +use planner_types::ir::physical_export::{ + PhysicalASAPDAG, PhysicalASAPDAGNode, PhysicalASAPNodeId, + PhysicalASAPOperatorPayload as Payload, }; use planner_types::ir::{ASAPOp, NonASAPOp, Operator as LogicalOperator, OperatorNode, ScalarExpr}; use planner_types::{ @@ -92,7 +92,7 @@ pub fn bind_with_data_sources<'a>( let _node = dag .nodes .iter() - .find(|n| u64::from(n.id.0) == id) + .find(|n| n.id as u64 == id) .ok_or_else(|| invalid(format!("missing node {id}")))?; if matches!(restored[&id].non_asap(), Some(NonASAPOp::Scan { .. })) { sources.insert(id, Box::new(data_sources.bind(&restored[&id])?)); @@ -100,8 +100,8 @@ pub fn bind_with_data_sources<'a>( pending.extend( dag.edges .iter() - .filter(|e| u64::from(e.consumer.0) == id) - .map(|e| u64::from(e.producer.0)), + .filter(|e| e.consumer as u64 == id) + .map(|e| e.producer as u64), ); } } @@ -134,37 +134,37 @@ fn compile_internal( let nodes = dag .nodes .iter() - .map(|node| (u64::from(node.id.0), node)) + .map(|node| (node.id as u64, node)) .collect::>(); let mut dependencies = BTreeMap::>::new(); // Binary input order is semantic; serialized edge order is not. let mut edges = dag.edges.iter().collect::>(); edges.sort_by_key(|edge| { ( - edge.consumer.0, + edge.consumer, match edge.role { - planner_types::ir::export::EdgeRole::Left => 0, - planner_types::ir::export::EdgeRole::Input => 1, - planner_types::ir::export::EdgeRole::Right => 2, - planner_types::ir::export::EdgeRole::ScalarRef => 3, + planner_types::ir::physical_export::EdgeRole::Left => 0, + planner_types::ir::physical_export::EdgeRole::Input => 1, + planner_types::ir::physical_export::EdgeRole::Right => 2, + planner_types::ir::physical_export::EdgeRole::ScalarRef => 3, }, ) }); let literals = BTreeMap::::new(); for edge in edges { dependencies - .entry(u64::from(edge.consumer.0)) + .entry(edge.consumer as u64) .or_default() - .push(u64::from(edge.producer.0)); + .push(edge.producer as u64); } let mut fallback = BTreeMap::new(); for (&id, root) in &restored { let raw_summary_input = matches!(root.non_asap(), Some(NonASAPOp::TimeRange { .. })) && dag.edges.iter().any(|e| { - u64::from(e.producer.0) == id + e.producer as u64 == id && matches!( - nodes[&u64::from(e.consumer.0)].payload, - Payload::SummaryAgg { .. } + nodes[&(e.consumer as u64)].payload, + Payload::ASAP(ASAPOp::SummaryAgg { .. }) ) }); if !root.contains_asap() && !raw_summary_input { @@ -179,7 +179,7 @@ fn compile_internal( matches!( nodes.get(&owner), Some(PhysicalASAPDAGNode { - payload: Payload::Relational { .. }, + payload: Payload::NonASAP(_), .. }) ) @@ -228,7 +228,9 @@ fn compile_internal( .iter() .map(|id| Arc::new(nodes[id].output_schema.clone())) .collect::>(); - if matches!(node.payload, Payload::SummaryMerge) && inputs.len() > 1 { + if matches!(node.payload, Payload::ASAP(ASAPOp::SummaryMerge { .. })) + && inputs.len() > 1 + { if schemas.iter().any(|s| s != &schemas[0]) { return Err(invalid("summary merge inputs have different schemas")); } @@ -290,7 +292,7 @@ fn compile_internal( )?; continue; } - if let Payload::MaintainPopulation { population } = &node.payload { + if let Payload::ASAP(ASAPOp::MaintainPopulation { population, .. }) = &node.payload { use planner_types::post_asap::maintained_population::PopulationInput; let PopulationInput::CurrentSeries(spec) = &population.input else { return Err(invalid( @@ -323,14 +325,16 @@ fn compile_internal( )?; continue; } - if let Payload::EvaluatePopulation { evaluation } = &node.payload { + if let Payload::ASAP(ASAPOp::EvaluatePopulation { evaluation, .. }) = &node.payload { use planner_types::post_asap::maintained_population::{ PopulationInput, PopulationStatistic, }; let [producer] = inputs.as_slice() else { return Err(invalid("population evaluation requires one input")); }; - let Payload::MaintainPopulation { population } = &nodes[producer].payload else { + let Payload::ASAP(ASAPOp::MaintainPopulation { population, .. }) = + &nodes[producer].payload + else { return Err(invalid( "population evaluation requires its declared population", )); @@ -385,13 +389,14 @@ fn compile_internal( } // A closed row must include either all source labels or the explicit // complete-label identity. Projected labels alone are insufficient. - if let Payload::SummaryAgg { + if let Payload::ASAP(ASAPOp::SummaryAgg { family, input: update, reduction: PlannerReduction::PerEntity, grouping, filter: None, - } = &node.payload + .. + }) = &node.payload { let [input_id] = inputs.as_slice() else { return Err(invalid("per-entity summary requires one input")); @@ -440,13 +445,11 @@ fn compile_internal( )?; continue; } - if let Payload::Relational { - operator: - NonASAPOpKind::BinaryOp { - operator, - return_bool, - }, - } = &node.payload + if let Payload::NonASAP(NonASAPOp::BinaryOp { + operator, + return_bool, + .. + }) = &node.payload { let operator = crate::expressions::binary::BinaryOperator::from_logical( operator, @@ -506,7 +509,7 @@ fn compile_internal( } } } - if let Payload::FinalizeExactAccumulator = &node.payload { + if let Payload::ASAP(ASAPOp::FinalizeExactAccumulator { .. }) = &node.payload { // Exact counts read out as Int64; PromQL declares a Float64 sample. let evaluation = bind_operation(node, &schemas) .map_err(|error| invalid(format!("node {id}: {error}")))?; @@ -558,9 +561,8 @@ fn compile_internal( if !visited.insert(ancestor) { continue; } - if let Payload::Relational { - operator: NonASAPOpKind::TimeRange { range, .. }, - } = &nodes[&ancestor].payload + if let Payload::NonASAP(NonASAPOp::TimeRange { range, .. }) = + &nodes[&ancestor].payload { ranges.insert( i64::try_from(range.as_millis()) @@ -598,14 +600,15 @@ fn temporal_evaluation_drops_name(node: &PhysicalASAPDAGNode) -> bool { .any(|field| field.name == promql_rows::SERIES_IDENTITY_COLUMN) && matches!( &node.payload, - Payload::FinalizeExactAccumulator - | Payload::SummaryEstimate { + Payload::ASAP(ASAPOp::FinalizeExactAccumulator { .. }) + | Payload::ASAP(ASAPOp::SummaryEstimate { query: SketchStatistic::Quantile { .. } | SketchStatistic::Cardinality | SketchStatistic::PointCount { .. } | SketchStatistic::FrequencyL2 - | SketchStatistic::FrequencyEntropy - } + | SketchStatistic::FrequencyEntropy, + .. + }) ) } @@ -619,12 +622,11 @@ pub fn compile_node(node: &PhysicalASAPDAGNode, inputs: &[SchemaRef]) -> Result< } fn bind_operation(node: &PhysicalASAPDAGNode, inputs: &[SchemaRef]) -> Result { - if let Payload::Relational { - operator: NonASAPOpKind::BinaryOp { - operator, - return_bool, - }, - } = &node.payload + if let Payload::NonASAP(NonASAPOp::BinaryOp { + operator, + return_bool, + .. + }) = &node.payload { let operator = crate::expressions::binary::BinaryOperator::from_logical(operator, *return_bool); @@ -673,9 +675,11 @@ fn bind_operation(node: &PhysicalASAPDAGNode, inputs: &[SchemaRef]) -> Result Result Result { + Payload::ASAP(ASAPOp::FinalizeExactAccumulator { .. }) => { let state = summary_column(input)?; use crate::Statistic as S; use planner_types::post_asap::ExactKind as E; @@ -748,8 +749,8 @@ fn bind_operation(node: &PhysicalASAPDAGNode, inputs: &[SchemaRef]) -> Result match operator { - NonASAPOpKind::Project { cols, .. } => Operator::project( + Payload::NonASAP(operator) => match operator { + NonASAPOp::Project { cols, .. } => Operator::project( input.clone(), cols.iter() .enumerate() @@ -762,21 +763,23 @@ fn bind_operation(node: &PhysicalASAPDAGNode, inputs: &[SchemaRef]) -> Result Expression::Column(*index), + ScalarExpr::Column(index) => Expression::Column(*index), expr => expression(expr, input)?, }, )) }) .collect::>()?, ), - NonASAPOpKind::Filter { pred } => { + NonASAPOp::Filter { pred, .. } => { Operator::filter(input.clone(), expression(&pred.0, input)?) } - NonASAPOpKind::Sort { keys, partition_by } => Operator::sort( + NonASAPOp::Sort { + keys, partition_by, .. + } => Operator::sort( input.clone(), keys.iter() .map(|key| { - let WireScalarExpr::Column(column) = key.expr else { + let ScalarExpr::Column(column) = key.expr else { return Err(invalid( "sort expression must be projected before sorting", )); @@ -790,22 +793,24 @@ fn bind_operation(node: &PhysicalASAPDAGNode, inputs: &[SchemaRef]) -> Result>()?, groups(input, partition_by)?, ), - NonASAPOpKind::Limit { + NonASAPOp::Limit { n, offset, partition_by, + .. } => Operator::limit( input.clone(), n.unwrap_or(usize::MAX) as u64, *offset as u64, groups(input, partition_by)?, ), - NonASAPOpKind::Aggregate { + NonASAPOp::Aggregate { reduction, measures, output_names, filters, having: None, + .. } => { if filters.iter().any(Option::is_some) { return Err(invalid("filtered aggregate has no native implementation")); @@ -845,13 +850,14 @@ fn bind_operation(node: &PhysicalASAPDAGNode, inputs: &[SchemaRef]) -> Result Err(invalid("value operation has no native implementation")), }, - Payload::SummaryAgg { + Payload::ASAP(ASAPOp::SummaryAgg { family, input: update, reduction, grouping, filter, - } => { + .. + }) => { if filter.is_some() { return Err(invalid( "filtered summary update has no native implementation", @@ -922,7 +928,7 @@ fn bind_operation(node: &PhysicalASAPDAGNode, inputs: &[SchemaRef]) -> Result { + Payload::ASAP(ASAPOp::SummaryMerge { .. }) => { let state = summary_column(input)?; Operator::summary_merge( input.clone(), @@ -932,7 +938,7 @@ fn bind_operation(node: &PhysicalASAPDAGNode, inputs: &[SchemaRef]) -> Result { + Payload::ASAP(ASAPOp::SummaryEstimate { query, .. }) => { if let SketchStatistic::TopK { k } = query { return Operator::keyed_readout( input.clone(), @@ -999,7 +1005,10 @@ fn groups(input: &SchemaRef, groups: &GroupKeys) -> Result, Error> { } Ok(groups.keys().to_vec()) } -fn expression(expr: &WireScalarExpr, input: &SchemaRef) -> Result { +fn expression( + expr: &ScalarExpr, + input: &SchemaRef, +) -> Result { let expr = local_scalar(expr)?; Ok(Expression::unified_planner( crate::expressions::unified_planner::CompiledExpression::compile(&expr, input)?, @@ -1147,23 +1156,11 @@ pub fn equijoin_keys( Ok(keys) } -fn local_scalar(expr: &WireScalarExpr) -> Result { - let mut missing = false; - let result = logical::scalar(expr, &mut |_| { - missing = true; - std::rc::Rc::new(OperatorNode::with_schema( - LogicalOperator::NonASAP(NonASAPOp::Values { - rows: vec![], - schema: Default::default(), - }), - Default::default(), - )) - }); - if missing { - Err(invalid( +fn local_scalar(expr: &ScalarExpr) -> Result { + if !expr.operator_refs().is_empty() { + return Err(invalid( "scalar plan reads require explicit execution bindings", - )) - } else { - Ok(result) + )); } + Ok(expr.map_operator_refs(&mut |_| unreachable!("no operator references"))) } diff --git a/crates/asap-physical-operators/src/unified_physical_planner/precompute.rs b/crates/asap-physical-operators/src/unified_physical_planner/precompute.rs index 12e7564d8..2053e1d75 100644 --- a/crates/asap-physical-operators/src/unified_physical_planner/precompute.rs +++ b/crates/asap-physical-operators/src/unified_physical_planner/precompute.rs @@ -86,12 +86,12 @@ pub fn raw_sample_row( pub fn boundary_schema(node: &PhysicalASAPDAGNode) -> Result { if !matches!( &node.payload, - Payload::Relational { - operator: NonASAPOpKind::Scan { + Payload::NonASAP( + NonASAPOp::Scan { source: planner_types::pre_asap::Source::TimeSeries { .. }, .. - } | NonASAPOpKind::TimeRange { .. } - } + } | NonASAPOp::TimeRange { .. } + ) ) { return source_schema(&node.output_schema); } @@ -164,7 +164,7 @@ pub fn compile( let nodes = dag .nodes .iter() - .map(|n| (u64::from(n.id.0), n)) + .map(|n| (n.id as u64, n)) .collect::>(); let frontier = frontiers.iter().copied().collect::>(); if frontier.len() != frontiers.len() || roots.iter().any(|r| frontier.contains(r)) { @@ -176,20 +176,20 @@ pub fn compile( let mut edges = dag.edges.iter().collect::>(); edges.sort_by_key(|edge| { ( - edge.consumer.0, + edge.consumer, match edge.role { - planner_types::ir::export::EdgeRole::Left => 0, - planner_types::ir::export::EdgeRole::Input => 1, - planner_types::ir::export::EdgeRole::Right => 2, - planner_types::ir::export::EdgeRole::ScalarRef => 3, + planner_types::ir::physical_export::EdgeRole::Left => 0, + planner_types::ir::physical_export::EdgeRole::Input => 1, + planner_types::ir::physical_export::EdgeRole::Right => 2, + planner_types::ir::physical_export::EdgeRole::ScalarRef => 3, }, ) }); for edge in edges { dependencies - .entry(u64::from(edge.consumer.0)) + .entry(edge.consumer as u64) .or_default() - .push(u64::from(edge.producer.0)); + .push(edge.producer as u64); } let mut ordered = Vec::new(); let mut seen = BTreeSet::new(); @@ -309,13 +309,11 @@ fn fragment( Ok(id) }; let root = match &node.payload { - Payload::Relational { - operator: - NonASAPOpKind::BinaryOp { - operator, - return_bool, - }, - } => { + Payload::NonASAP(NonASAPOp::BinaryOp { + operator, + return_bool, + .. + }) => { let operator = crate::expressions::binary::BinaryOperator::from_logical(operator, *return_bool); validate_value_output(node)?; @@ -340,7 +338,7 @@ fn fragment( )?, )? } - Payload::FinalizeExactAccumulator => { + Payload::ASAP(ASAPOp::FinalizeExactAccumulator { .. }) => { let [input] = schemas else { return Err(invalid("finalize requires one state input")); }; @@ -385,13 +383,14 @@ fn fragment( )))?; add(vec![read], project)? } - Payload::SummaryAgg { + Payload::ASAP(ASAPOp::SummaryAgg { family, input: update, reduction, grouping, filter, - } => { + .. + }) => { if filter.is_some() { return Err(invalid( "filtered summary update has no native implementation", @@ -539,7 +538,7 @@ fn fragment( Operator::scope_timestamp(built, population_schema(family.clone()))?, )? } - Payload::SummaryMerge => { + Payload::ASAP(ASAPOp::SummaryMerge { .. }) => { let Some(input) = schemas.first() else { return Err(invalid("summary merge requires inputs")); }; diff --git a/crates/asap-physical-operators/src/unified_physical_planner/promql_rows.rs b/crates/asap-physical-operators/src/unified_physical_planner/promql_rows.rs index 20992dfe7..15d7dd689 100644 --- a/crates/asap-physical-operators/src/unified_physical_planner/promql_rows.rs +++ b/crates/asap-physical-operators/src/unified_physical_planner/promql_rows.rs @@ -1,7 +1,7 @@ //! A bounded PromQL source row carries the entire label set, not just labels //! mentioned by the query. The source adapter owns this lossless encoding. use super::*; -use planner_types::ir::export::{ +use planner_types::ir::physical_export::{ compile_physical_asap_dag, compile_physical_asap_dag_with_node_ids, }; use planner_types::post_asap::FieldDataType as SummaryFamilyType; @@ -99,7 +99,7 @@ pub fn compile_current_series_evaluation( // Typed snapshot candidates already carry full identity throughout the DAG. // Cut at the population output, preserving all selected heap/evaluation nodes. let populations = dag.nodes.iter().filter(|node| matches!(&node.payload, - Payload::MaintainPopulation { population } + Payload::ASAP(ASAPOp::MaintainPopulation { population, .. }) if matches!(population.input, planner_types::post_asap::maintained_population::PopulationInput::CurrentSeries(_)) )).collect::>(); if let [population] = populations.as_slice() { @@ -112,18 +112,18 @@ pub fn compile_current_series_evaluation( return compile( &dag, BTreeMap::from([( - u64::from(population.id.0), + population.id as u64, InputContract::bounded(Arc::new(population.output_schema.clone())), )]), - &dag.roots.iter().map(|r| u64::from(r.0)).collect::>(), + &dag.roots.iter().map(|r| *r as u64).collect::>(), ); } } let mut frontier = None; for node in &mut dag.nodes { match &mut node.payload { - Payload::Relational { operator } => { - if let NonASAPOpKind::Scan { schema, .. } = operator { + Payload::NonASAP(operator) => { + if let NonASAPOp::Scan { schema, .. } = operator { schema.fields.push(SummaryField::new( SERIES_IDENTITY_COLUMN, SummaryFamilyType::Plain(DataType::Utf8), @@ -132,12 +132,13 @@ pub fn compile_current_series_evaluation( schema.closed = true; } } - Payload::MaintainPopulation { .. } => { - frontier = Some(u64::from(node.id.0)); + Payload::ASAP(ASAPOp::MaintainPopulation { .. }) => { + frontier = Some(node.id as u64); } - Payload::EvaluatePopulation { + Payload::ASAP(ASAPOp::EvaluatePopulation { evaluation: PopulationStatistic::TopK { .. }, - } => {} + .. + }) => {} _ => return Err(invalid("unsupported current-series evaluation dependency")), } if node @@ -170,7 +171,7 @@ pub fn compile_current_series_evaluation( let schema = Arc::new( dag.nodes .iter() - .find(|node| u64::from(node.id.0) == frontier) + .find(|node| node.id as u64 == frontier) .unwrap() .output_schema .clone(), @@ -178,7 +179,7 @@ pub fn compile_current_series_evaluation( compile( &dag, BTreeMap::from([(frontier, InputContract::bounded(schema))]), - &dag.roots.iter().map(|r| u64::from(r.0)).collect::>(), + &dag.roots.iter().map(|r| *r as u64).collect::>(), ) } @@ -219,13 +220,10 @@ pub fn compile_rate_ranking( } let compiled = compile_physical_asap_dag_with_node_ids(&selected) .map_err(|error| invalid(error.to_string()))?; - let id = u64::from( - compiled - .node_ids - .node_id(&source) - .ok_or_else(|| invalid("missing Rate frontier"))? - .0, - ); + let id = compiled + .node_ids + .node_id(&source) + .ok_or_else(|| invalid("missing Rate frontier"))? as u64; let program = compile( &compiled.dag, BTreeMap::from([(id, InputContract::bounded(Arc::new(source.schema.clone())))]), @@ -233,7 +231,7 @@ pub fn compile_rate_ranking( .dag .roots .iter() - .map(|r| u64::from(r.0)) + .map(|r| *r as u64) .collect::>(), )?; Ok((source, program)) @@ -243,7 +241,7 @@ pub fn compile_rate_ranking( /// Rate evaluations runs at ingestion time: fresh aggregate state per closed /// window. The input is the complete collection of per-series counter states. pub fn compile_fixed_window_rate_aggregation( - dag: &planner_types::ir::export::PhysicalASAPDAG, + dag: &planner_types::ir::physical_export::PhysicalASAPDAG, ) -> Result { use planner_types::post_asap::{ExactKind, ExecutionTiming, SketchAlgorithm}; let sources = dag @@ -252,11 +250,11 @@ pub fn compile_fixed_window_rate_aggregation( .filter(|n| { matches!( &n.payload, - Payload::SummaryAgg { + Payload::ASAP(ASAPOp::SummaryAgg { family: SummaryFamilyType::ExactAggregate(ExactKind::Rate, _), reduction: planner_types::pre_asap::Reduction::PerEntity, .. - } + }) ) }) .collect::>(); @@ -266,17 +264,17 @@ pub fn compile_fixed_window_rate_aggregation( .filter(|n| { n.output_state.timing == ExecutionTiming::IngestionTime && match &n.payload { - Payload::SummaryAgg { + Payload::ASAP(ASAPOp::SummaryAgg { family: SummaryFamilyType::Sketch(kind, _), .. - } => matches!( + }) => matches!( kind.algorithm(), SketchAlgorithm::CmsWithHeap | SketchAlgorithm::CountSketchWithHeap ), - Payload::SummaryAgg { + Payload::ASAP(ASAPOp::SummaryAgg { family: SummaryFamilyType::ExactAggregate(ExactKind::Sum, _), .. - } => true, + }) => true, _ => false, } }) @@ -299,10 +297,10 @@ pub fn compile_fixed_window_rate_aggregation( compile_candidate( dag, BTreeMap::from([( - u64::from(source.id.0), + source.id as u64, InputContract::bounded(Arc::new(source.output_schema.clone())), )]), - &dag.roots.iter().map(|r| u64::from(r.0)).collect::>(), - &[u64::from(heap.id.0)], + &dag.roots.iter().map(|r| *r as u64).collect::>(), + &[heap.id as u64], ) } diff --git a/crates/asap-physical-operators/tests/unified_common/mod.rs b/crates/asap-physical-operators/tests/unified_common/mod.rs index 35ba496b4..16f6b1f25 100644 --- a/crates/asap-physical-operators/tests/unified_common/mod.rs +++ b/crates/asap-physical-operators/tests/unified_common/mod.rs @@ -1,5 +1,5 @@ #![allow(dead_code)] -use planner_types::ir::export::PhysicalASAPDAG; +use planner_types::ir::physical_export::PhysicalASAPDAG; use planner_types::ir::{apply_lifecycle_timings, LifecycleAssignment, OperatorNode, TimingMemo}; use std::rc::Rc; @@ -11,5 +11,5 @@ pub fn compile_physical_asap_dag( &LifecycleAssignment::default(), &mut TimingMemo::default(), )?; - Ok(planner_types::ir::export::compile_physical_asap_dag(&root)?) + Ok(planner_types::ir::physical_export::compile_physical_asap_dag(&root)?) } diff --git a/crates/asap-physical-operators/tests/unified_promql_fallback.rs b/crates/asap-physical-operators/tests/unified_promql_fallback.rs index b3a00fc32..fe1a39140 100644 --- a/crates/asap-physical-operators/tests/unified_promql_fallback.rs +++ b/crates/asap-physical-operators/tests/unified_promql_fallback.rs @@ -13,7 +13,7 @@ use asap_physical_operators::{ }; use common::compile_physical_asap_dag; use futures::{executor::block_on, StreamExt}; -use planner_types::ir::export::PhysicalASAPDAG; +use planner_types::ir::physical_export::PhysicalASAPDAG; use planner_types::{ post_asap::execution_data_state::lift_plain, types::AccuracyTarget, workload::*, }; @@ -108,7 +108,7 @@ fn compile_dag( expression: &planner_types::ir::OperatorNode, dag: &PhysicalASAPDAG, ) -> Result { - let root = u64::from(dag.roots[0].0); + let root = dag.roots[0] as u64; let inputs = promql_fallback::raw_series(expression) .map_err(|e| e.to_string())? .into_iter() @@ -454,7 +454,7 @@ fn dense_subquery_grids_are_rejected() { fn raw_series_contract_is_explicit() { let expression = lower("rate(m[5m])"); let dag = fallback_dag(expression.clone()); - let root = u64::from(dag.roots[0].0); + let root = dag.roots[0] as u64; let [(selector, schema)] = promql_fallback::raw_series(&expression) .unwrap() .try_into() @@ -494,14 +494,10 @@ fn raw_series_contract_is_explicit() { assert!(compile( &consumed, BTreeMap::from([( - promql_fallback::raw_series_input(u64::from(consumed.roots[0].0), 0), + promql_fallback::raw_series_input(consumed.roots[0] as u64, 0), InputContract::bounded(raw) )]), - &consumed - .roots - .iter() - .map(|r| u64::from(r.0)) - .collect::>() + &consumed.roots.iter().map(|r| *r as u64).collect::>() ) .is_ok()); // Implicit subquery resolution belongs to the deployment's evaluation interval. diff --git a/crates/types/tests/physical_export.rs b/crates/types/tests/physical_export.rs index 52f492f08..44f2f9721 100644 --- a/crates/types/tests/physical_export.rs +++ b/crates/types/tests/physical_export.rs @@ -104,7 +104,7 @@ fn untimed_plan_is_rejected() { /// Two queries reading one summary state export once, with one root per query. #[test] fn batch_shares_the_summary_and_keeps_one_root_per_query() { - use asap_types::ir::export::compile_physical_asap_workload; + use asap_types::ir::physical_export::compile_physical_asap_workload; let first = plan(); let state = first.children()[0].clone(); let second = OperatorNode::new_shared(Operator::ASAP(ASAPOp::FinalizeExactAccumulator {