diff --git a/crates/asap-aware-mapping/src/accuracy/composition.rs b/crates/asap-aware-mapping/src/accuracy/composition.rs index b7b99850d..17f2796f5 100644 --- a/crates/asap-aware-mapping/src/accuracy/composition.rs +++ b/crates/asap-aware-mapping/src/accuracy/composition.rs @@ -4,11 +4,11 @@ //! `Unknown` leaf named by one of the constants below, which Stage 3 binds. use super::*; -/// Rows an exact aggregation folds (per group): bounded by the data -/// workload's input cardinality. +/// Rows an exact aggregation folds (per group): bounded by the series +/// count of the sources the query reads. pub(crate) const INPUT_ROW_COUNT: &str = "input_row_count"; /// Rows one group of a SQL fold over an approximate relation folds: not -/// bounded by the data workload's input cardinality, so plan selection +/// bounded by the series count of the sources it reads, so plan selection /// leaves it unbound. pub(crate) const GROUP_ROW_COUNT: &str = "group_row_count"; /// Whether every input value is non-negative. diff --git a/crates/asap-aware-mapping/src/accuracy/evidence.rs b/crates/asap-aware-mapping/src/accuracy/evidence.rs index dd245bc77..3c98f94fa 100644 --- a/crates/asap-aware-mapping/src/accuracy/evidence.rs +++ b/crates/asap-aware-mapping/src/accuracy/evidence.rs @@ -130,70 +130,3 @@ pub trait AccuracyEvidenceProvider { pub struct NoAccuracyEvidence; impl AccuracyEvidenceProvider for NoAccuracyEvidence {} - -/// Accuracy evidence backed by the normalized data workload. Freshness is -/// checked at the planning time before values reach any accuracy rule. -#[derive(Debug, Clone, Copy)] -pub struct WorkloadAccuracyEvidence<'a> { - pub data: &'a asap_types::workload::DataWorkload, - pub now_ms: u64, -} - -impl AccuracyEvidenceProvider for WorkloadAccuracyEvidence<'_> { - fn propagation_stats( - &self, - _op: &CompositionOperator, - _family: &FieldDataType, - _query: Option<&SketchStatistic>, - ) -> PropagationStats { - PropagationStats { - input_row_count: self.data.input_cardinality.value_at(self.now_ms).copied(), - ..PropagationStats::default() - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use asap_types::workload::{DataWorkload, Evidence, EvidenceSource}; - #[test] - fn workload_accuracy_evidence_uses_only_fresh_data_characteristics() { - let data = DataWorkload { - input_cardinality: Evidence { - value: Some(42), - source: EvidenceSource::Observed, - observed_at_ms: Some(1_000), - valid_for_ms: Some(500), - }, - ..Default::default() - }; - let provider = WorkloadAccuracyEvidence { - data: &data, - now_ms: 1_500, - }; - let fresh = provider.propagation_stats( - &CompositionOperator::ExactSum, - &FieldDataType::ExactAggregate( - asap_types::post_asap::ExactKind::Sum, - asap_types::post_asap::ExactParams::Sum, - ), - None, - ); - assert_eq!(fresh.input_row_count, Some(42)); - - let stale = WorkloadAccuracyEvidence { - data: &data, - now_ms: 1_501, - } - .propagation_stats( - &CompositionOperator::ExactSum, - &FieldDataType::ExactAggregate( - asap_types::post_asap::ExactKind::Sum, - asap_types::post_asap::ExactParams::Sum, - ), - None, - ); - assert_eq!(stale.input_row_count, None); - } -} diff --git a/crates/asap-aware-mapping/src/accuracy/mod.rs b/crates/asap-aware-mapping/src/accuracy/mod.rs index c54ebf1df..05b1a6342 100644 --- a/crates/asap-aware-mapping/src/accuracy/mod.rs +++ b/crates/asap-aware-mapping/src/accuracy/mod.rs @@ -13,10 +13,7 @@ pub mod evidence; pub mod reconciliation; pub use budget::per_input_target; -pub use evidence::{ - AccuracyEvidenceProvider, NoAccuracyEvidence, OperandDomain, PropagationStats, - WorkloadAccuracyEvidence, -}; +pub use evidence::{AccuracyEvidenceProvider, NoAccuracyEvidence, OperandDomain, PropagationStats}; use asap_types::ir::OperatorNode; use asap_types::post_asap::{ diff --git a/crates/asap-aware-mapping/src/analytical_cost.rs b/crates/asap-aware-mapping/src/analytical_cost.rs index 77caffbb6..c62ab78a4 100644 --- a/crates/asap-aware-mapping/src/analytical_cost.rs +++ b/crates/asap-aware-mapping/src/analytical_cost.rs @@ -96,12 +96,12 @@ fn resolve_cache_profile( } 0.0 } - DataArrival::ContinuouslyIngesting => evidence.result_invalidation_ratio.ok_or( + DataArrival::Ingesting { .. } => evidence.result_invalidation_ratio.ok_or( AnalyticalCostError::InvalidCacheEvidence( "continuous ingestion requires result-cache invalidation evidence", ), )?, - DataArrival::Mixed | DataArrival::Unknown => { + DataArrival::Mixed { .. } | DataArrival::Unknown => { return Err(AnalyticalCostError::InvalidCacheEvidence( "cache invalidation cannot be derived for mixed or unknown data arrival", )); @@ -3811,7 +3811,13 @@ mod tests { let profile = cache_profile(100, 100); assert!(matches!( - cache_hit_ratios(&profile, 6, DataArrival::ContinuouslyIngesting), + cache_hit_ratios( + &profile, + 6, + DataArrival::Ingesting { + rows_per_second: 1_000.0, + } + ), Err(AnalyticalCostError::InvalidCacheEvidence(_)) )); } @@ -4009,7 +4015,9 @@ mod tests { fn streaming_invalidation_bounds_result_hits_and_changes_physical_work() { let (nodes, evidence) = cache_test_scan(); let mut scope = comparison_scope(); - scope.data_arrival = DataArrival::ContinuouslyIngesting; + scope.data_arrival = DataArrival::Ingesting { + rows_per_second: 1_000.0, + }; let mut profile = cache_profile(100, 50); let CacheProfile::Evidence(inputs) = &mut profile else { unreachable!() diff --git a/crates/asap-aware-mapping/src/lib.rs b/crates/asap-aware-mapping/src/lib.rs index 8a90da3c3..2d59269e3 100644 --- a/crates/asap-aware-mapping/src/lib.rs +++ b/crates/asap-aware-mapping/src/lib.rs @@ -172,7 +172,7 @@ pub mod topk_reuse; pub use accuracy::reconciliation::AccuracyReconciliationStrategy; pub use accuracy::{ AccuracyEvidenceProvider, AccuracyModel, DefaultAccuracyModel, NoAccuracyEvidence, - PropagationStats, WorkloadAccuracyEvidence, + PropagationStats, }; pub use cost_model::{ maintenance_operation_plan_cost_rate, raw_recompute_cost_rate, read_operation_plan_cost_rate, diff --git a/crates/asap-aware-mapping/src/maintained_population.rs b/crates/asap-aware-mapping/src/maintained_population.rs index 7a5e45333..c847e5d1e 100644 --- a/crates/asap-aware-mapping/src/maintained_population.rs +++ b/crates/asap-aware-mapping/src/maintained_population.rs @@ -138,7 +138,7 @@ fn recognize( } return Some((population, evaluation, Rc::clone(source))); } - // A bare PromQL selector carries the declared ingestion interval as a + // A bare PromQL selector carries its metric's sample interval as a // temporal input scope. Membership must expire at that horizon; retain // the wrapper as the maintained input so validation can check agreement. let (series_source, lookback_ms) = match source.non_asap() { diff --git a/crates/asap-aware-mapping/src/per_computation_candidates.rs b/crates/asap-aware-mapping/src/per_computation_candidates.rs index ecba85ef5..bbd26fb79 100644 --- a/crates/asap-aware-mapping/src/per_computation_candidates.rs +++ b/crates/asap-aware-mapping/src/per_computation_candidates.rs @@ -1577,8 +1577,8 @@ fn source_distinct_guarantee( fn fold_stats(reduction: &Reduction, input: &Rc) -> Option { let sign_statistic = estimates_samples_of(input).map(|metric| samples_sign_statistic(&metric)); let stats = match reduction { - // The input cardinality bounds a PromQL fold's rows (series), not - // the rows of a SQL group. + // The series count of the sources read bounds a PromQL fold's rows + // (series), not the rows of a SQL group. Reduction::Reduce(_) if reads_table(input) => Some(PropagationStats { row_count_statistic: Some(GROUP_ROW_COUNT), ..PropagationStats::default() diff --git a/crates/asap-aware-mapping/src/physical_operator_statistics.rs b/crates/asap-aware-mapping/src/physical_operator_statistics.rs index 288c8e1e2..5a0706e32 100644 --- a/crates/asap-aware-mapping/src/physical_operator_statistics.rs +++ b/crates/asap-aware-mapping/src/physical_operator_statistics.rs @@ -71,7 +71,7 @@ impl ComparisonScope { pub fn validate(&self) -> Result { if !matches!( self.data_arrival, - DataArrival::AtRest | DataArrival::ContinuouslyIngesting + DataArrival::AtRest | DataArrival::Ingesting { .. } ) { return Err(AnalyticalCostError::UnsupportedDataArrival( self.data_arrival, diff --git a/crates/asap-aware-mapping/src/query_physical_lowering.rs b/crates/asap-aware-mapping/src/query_physical_lowering.rs index f0978dc6d..69a67fe1d 100644 --- a/crates/asap-aware-mapping/src/query_physical_lowering.rs +++ b/crates/asap-aware-mapping/src/query_physical_lowering.rs @@ -2867,8 +2867,8 @@ mod tests { ) { use crate::physical_operator_statistics::ScalarInputStatistics; use asap_types::workload::{ - BatchEntry, DataWorkload, Evidence, PlanningWorkload, Predictability, Query, - QueryLanguage, QueryRequirements, QueryWorkload, + BatchEntry, DataWorkload, PlanningWorkload, Predictability, Query, QueryLanguage, + QueryRequirements, QueryWorkload, }; use std::cell::RefCell; @@ -2887,15 +2887,12 @@ mod tests { }]), repeating_queries: None, }, - data_workload: Some(DataWorkload { - data_ingestion_interval: Evidence { - value: Some(DurationMs(1_000)), - ..Default::default() - }, - ..Default::default() - }), + data_workload: Some(DataWorkload::sampled_every( + asap_frontend_promql::promql_metric_names(query).unwrap_or_default(), + DurationMs(1_000), + )), }; - let root = asap_frontend_promql::lower_promql_workload(&workload, 0) + let root = asap_frontend_promql::lower_promql_workload(&workload) .unwrap() .remove(0); // Read one node through every reference (as a planner that shares diff --git a/crates/asap-aware-mapping/src/recurrence.rs b/crates/asap-aware-mapping/src/recurrence.rs index 21e0d6a33..0cd36cd35 100644 --- a/crates/asap-aware-mapping/src/recurrence.rs +++ b/crates/asap-aware-mapping/src/recurrence.rs @@ -79,8 +79,8 @@ //! for a whole workload). A one-shot ([`asap_types::workload::BatchEntry`]) //! consumer contributes to [`RecurrenceProfile::one_shot_consumers`] //! instead, never to this rate. -//! - [`UpdateRate`]: read from workload-level -//! [`asap_types::workload::DataWorkload::ingestion_rate`] via +//! - [`UpdateRate`]: read from the workload-level arrival's +//! [`asap_types::workload::DataArrival::rows_per_second`] via //! [`update_rate_from_data_workload`]. Missing evidence remains unknown; //! data at rest is not assigned a fabricated update rate. //! - `maintenance_cost_per_update` / `summary_read_cost` / @@ -112,7 +112,7 @@ use crate::cost_model::{Cost, CostModel, CseCandidate, ShareDecision}; /// How often the *raw* data underlying a maintained summary changes — /// ingest/update events per second (Hz). See the module docs' provenance -/// table: normally read from [`DataWorkload::ingestion_rate`] via +/// table: normally read from the arrival's `rows_per_second` via /// [`update_rate_from_data_workload`]. #[derive(Debug, Clone, Copy, PartialEq, PartialOrd)] pub struct UpdateRate(pub f64); @@ -295,9 +295,9 @@ pub fn update_rate_from_data_workload( workload: &DataWorkload, ) -> Result, RecurrenceError> { workload - .ingestion_rate - .value - .map(|rate| validate_update_rate(UpdateRate(rate.0))) + .arrival + .rows_per_second() + .map(|rate| validate_update_rate(UpdateRate(rate))) .transpose() } @@ -323,7 +323,7 @@ pub struct RecurrenceProfile { pub one_shot_consumers: usize, /// The ingest/update rate of the raw data this target (if maintained) /// would be kept up to date against. `None` when no - /// [`DataWorkload::ingestion_rate`] evidence was available. + /// arrival rate (`DataArrival::rows_per_second`) was declared. pub update_rate: Option, } @@ -674,11 +674,10 @@ mod tests { // ── update_rate_from_data_workload ─────────────────────────────────── #[test] - fn update_rate_from_data_workload_reads_ingestion_rate_evidence() { + fn update_rate_from_data_workload_reads_the_arrival_rate() { let workload = DataWorkload { - ingestion_rate: asap_types::workload::Evidence { - value: Some(asap_types::workload::Rate(100.0)), - ..Default::default() + arrival: asap_types::workload::DataArrival::Ingesting { + rows_per_second: 100.0, }, ..Default::default() }; @@ -689,9 +688,8 @@ mod tests { #[test] fn update_rate_from_data_workload_rejects_a_negative_rate() { let workload = DataWorkload { - ingestion_rate: asap_types::workload::Evidence { - value: Some(asap_types::workload::Rate(-0.1)), - ..Default::default() + arrival: asap_types::workload::DataArrival::Mixed { + rows_per_second: -0.1, }, ..Default::default() }; diff --git a/crates/asap-aware-mapping/src/replacement.rs b/crates/asap-aware-mapping/src/replacement.rs index df8dc43c4..d557ae0f3 100644 --- a/crates/asap-aware-mapping/src/replacement.rs +++ b/crates/asap-aware-mapping/src/replacement.rs @@ -4455,8 +4455,8 @@ impl CandidateLogicalASAPDAGs { recurrences.push(recurrence); } let update_rate = data_workload - .and_then(|data| data.ingestion_rate.value_at(now_ms)) - .map(|rate| UpdateRate(rate.0)); + .and_then(|data| data.arrival.rows_per_second()) + .map(UpdateRate); self.recurrence_profiles(&recurrences, update_rate) } } diff --git a/crates/asap-aware-mapping/src/stage2_physical_asap_optimization.rs b/crates/asap-aware-mapping/src/stage2_physical_asap_optimization.rs index 3682609e2..b186e37d6 100644 --- a/crates/asap-aware-mapping/src/stage2_physical_asap_optimization.rs +++ b/crates/asap-aware-mapping/src/stage2_physical_asap_optimization.rs @@ -363,7 +363,7 @@ fn ingestion_eligibility( use IngestionPruneReason as Why; if !matches!( data.arrival, - DataArrival::ContinuouslyIngesting | DataArrival::Mixed + DataArrival::Ingesting { .. } | DataArrival::Mixed { .. } ) { return Err(Why::NotIngesting); } @@ -396,7 +396,7 @@ fn ingestion_eligibility( // Assumption (module doc): continuously ingesting PromQL data // arrives in time order per series, as Prometheus assumes. let ordered = - data.arrival == DataArrival::ContinuouslyIngesting && reads_only_time_series(node); + matches!(data.arrival, DataArrival::Ingesting { .. }) && reads_only_time_series(node); if !ordered { return Err(Why::CounterNeedsTimeOrder); } @@ -981,7 +981,9 @@ mod tests { fn arriving() -> DataWorkload { DataWorkload { - arrival: DataArrival::ContinuouslyIngesting, + arrival: DataArrival::Ingesting { + rows_per_second: 1_000.0, + }, ..Default::default() } } @@ -1109,7 +1111,9 @@ mod tests { }; let table = QueryRoot::Operator(to_table(node)); let mixed = DataWorkload { - arrival: DataArrival::Mixed, + arrival: DataArrival::Mixed { + rows_per_second: 1_000.0, + }, ..Default::default() }; for (root, data) in [(table, arriving()), (root.clone(), mixed)] { diff --git a/crates/asap-aware-mapping/src/stage3_plan_selection.rs b/crates/asap-aware-mapping/src/stage3_plan_selection.rs index e3d3eab4c..2e4163371 100644 --- a/crates/asap-aware-mapping/src/stage3_plan_selection.rs +++ b/crates/asap-aware-mapping/src/stage3_plan_selection.rs @@ -128,7 +128,9 @@ use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use std::rc::Rc; -use asap_types::ir::operator_properties::BinaryOpKind; +use asap_types::ir::operator_properties::{ + BinaryOpKind, PromQLVectorSetOpKind, RelationalSetOpKind, +}; use asap_types::ir::physical_export::compile_physical_asap_workload_with_node_ids; use asap_types::ir::physical_export::{ PhysicalASAPDAG, PhysicalASAPNodeId, PhysicalASAPOperatorPayload as Payload, @@ -142,7 +144,7 @@ use asap_types::post_asap::{ AccuracyGuarantee, AccuracyMetric, CompositionOperator, ExecutionTiming, FieldDataType, Materialization, Medium, SketchAlgorithm, SketchParams, SketchStatistic, WeightDomain, }; -use asap_types::pre_asap::{AggIntent, DataType, Reduction, ScalarValue, Schema}; +use asap_types::pre_asap::{AggIntent, DataType, Reduction, ScalarValue, Schema, Source}; use asap_types::types::{AccuracyScope, AccuracyTarget}; use asap_types::workload::{ DataWorkload, MetricType, QueryRecurrence, QueryWorkloadEntry, RepeatedDemand, @@ -896,32 +898,147 @@ fn describe_usage(usage: &ResourceUsage) -> String { ) } -/// Without a planning time, only evidence that cannot expire is fresh. -const ANY_TIME: u64 = u64::MAX; +/// A scan source's name: its PromQL metric or SQL table. +fn source_name(source: &Source) -> &str { + match source { + Source::TimeSeries { metric } => metric, + Source::Table { table_ref } => table_ref, + } +} + +/// An upper bound on the rows one node and the operators below it see or +/// return per evaluation, in terms of the sources they read: +/// `copies_per_source[s]` times the series of `s`, plus `constant_rows`. +#[derive(Debug, Clone, Default, PartialEq)] +struct RowBound { + copies_per_source: BTreeMap, + constant_rows: u64, +} + +impl RowBound { + /// The bound of a node that returns at most as many rows as its largest + /// input, source by source: a fold, a join, a filter. + fn largest(inputs: &[RowBound]) -> Self { + let mut bound = RowBound::default(); + for input in inputs { + for (source, copies) in &input.copies_per_source { + let entry = bound.copies_per_source.entry(source.clone()).or_default(); + *entry = (*entry).max(*copies); + } + bound.constant_rows = bound.constant_rows.max(input.constant_rows); + } + bound + } + + /// The bound of a union, which returns every row of each input. + fn sum(inputs: &[RowBound]) -> Self { + let mut bound = RowBound::default(); + for input in inputs { + for (source, copies) in &input.copies_per_source { + *bound.copies_per_source.entry(source.clone()).or_default() += copies; + } + bound.constant_rows += input.constant_rows; + } + bound + } +} + +/// [`RowBound`] of `node`: a scan returns its source's series once; a union +/// (PromQL `or`, SQL `UNION`, `Concat`) the rows of all its branches; a +/// constant vector one row and `VALUES` its rows; any other operator at +/// most as many as its largest input. `memo` holds the shared nodes' bounds. +fn row_bound( + node: &Rc, + memo: &mut HashMap<*const OperatorNode, RowBound>, +) -> RowBound { + if let Some(bound) = memo.get(&Rc::as_ptr(node)) { + return bound.clone(); + } + let inputs: Vec = node + .children() + .into_iter() + .map(|child| row_bound(child, memo)) + .collect(); + let bound = match node.non_asap() { + Some(NonASAPOp::Scan { source, .. }) => RowBound { + copies_per_source: [(source_name(source).to_string(), 1)].into(), + constant_rows: 0, + }, + Some(NonASAPOp::Values { rows, .. }) => RowBound { + constant_rows: rows.len() as u64, + ..RowBound::largest(&inputs) + }, + // One row out, but the operators below still read their sources' + // rows, which the bound keeps; `at_most_one_row` makes `G` of a + // `vector()` root 1. + Some(NonASAPOp::PromqlVectorFromScalar(_)) => { + let mut bound = RowBound::largest(&inputs); + bound.constant_rows = bound.constant_rows.max(1); + bound + } + Some( + NonASAPOp::SetOp { + kind: RelationalSetOpKind::Union, + .. + } + | NonASAPOp::Concat { .. }, + ) => RowBound::sum(&inputs), + Some(NonASAPOp::BinaryOp { operator, .. }) + if operator.kind == BinaryOpKind::Set(PromQLVectorSetOpKind::Or) => + { + RowBound::sum(&inputs) + } + _ => RowBound::largest(&inputs), + }; + memo.insert(Rc::as_ptr(node), bound.clone()); + bound +} + +/// An upper bound on the rows any operator of `root` sees or returns in one +/// evaluation ([`row_bound`]), from the series count each source it reads +/// declares: the input row count of its folds, and `G` for a PromQL +/// instant vector ([`values_per_result`]). A join or fold of one source +/// counts it once; an `or` counts each branch. `None` when `root` reads no +/// source, or a source it reads declares no series count. +fn series_read(root: &QueryRoot, data: &DataWorkload) -> Option { + let mut memo = HashMap::new(); + let inputs: Vec = root_operators(root) + .into_iter() + .map(|node| row_bound(node, &mut memo)) + .collect(); + let bound = RowBound::largest(&inputs); + if bound.copies_per_source.is_empty() { + return None; + } + let mut rows = bound.constant_rows; + for (source, copies) in &bound.copies_per_source { + rows += copies * data.sources.get(source)?.series_count?; + } + Some(rows) +} /// How many values one evaluation of `root` returns at most (`G`): one for /// a scalar root and for a root of at most one row ([`at_most_one_row`]), -/// provable from the query; otherwise, from `data`, at most the input -/// cardinality (the series) for a PromQL instant vector, whose groups and -/// series never outnumber its input series. `None` when unbound: any other -/// SQL result, until a bound on the groups of its source is an input, and a -/// range vector. -fn values_per_result(root: &QueryRoot, data: &DataWorkload) -> Option { +/// provable from the query; otherwise `series_read` ([`series_read`]) for +/// a PromQL instant vector, which bounds the values it can return: each +/// branch of an `or` adds its own. `None` when unbound: `series_read` is, +/// any other SQL result (until a bound on the groups of its source is an +/// input), and a range vector. +fn values_per_result(root: &QueryRoot, series_read: Option) -> Option { match root { QueryRoot::Scalar(_) => Some(1), QueryRoot::Operator(node) if at_most_one_row(node) => Some(1), - QueryRoot::Operator(node) if node.result_kind == OperatorResultKind::InstantVector => data - .input_cardinality - .value_at(ANY_TIME) - .map(|series| (*series).max(1)), + QueryRoot::Operator(node) if node.result_kind == OperatorResultKind::InstantVector => { + series_read.map(|series| series.max(1)) + } QueryRoot::Operator(_) => None, } } /// Whether `node` returns at most one row whatever its input: a global -/// aggregate or summary (a reduction with no group keys), and above one -/// only operators that keep or drop its rows, or join two such inputs -/// without adding unmatched rows of both sides. +/// aggregate or summary (a reduction with no group keys), `vector(s)`, and +/// above one only operators that keep or drop its rows, or join two such +/// inputs without adding unmatched rows of both sides. fn at_most_one_row(node: &OperatorNode) -> bool { let global = |reduction: &asap_types::pre_asap::Reduction| { reduction @@ -953,6 +1070,8 @@ fn at_most_one_row(node: &OperatorNode) -> bool { query, }) => !matches!(query, SketchStatistic::TopK { .. }) && at_most_one_row(summary_input), Operator::ASAP(ASAPOp::FinalizeExactAccumulator { child }) => at_most_one_row(child), + // `vector(s)` turns one scalar into one row. + Operator::NonASAP(NonASAPOp::PromqlVectorFromScalar(_)) => true, Operator::NonASAP( NonASAPOp::Project { child, .. } | NonASAPOp::Filter { child, .. } @@ -1046,7 +1165,7 @@ pub fn resize_for_failure_probability( }; let values = match target.accuracy_scope { AccuracyScope::PerValue => Some(1), - AccuracyScope::WholeResult => values_per_result(root, data), + AccuracyScope::WholeResult => values_per_result(root, series_read(root, data)), }; let Some(values) = values else { continue }; let inputs = approximate_inputs(root); @@ -1067,16 +1186,11 @@ pub fn resize_for_failure_probability( relative_error, } => { let source = distinct_bound_source(state)?; - let bound = data.max_distinct_per_evaluation.value_at(ANY_TIME)?; + let bound = data.sources.get(&source)?.max_distinct_per_evaluation?; // The guarantee keeps the relative error Pass 1 sized for. let relative_error = hll::classic_relative_error(*precision, *relative_error); SketchParams::Hll { - precision: hll::min_precision( - relative_error, - delta, - *bound.get(&source)?, - *precision, - ), + precision: hll::min_precision(relative_error, delta, bound, *precision), relative_error: Some(relative_error), } } @@ -1150,12 +1264,13 @@ fn accuracy_check( } // A guarantee per value is stated over the whole result's `G` // values for a whole-result target. - let values = values_per_result(root, data); + let series = series_read(root, data); + let values = values_per_result(root, series); let over_scope = |g: AccuracyGuarantee| match target.accuracy_scope { AccuracyScope::WholeResult => g.over_whole_result(values), AccuracyScope::PerValue => g, }; - let guarantee = bound_root_guarantee(root, data, models).map(over_scope); + let guarantee = bound_root_guarantee(root, series, data, models).map(over_scope); let meets = |g: &AccuracyGuarantee| models.accuracy.satisfies(g, target); let met = guarantee.clone().filter(meets).or_else(|| { models @@ -1181,8 +1296,9 @@ fn accuracy_check( { format!( "q{query}: the output's guarantee holds for each value, and the number \ - of values one evaluation returns is not bound (no declared input \ - cardinality for a PromQL vector; no bound on a SQL result's groups yet), \ + of values one evaluation returns is not bound (no declared series count \ + for a source a PromQL vector reads; no bound on a SQL result's groups \ + yet), \ so it does not meet whole-result target {target:?}, and the deployment \ has no evidence that it meets it" ) @@ -1208,7 +1324,15 @@ fn accuracy_check( /// answered from `data`'s declared metric types: proven when the metric is /// declared a counter, unknown otherwise. No type is inferred from a name. pub fn confirmed_weights(domain: &WeightDomain, data: &DataWorkload) -> WeightDomain { - domain.confirmed(|metric| data.metric_types.get(metric) == Some(&MetricType::Counter)) + domain.confirmed(|metric| declared_counter(data, metric)) +} + +/// Whether `data` declares `metric` a counter. A declared type does not +/// expire, so its evidence is not checked. +fn declared_counter(data: &DataWorkload, metric: &str) -> bool { + data.sources + .get(metric) + .is_some_and(|facts| facts.metric_type == Some(MetricType::Counter)) } /// `roots` with every open [`WeightDomain::SamplesOfMetric`] requirement @@ -1273,7 +1397,7 @@ fn root_operators(root: &QueryRoot) -> Vec<&Rc> { } /// [`root_guarantee`] with the statistics Stage 1 left symbolic bound: -/// - the input row count, from the data workload's input cardinality; +/// - the input row count, `series` ([`series_read`] of `root`); /// - each source's stream norm ([`source_norm_statistic`]), from the data /// workload's per-source `stream_norms`; /// - classic HLL's failure probability and the chance a UnivMon's top layer @@ -1282,20 +1406,20 @@ fn root_operators(root: &QueryRoot) -> Vec<&Rc> { /// - a product's or quotient's bound, from the deployment's enforced /// operand domains ([`operand_domain_bounds`]). /// -/// Data-workload evidence is used only while fresh. Stage 3 has no planning -/// time and, by design, stays conservative: it uses only evidence that -/// holds at any planning time (no validity window); evidence with a -/// validity window is never used. Any other statistic stays unknown, and -/// an unknown statistic never meets a target. +/// Every data-workload fact is declared: the caller drops facts that no +/// longer hold before planning. Any other statistic stays unknown, and an +/// unknown statistic never meets a target. fn bound_root_guarantee( root: &QueryRoot, + series: Option, data: &DataWorkload, models: &PlanningModels<'_>, ) -> Option { let guarantee = root_guarantee(root)?; - let domains = operand_domain_bounds(root, data, models); + let domains = operand_domain_bounds(root, series, data, models); Some(bind_statistics(&guarantee, &|statistic| { - workload_statistic(statistic, data).or_else(|| domains.get(statistic).copied().flatten()) + workload_statistic(statistic, series, data) + .or_else(|| domains.get(statistic).copied().flatten()) })) } @@ -1316,45 +1440,41 @@ fn bind_statistics( }) } -/// A data-workload statistic Stage 1 left symbolic: the input row count, -/// one source's stream norm, or classic HLL's failure probability or a -/// UnivMon's incomplete-top-layer probability given one source's distinct -/// bound, from evidence that holds at any planning time, or the sign of -/// one metric's samples, from its declared type. -fn workload_statistic(statistic: &str, data: &DataWorkload) -> Option { +/// A data-workload statistic Stage 1 left symbolic: the input row count +/// (`series`, the series of the sources the query reads), one source's +/// stream norm, or classic HLL's failure probability or a UnivMon's +/// incomplete-top-layer probability given one source's distinct bound, +/// from source facts that hold at any planning time, or the sign of one +/// metric's samples, from its declared type. +fn workload_statistic(statistic: &str, series: Option, data: &DataWorkload) -> Option { // The sign of one metric's samples: proven (1) when the metric is // declared a counter; no type is inferred from its name. if let Some(metric) = parse_samples_sign_statistic(statistic) { - return (data.metric_types.get(metric) == Some(&MetricType::Counter)).then_some(1.0); + return declared_counter(data, metric).then_some(1.0); } if statistic == INPUT_ROW_COUNT { - return data - .input_cardinality - .value_at(ANY_TIME) - .map(|rows| *rows as f64); + return series.map(|rows| rows as f64); } if let Some((precision, relative_error, source)) = hll::parse_classic_failure_statistic(statistic) { - let bound = data.max_distinct_per_evaluation.value_at(ANY_TIME)?; + let bound = data.sources.get(source)?.max_distinct_per_evaluation?; return Some(hll::classic_failure_probability( precision, relative_error, - *bound.get(source)?, + bound, )); } if let Some((heap_size, layers, source)) = univmon::parse_incomplete_top_layer_statistic(statistic) { - let bound = data.max_distinct_per_evaluation.value_at(ANY_TIME)?; + let bound = data.sources.get(source)?.max_distinct_per_evaluation?; return Some(univmon::incomplete_top_layer_probability( - heap_size, - layers, - *bound.get(source)?, + heap_size, layers, bound, )); } let (norm, source) = parse_source_norm_statistic(statistic)?; - let norms = data.stream_norms.value_at(ANY_TIME)?.get(source)?; + let norms = data.sources.get(source)?.stream_norms?; match norm { STREAM_L1_NORM => norms.l1, _ => norms.l2, @@ -1408,9 +1528,11 @@ struct DomainOperation<'a> { /// among the operations that leave it symbolic, or `None` (unbound) when /// any of them has no bound. Operations are found where the bound /// originates (the same rule Stage 1 uses), in vector arithmetic, a scalar -/// root, and projection expressions. +/// root, and projection expressions. `series` is the root's input row +/// count ([`series_read`]). fn operand_domain_bounds( root: &QueryRoot, + series: Option, data: &DataWorkload, models: &PlanningModels<'_>, ) -> HashMap<&'static str, Option> { @@ -1458,7 +1580,7 @@ fn operand_domain_bounds( } let mut bounds: HashMap<&'static str, Option> = HashMap::new(); for operation in &operations { - let bound = domain_bound(operation, data, models); + let bound = domain_bound(operation, series, data, models); let entry = bounds.entry(operation.statistic).or_insert(Some(0.0)); *entry = entry.zip(bound).map(|(a, b)| a.max(b)); } @@ -1499,16 +1621,19 @@ fn scalar_domain_operations<'a>( /// /// `|x|`, `|y|` are the domains' largest magnitudes, and `e` each /// operand's absolute error (a relative or cardinality bound times that -/// magnitude) after binding its data-workload statistics. `None` when an -/// operand has no domain or an unbound error. +/// magnitude) after binding its data-workload statistics (`series`, the +/// root's input row count). `None` when an operand has no domain or an +/// unbound error. fn domain_bound( operation: &DomainOperation<'_>, + series: Option, data: &DataWorkload, models: &PlanningModels<'_>, ) -> Option { let [(x, gx), (y, gy)] = &operation.operands; let (dx, dy) = (x.domain(models)?, y.domain(models)?); - let bind = |g: &AccuracyGuarantee| g.bind(&|statistic| workload_statistic(statistic, data)); + let bind = + |g: &AccuracyGuarantee| g.bind(&|statistic| workload_statistic(statistic, series, data)); let (gx, gy) = (bind(gx), bind(gy)); if operation.statistic == DIVISION_OPERAND_DOMAINS { let stats = PropagationStats { @@ -1566,10 +1691,23 @@ fn family_name(family: &FieldDataType) -> String { } } -/// Statistics the analytical model needs, derived once per workload. -struct Shape { - series: u64, - rows_per_ms: f64, +/// The series pricing assumes a node reading `sources` sees: the sum of +/// their declared series counts, [`DEFAULT_SERIES`] for each source +/// without one and for a node that reads none. +fn priced_series(sources: &BTreeSet<&str>, data: &DataWorkload) -> u64 { + if sources.is_empty() { + return DEFAULT_SERIES; + } + sources + .iter() + .map(|source| { + data.sources + .get(*source) + .and_then(|facts| facts.series_count) + .unwrap_or(DEFAULT_SERIES) + }) + .sum::() + .max(1) } /// Evaluations per second of a node computed once for `queries` together. @@ -1746,19 +1884,13 @@ fn price( CostModel::UsageOverTime(prices) => Some(prices), _ => None, }; - let series = data - .input_cardinality - .value - .unwrap_or(DEFAULT_SERIES) - .max(1); - let shape = Shape { - series, - rows_per_ms: data - .ingestion_rate - .value - .map_or(DEFAULT_ROWS_PER_SECOND, |rate| rate.0) - / 1_000.0, - }; + let rows_per_ms = data + .arrival + .rows_per_second() + .unwrap_or(DEFAULT_ROWS_PER_SECOND) + / 1_000.0; + // The sources each node reads, filled children first. + let mut sources_read_by: HashMap> = HashMap::new(); let nodes: HashMap<_, _> = dag.nodes.iter().map(|n| (n.id, n)).collect(); let multipliers = computations( dag, @@ -1780,14 +1912,24 @@ fn price( // Each in-memory ingestion-time state's bytes per window (what it keeps // for one window, its output or an exact aggregate's values). let mut maintained: Vec<(PhysicalASAPNodeId, u64)> = Vec::new(); + // Each node's producers, in edge order. + let mut producers_of: HashMap> = HashMap::new(); + for e in &dag.edges { + producers_of.entry(e.consumer).or_default().push(e.producer); + } for node in &dag.nodes { let ingestion = node.output_state.timing == ExecutionTiming::IngestionTime; - let inputs: Vec<_> = dag - .edges - .iter() - .filter(|e| e.consumer == node.id) - .map(|e| output[&e.producer]) - .collect(); + let producers = producers_of.get(&node.id).map_or(&[][..], Vec::as_slice); + let inputs: Vec<_> = producers.iter().map(|p| output[p]).collect(); + let sources: BTreeSet<&str> = match &node.payload { + Payload::NonASAP(NonASAPOp::Scan { source, .. }) => [source_name(source)].into(), + _ => producers + .iter() + .flat_map(|p| sources_read_by[p].iter().copied()) + .collect(), + }; + let series = priced_series(&sources, data); + sources_read_by.insert(node.id, sources); let input = inputs .first() .copied() @@ -1806,7 +1948,7 @@ fn price( let groups = match reduction { Reduction::Reduce(keys) if keys.keys().is_empty() && !keys.is_without() => 1, Reduction::Reduce(keys) if !keys.is_without() => DEFAULT_GROUP_COUNT, - _ => shape.series, + _ => series, }; // One second of arriving rows does not bound the groups a // maintained state holds. @@ -1825,7 +1967,7 @@ fn price( .max() .unwrap_or(DEFAULT_LOOKBACK_MS); let lookback = if ingestion { 1_000 } else { lookback }; - let out = edge(((shape.rows_per_ms * lookback as f64).round() as u64).max(1)); + let out = edge(((rows_per_ms * lookback as f64).round() as u64).max(1)); let estimate = estimate_operator( PhysicalOperator::Scan, OperatorStatistics::Scan { @@ -1935,9 +2077,9 @@ fn price( // selector the latest sample of each series. let kept = match kind { TimeRangeKind::Range => { - (shape.rows_per_ms * range.as_millis() as f64).round() as u64 + (rows_per_ms * range.as_millis() as f64).round() as u64 } - TimeRangeKind::Instant => shape.series, + TimeRangeKind::Instant => series, }; let out = edge(input.rows.min(kept.max(1))); let out = EdgeStatistics { @@ -2023,12 +2165,11 @@ fn price( Payload::ASAP(ASAPOp::SummaryMerge { .. }) => { // One operation per input state; one state per group out, // each pane of a chain being a state of its group. - let producers = dag.edges.iter().filter(|e| e.consumer == node.id); let mut states = 0; let mut group_count = 0; - for edge in producers { - let rows = output[&edge.producer].rows; - let panes = match &nodes[&edge.producer].payload { + for producer in producers { + let rows = output[producer].rows; + let panes = match &nodes[producer].payload { Payload::ASAP(ASAPOp::PaneChain { pane_count, .. }) => { u64::from(*pane_count) } @@ -2570,7 +2711,21 @@ mod tests { }; use asap_types::pre_asap::expr_ir::ArithmeticOpKind; use asap_types::types::AccuracyTarget; - use asap_types::workload::{Evidence, Rate}; + use asap_types::workload::{DataArrival, MetricType, SourceFacts}; + + /// `G` of `root` over `data` ([`values_per_result`]). + fn values_of(root: &QueryRoot, data: &DataWorkload) -> Option { + values_per_result(root, series_read(root, data)) + } + + /// [`bound_root_guarantee`] of `root` over `data`. + fn bound_of( + root: &QueryRoot, + data: &DataWorkload, + models: &PlanningModels<'_>, + ) -> Option { + bound_root_guarantee(root, series_read(root, data), data, models) + } /// `n` queries repeating every 10 s, as Example 1's panels do. fn recurrences(n: usize) -> Vec { @@ -2584,36 +2739,62 @@ mod tests { )) } + /// The metrics and tables the tests' queries read. + const TEST_SOURCES: [&str; 5] = ["m", "x", "a", "b", "t"]; + + /// Data arriving at 10 000 rows per second, each test source holding + /// 10 000 series. fn data() -> DataWorkload { + with_series(Some(10_000)) + } + + /// [`data`] with every test source holding `series` (`None`: unknown). + fn with_series(series: Option) -> DataWorkload { DataWorkload { - ingestion_rate: Evidence { - value: Some(Rate(10_000.0)), - ..Default::default() + arrival: DataArrival::Ingesting { + rows_per_second: 10_000.0, }, - input_cardinality: Evidence { - value: Some(10_000), - ..Default::default() - }, - ..Default::default() + sources: TEST_SOURCES + .iter() + .map(|source| { + let facts = SourceFacts { + series_count: series, + ..SourceFacts::default() + }; + (source.to_string(), facts) + }) + .collect(), } } + /// `data` with each of `types`' metrics declared of its type. + fn with_metric_types(mut data: DataWorkload, types: &[(&str, MetricType)]) -> DataWorkload { + for (metric, metric_type) in types { + data.sources + .entry(metric.to_string()) + .or_default() + .metric_type = Some(*metric_type); + } + data + } + /// Classic HLL's failure probability over a source binds only that /// source's distinct bound: a bound keyed to another source leaves it /// unknown. #[test] fn classic_hll_failure_probability_binds_only_its_own_sources_bound() { let statistic = hll::classic_failure_statistic(15, 0.05, "a"); - let bounded = |source: &str| DataWorkload { - max_distinct_per_evaluation: Evidence { - value: Some(std::collections::BTreeMap::from([(source.into(), 100)])), - ..Default::default() - }, - ..data() + let bounded = |source: &str| { + let mut data = data(); + data.sources + .get_mut(source) + .unwrap() + .max_distinct_per_evaluation = Some(100); + data }; - assert_eq!(workload_statistic(&statistic, &bounded("b")), None); + assert_eq!(workload_statistic(&statistic, None, &bounded("b")), None); assert_eq!( - workload_statistic(&statistic, &bounded("a")), + workload_statistic(&statistic, None, &bounded("a")), Some(hll::classic_failure_probability(15, 0.05, 100)) ); assert!(hll::classic_failure_probability(15, 0.05, 100) <= 0.01); @@ -2734,10 +2915,7 @@ mod tests { fn count_min_over_samples_needs_a_declared_counter() { use asap_types::workload::MetricType; let target = AccuracyTarget::max_error(0.01).failure_probability(0.001); - let with_types = |types: &[(&str, MetricType)]| DataWorkload { - metric_types: types.iter().map(|(m, t)| (m.to_string(), *t)).collect(), - ..data() - }; + let with_types = |types: &[(&str, MetricType)]| with_metric_types(data(), types); for (types, admitted) in [ (vec![("m", MetricType::Counter)], true), (vec![("m", MetricType::Gauge)], false), @@ -2845,12 +3023,9 @@ mod tests { ); let node = lower_promql("sum(m)", AccuracyTarget::exact()); let root = QueryRoot::Operator(Rc::new((*node).clone().with_guarantee(Some(gated)))); - let with = |types: &[(&str, MetricType)]| DataWorkload { - metric_types: types.iter().map(|(m, t)| (m.to_string(), *t)).collect(), - ..data() - }; + let with = |types: &[(&str, MetricType)]| with_metric_types(data(), types); let bound = |data: &DataWorkload| { - bound_root_guarantee(&root, data, &PlanningModels::builtin()) + bound_of(&root, data, &PlanningModels::builtin()) .unwrap() .spec .error @@ -2876,14 +3051,7 @@ mod tests { let target = AccuracyTarget::max_error(0.01).failure_probability(0.001); let root = lower_promql("topk by (job) (10, sum_over_time(m[1m]))", target.clone()); let root = asap_types::ir::schema_support::with_promql_series_identity(&root).unwrap(); - let data = DataWorkload { - metric_types: [("m".to_string(), MetricType::Counter)].into(), - input_cardinality: Evidence { - value: Some(10_000_000), - ..Default::default() - }, - ..data() - }; + let data = with_metric_types(with_series(Some(10_000_000)), &[("m", MetricType::Counter)]); let run = crate::pass::run_stage1_to_stage3( vec![(0, QueryRoot::Operator(root))], &[entry_with(target, repeating(10_000))], @@ -2938,10 +3106,7 @@ mod tests { domains(&p2.roots), [WeightDomain::SamplesOfMetric { metric: "m".into() }] ); - let counter = DataWorkload { - metric_types: [("m".to_string(), MetricType::Counter)].into(), - ..data() - }; + let counter = with_metric_types(data(), &[("m", MetricType::Counter)]); let confirmed = with_confirmed_weights(&p2.roots, &counter); assert_eq!( domains(&confirmed), @@ -3239,20 +3404,17 @@ mod tests { } /// Without a bound on the values one evaluation returns, a per-value - /// guarantee does not meet a whole-result target: a PromQL vector with - /// no declared input cardinality, and a SQL relation (until a bound on + /// guarantee does not meet a whole-result target: a PromQL vector whose + /// source declares no series count, and a SQL relation (until a bound on /// its groups is an input). A scalar returns one value. #[test] fn an_unbound_number_of_values_leaves_a_whole_result_target_unmet() { let (candidates, whole) = per_series_kll(AccuracyScope::WholeResult); - let no_cardinality = DataWorkload { - input_cardinality: Evidence::default(), - ..data() - }; + let no_series_count = with_series(None); let Err(SelectionError::NoValidCandidate(rejected)) = select_plan( &candidates, &[entry_with(whole, repeating(10_000))], - &no_cardinality, + &no_series_count, PlanningModels::builtin(), ) else { panic!("the KLL candidates are invalid") @@ -3272,12 +3434,9 @@ mod tests { false, )]), ); + assert_eq!(values_of(&QueryRoot::Operator(table), &data()), None); assert_eq!( - values_per_result(&QueryRoot::Operator(table), &data()), - None - ); - assert_eq!( - values_per_result( + values_of( &QueryRoot::Scalar(ScalarExpr::literal_f64(1.0)), &DataWorkload::default() ), @@ -3289,23 +3448,17 @@ mod tests { /// query with no data workload: a global aggregate, PromQL or SQL, and /// what selects, renames or joins single rows above it. A grouped SQL /// aggregate stays unbound, and a grouped PromQL one, or a global top-k - /// (k rows), is bound by the input cardinality. + /// (k rows), is bound by the series count of the source it reads. #[test] fn a_root_of_at_most_one_row_returns_one_value() { let none = DataWorkload::default(); let root = |query: &str| QueryRoot::Operator(lower_promql(query, AccuracyTarget::max_error(0.01))); - assert_eq!(values_per_result(&root("sum(rate(x[5m]))"), &none), Some(1)); - assert_eq!( - values_per_result(&root("topk(5, rate(x[5m]))"), &none), - None - ); + assert_eq!(values_of(&root("sum(rate(x[5m]))"), &none), Some(1)); + assert_eq!(values_of(&root("topk(5, rate(x[5m]))"), &none), None); + assert_eq!(values_of(&root("sum by (job) (rate(x[5m]))"), &none), None); assert_eq!( - values_per_result(&root("sum by (job) (rate(x[5m]))"), &none), - None - ); - assert_eq!( - values_per_result(&root("sum by (job) (rate(x[5m]))"), &data()), + values_of(&root("sum by (job) (rate(x[5m]))"), &data()), Some(10_000) ); let table = || { @@ -3325,13 +3478,10 @@ mod tests { let global = crate::test_support::agg(vec![], count(), table()); let grouped = crate::test_support::agg(vec![0], count(), table()); assert_eq!( - values_per_result(&QueryRoot::Operator(global.clone()), &none), + values_of(&QueryRoot::Operator(global.clone()), &none), Some(1) ); - assert_eq!( - values_per_result(&QueryRoot::Operator(grouped), &data()), - None - ); + assert_eq!(values_of(&QueryRoot::Operator(grouped), &data()), None); let cross = OperatorNode::new_shared(Operator::NonASAP(NonASAPOp::Join { kind: asap_types::ir::operator_properties::JoinKind::Cross, pred: asap_types::ir::Predicate(ScalarExpr::Literal(ScalarValue::Boolean(true))), @@ -3339,10 +3489,89 @@ mod tests { right: global, })) .unwrap(); + assert_eq!(values_of(&QueryRoot::Operator(cross), &none), Some(1)); + } + + /// `G` and the input row count are bound per query from the sources + /// it reads: another source's series do not count, a query over two + /// sources is bound by their sum, and one source without a series + /// count leaves it unbound. Pricing reads the same per-source series, with + /// the default for an undeclared source. + #[test] + fn values_per_result_is_bound_by_the_series_of_the_sources_read() { + let root = + |query: &str| QueryRoot::Operator(lower_promql(query, AccuracyTarget::max_error(0.01))); + let series = |declared: &[(&str, u64)]| DataWorkload { + sources: declared + .iter() + .map(|(source, series)| { + let facts = SourceFacts { + series_count: Some(*series), + ..SourceFacts::default() + }; + (source.to_string(), facts) + }) + .collect(), + ..DataWorkload::default() + }; + let x = root("sum by (job) (rate(x[5m]))"); + let both = root("sum by (job) (rate(x[5m])) + sum by (job) (rate(m[5m]))"); + assert_eq!(values_of(&x, &series(&[("x", 10)])), Some(10)); + assert_eq!(values_of(&x, &series(&[("m", 10)])), None); + let declared = series(&[("x", 10), ("m", 20)]); + assert_eq!(values_of(&x, &declared), Some(10)); + assert_eq!(values_of(&both, &declared), Some(30)); + assert_eq!(series_read(&both, &declared), Some(30)); + assert_eq!(values_of(&both, &series(&[("x", 10)])), None); + // Pricing sums the series of the sources a node reads; an + // undeclared source counts the default. + assert_eq!(priced_series(&["x", "m"].into(), &declared), 30); assert_eq!( - values_per_result(&QueryRoot::Operator(cross), &none), - Some(1) + priced_series(&["x", "y"].into(), &declared), + 10 + DEFAULT_SERIES ); + assert_eq!(priced_series(&BTreeSet::new(), &declared), DEFAULT_SERIES); + } + + /// `G` counts the values the root can return: an `or` returns the + /// values of both branches (here up to `series(x)` series plus the + /// groups of `x`), a constant vector one more, while a join or a fold + /// of one source counts it once. + #[test] + fn values_per_result_adds_the_branches_of_an_or() { + let root = + |query: &str| QueryRoot::Operator(lower_promql(query, AccuracyTarget::max_error(0.01))); + let data = with_series(Some(10)); + for (query, values) in [ + ("rate(x[5m]) or sum without (instance) (rate(x[5m]))", 20), + ("rate(x[5m]) or vector(0)", 11), + ("rate(x[5m]) or rate(m[5m])", 20), + ("(rate(x[5m]) or vector(0)) or vector(1)", 12), + ("sum by (job) (rate(x[5m]) or rate(x[5m]))", 20), + ("rate(x[5m]) / rate(x[5m])", 10), + ("sum without (instance) (rate(x[5m])) * rate(x[5m])", 10), + ("rate(x[5m]) + rate(m[5m])", 20), + ] { + assert_eq!(values_of(&root(query), &data), Some(values), "{query}"); + } + } + + /// `vector(s)` returns one row, but the operators below it still read + /// their sources' rows: the input row count of + /// `vector(scalar(max(rate(x[5m]))))` counts the 10 series the `max` + /// folds (plus the one constant row), never shrinking going up the tree, + /// while its `G` is 1. + #[test] + fn a_vector_of_a_scalar_keeps_the_rows_read_below_it() { + let root = + |query: &str| QueryRoot::Operator(lower_promql(query, AccuracyTarget::max_error(0.01))); + let data = with_series(Some(10)); + let vector = root("vector(scalar(max(rate(x[5m]))))"); + assert_eq!(series_read(&vector, &data), Some(11)); + assert_eq!(values_of(&vector, &data), Some(1)); + assert_eq!(values_of(&root("vector(1)"), &data), Some(1)); + let or = root("rate(x[5m]) or vector(scalar(max(rate(x[5m]))))"); + assert_eq!(series_read(&or, &data), Some(21)); } /// Deployment evidence that every output was measured exact. @@ -3375,10 +3604,7 @@ mod tests { /// the target's metric admits the sketches. #[test] fn a_bound_in_another_metric_misses_the_target() { - let counter = DataWorkload { - metric_types: [("m".to_string(), asap_types::workload::MetricType::Counter)].into(), - ..data() - }; + let counter = with_metric_types(data(), &[("m", MetricType::Counter)]); let target = AccuracyTarget::max_error(0.5) .failure_probability(0.5) .accuracy_metric(AccuracyMetric::ABSOLUTE_STREAM_L1); @@ -3693,13 +3919,7 @@ mod tests { #[test] fn exact_distinct_count_beats_a_sketch_over_small_groups() { let target = AccuracyTarget::max_error(0.01).failure_probability(0.01); - let one_series = DataWorkload { - input_cardinality: Evidence { - value: Some(1), - ..Default::default() - }, - ..data() - }; + let one_series = with_series(Some(1)); for (data, sketch_wins) in [(data(), false), (one_series, true)] { let candidates = keeping_state(all_candidates("distinct_over_time(x[5m])", &target)); let selection = select_plan( @@ -4050,8 +4270,9 @@ mod tests { } /// `max` folds one value per series, so its failure probability is - /// scaled by the symbolic input row count. Stage 3 binds it from the data - /// workload's input cardinality; without one, the target is unmet. + /// scaled by the symbolic input row count. Stage 3 binds it from the + /// series count of the source the query reads; without one, the target + /// is unmet. #[test] fn stage3_binds_the_input_row_count_from_the_data_workload() { let target = AccuracyTarget::max_error(0.01) @@ -4069,10 +4290,7 @@ mod tests { let ids = ddsketch(&candidates); assert!(!ids.is_empty()); assert!(ids.iter().all(|id| with.costs.contains_key(id))); - let unknown = DataWorkload { - input_cardinality: Evidence::default(), - ..data() - }; + let unknown = with_series(None); let (_, without) = run(query, &target, &unknown, PlanningModels::builtin()); assert!(ids.iter().all(|id| !without.costs.contains_key(id))); } @@ -4144,26 +4362,17 @@ mod tests { } fn norms(sources: &[(&str, f64)]) -> DataWorkload { - DataWorkload { - stream_norms: Evidence { - value: Some( - sources - .iter() - .map(|(source, l1)| { - ( - source.to_string(), - asap_types::workload::StreamNorms { - l1: Some(*l1), - l2: None, - }, - ) - }) - .collect(), - ), - ..Evidence::default() - }, - ..data() + let mut data = data(); + for (source, l1) in sources { + data.sources + .entry(source.to_string()) + .or_default() + .stream_norms = Some(asap_types::workload::StreamNorms { + l1: Some(*l1), + l2: None, + }); } + data } /// A difference of two Count-Min estimates of the raw values of @@ -4197,13 +4406,12 @@ mod tests { ), ); let models = PlanningModels::builtin(); - let both = - bound_root_guarantee(&root, &norms(&[("a", 100.0), ("b", 1_000.0)]), &models).unwrap(); + let both = bound_of(&root, &norms(&[("a", 100.0), ("b", 1_000.0)]), &models).unwrap(); assert!( (both.spec.error.evaluate().unwrap() - 21.0).abs() < 1e-9, "{both:?}" ); - let one = bound_root_guarantee(&root, &norms(&[("a", 100.0)]), &models).unwrap(); + let one = bound_of(&root, &norms(&[("a", 100.0)]), &models).unwrap(); assert!(one.spec.error.has_unknown()); } @@ -4233,7 +4441,7 @@ mod tests { crate::test_support::cms_summary("count by (job) (m)"), column(), )); - let g = bound_root_guarantee(&raw, &data, &models).unwrap(); + let g = bound_of(&raw, &data, &models).unwrap(); assert!( (g.spec.error.evaluate().unwrap() - 2.0).abs() < 1e-12, "{g:?}" @@ -4247,7 +4455,7 @@ mod tests { column(), )), ] { - let g = bound_root_guarantee(&unbound, &data, &models).unwrap(); + let g = bound_of(&unbound, &data, &models).unwrap(); assert!(g.spec.error.has_unknown(), "{g:?}"); } } @@ -4270,13 +4478,13 @@ mod tests { (&product, 1_000.0 * 0.02 + 1_000.0 * 0.01 + 0.01 * 0.02), ("ient, (1_000.0 * 0.02 + 1_000.0 * 0.01) / (1.0 - 0.02)), ] { - let g = bound_root_guarantee(root, &data(), &bounded).unwrap(); + let g = bound_of(root, &data(), &bounded).unwrap(); assert!( (g.spec.error.evaluate().unwrap() - expected).abs() < 1e-9, "{g:?}" ); assert!((g.spec.failure_probability.evaluate().unwrap() - 0.002).abs() < 1e-12); - let unbound = bound_root_guarantee(root, &data(), &PlanningModels::builtin()).unwrap(); + let unbound = bound_of(root, &data(), &PlanningModels::builtin()).unwrap(); assert!(unbound.spec.error.has_unknown(), "{unbound:?}"); } let ratio = scalar_root( @@ -4284,7 +4492,7 @@ mod tests { scalar_of("sum(a)", guarantee(AccuracyMetric::Relative, 0.01, 0.0)), scalar_of("sum(b)", guarantee(AccuracyMetric::Relative, 0.01, 0.0)), ); - let g = bound_root_guarantee(&ratio, &data(), &PlanningModels::builtin()).unwrap(); + let g = bound_of(&ratio, &data(), &PlanningModels::builtin()).unwrap(); assert!(g.spec.error.has_unknown(), "{g:?}"); } @@ -4317,23 +4525,16 @@ mod tests { ); } - /// The input row count is bound only from fresh evidence: Stage 3 has - /// no planning time, so evidence with a validity window (which could - /// have expired) is not used; timeless declared evidence is. + /// The input row count is bound only from the series count of the + /// sources the query reads: another source's declared series count + /// leaves it unbound. #[test] - fn stage3_binds_only_fresh_input_cardinality() { + fn stage3_binds_only_the_series_count_of_the_sources_read() { let target = AccuracyTarget::max_error(0.01).failure_probability(0.001); let query = "max by (job) (quantile_over_time(0.99, m[1m]))"; - let expiring = DataWorkload { - input_cardinality: Evidence { - value: Some(10_000), - observed_at_ms: Some(1_000), - valid_for_ms: Some(1_000), - ..Evidence::default() - }, - ..data() - }; - let (candidates, selection) = run(query, &target, &expiring, PlanningModels::builtin()); + let mut other_source = data(); + other_source.sources.remove("m"); + let (candidates, selection) = run(query, &target, &other_source, PlanningModels::builtin()); let ddsketch: Vec<_> = candidates .iter() .filter(|c| reads_only(c, SketchAlgorithm::DDSketch)) @@ -4772,10 +4973,8 @@ mod tests { /// [`data`] arriving at `rows_per_s`. fn arriving(rows_per_s: f64) -> DataWorkload { DataWorkload { - arrival: asap_types::workload::DataArrival::ContinuouslyIngesting, - ingestion_rate: Evidence { - value: Some(Rate(rows_per_s)), - ..Default::default() + arrival: DataArrival::Ingesting { + rows_per_second: rows_per_s, }, ..data() } diff --git a/crates/asap-aware-mapping/src/test_support.rs b/crates/asap-aware-mapping/src/test_support.rs index c34cb8f9b..021dc10bc 100644 --- a/crates/asap-aware-mapping/src/test_support.rs +++ b/crates/asap-aware-mapping/src/test_support.rs @@ -6,8 +6,8 @@ use std::rc::Rc; use asap_types::ir::OperatorNode; use asap_types::types::AccuracyTarget; use asap_types::workload::{ - BatchEntry, DataWorkload, DurationMs, Evidence, PlanningWorkload, Predictability, Query, - QueryLanguage, QueryRequirements, QueryWorkload, + BatchEntry, DataWorkload, DurationMs, PlanningWorkload, Predictability, Query, QueryLanguage, + QueryRequirements, QueryWorkload, }; pub(crate) fn lower_promql(query: &str, accuracy: AccuracyTarget) -> Rc { @@ -26,15 +26,12 @@ pub(crate) fn lower_promql(query: &str, accuracy: AccuracyTarget) -> Rc Vec { }]), repeating_queries: None, }, - data_workload: Some(asap_types::workload::DataWorkload { - data_ingestion_interval: asap_types::workload::Evidence { - value: Some(asap_types::workload::DurationMs(1000)), - ..Default::default() - }, - ..Default::default() - }), + data_workload: Some(asap_types::workload::DataWorkload::sampled_every( + asap_frontend_promql::promql_metric_names(query).unwrap_or_default(), + asap_types::workload::DurationMs(1000), + )), }; - asap_frontend_promql::lower_promql_query_workload(&workload, 0).unwrap() + asap_frontend_promql::lower_promql_query_workload(&workload).unwrap() } #[test] diff --git a/crates/asap-physical-operators/src/physical_planner/candidates.rs b/crates/asap-physical-operators/src/physical_planner/candidates.rs index 9bb1b5b47..b20c31c4b 100644 --- a/crates/asap-physical-operators/src/physical_planner/candidates.rs +++ b/crates/asap-physical-operators/src/physical_planner/candidates.rs @@ -359,15 +359,12 @@ mod tests { }]), repeating_queries: None, }, - data_workload: Some(DataWorkload { - data_ingestion_interval: Evidence { - value: Some(DurationMs(1000)), - ..Default::default() - }, - ..Default::default() - }), + data_workload: Some(DataWorkload::sampled_every( + ["m".to_string()], + DurationMs(1000), + )), }; - let root = asap_frontend_promql::lower_promql_workload(&workload, 0) + let root = asap_frontend_promql::lower_promql_workload(&workload) .unwrap() .remove(0); let root = promql_rows::with_series_identity(&root).unwrap(); diff --git a/crates/asap-physical-operators/tests/common/mod.rs b/crates/asap-physical-operators/tests/common/mod.rs index abee99d6e..b1e894942 100644 --- a/crates/asap-physical-operators/tests/common/mod.rs +++ b/crates/asap-physical-operators/tests/common/mod.rs @@ -58,13 +58,10 @@ fn run_stage1_to_stage3( root: Rc, accuracy: planner_types::types::AccuracyTarget, ) -> asap_aware_mapping::Stage1ToStage3Run { - use planner_types::workload::{DataArrival, DataWorkload, Evidence, EvidenceSource, Rate}; + use planner_types::workload::{DataArrival, DataWorkload}; let data = DataWorkload { - arrival: DataArrival::ContinuouslyIngesting, - ingestion_rate: Evidence { - value: Some(Rate(1_000.0)), - source: EvidenceSource::Declared, - ..Default::default() + arrival: DataArrival::Ingesting { + rows_per_second: 1_000.0, }, ..Default::default() }; diff --git a/crates/asap-physical-operators/tests/current_series_heap.rs b/crates/asap-physical-operators/tests/current_series_heap.rs index a180ccaab..8a4767f89 100644 --- a/crates/asap-physical-operators/tests/current_series_heap.rs +++ b/crates/asap-physical-operators/tests/current_series_heap.rs @@ -314,15 +314,12 @@ fn planner_current_series_candidate_compiles_with_dynamic_identity() { }]), repeating_queries: None, }, - data_workload: Some(DataWorkload { - data_ingestion_interval: Evidence { - value: Some(DurationMs(60_000)), - ..Default::default() - }, - ..Default::default() - }), + data_workload: Some(DataWorkload::sampled_every( + ["m".to_string()], + DurationMs(60_000), + )), }; - let original = asap_frontend_promql::lower_promql_workload(&workload, 0) + let original = asap_frontend_promql::lower_promql_workload(&workload) .unwrap() .remove(0); let open_root = Rc::new(original.clone()); diff --git a/crates/asap-physical-operators/tests/deployment_computation.rs b/crates/asap-physical-operators/tests/deployment_computation.rs index 780d85831..d8f0686f3 100644 --- a/crates/asap-physical-operators/tests/deployment_computation.rs +++ b/crates/asap-physical-operators/tests/deployment_computation.rs @@ -34,15 +34,12 @@ fn lower_with(query: &str, accuracy: AccuracyTarget) -> Rc Rc { }]), repeating_queries: None, }, - data_workload: Some(DataWorkload { - data_ingestion_interval: WorkloadEvidence { - value: Some(DurationMs(1_000)), - ..Default::default() - }, - ..Default::default() - }), + data_workload: Some(DataWorkload::sampled_every( + asap_frontend_promql::promql_metric_names(query).unwrap_or_default(), + DurationMs(1_000), + )), }; - asap_frontend_promql::lower_promql_workload(&workload, 0) + asap_frontend_promql::lower_promql_workload(&workload) .unwrap() .remove(0) } diff --git a/crates/asap-physical-operators/tests/precompute_candidates.rs b/crates/asap-physical-operators/tests/precompute_candidates.rs index fec500f20..4d1805e2e 100644 --- a/crates/asap-physical-operators/tests/precompute_candidates.rs +++ b/crates/asap-physical-operators/tests/precompute_candidates.rs @@ -41,15 +41,12 @@ fn exact_promql_root(query: &str) -> std::rc::Rc {q}"); - match lower_promql_with_data_ingestion_interval(q, AccuracyTarget::exact(), 1_000) { + match lower_promql_with_sample_interval(q, AccuracyTarget::exact(), 1_000) { Ok(qe) => println!("{qe:#?}"), Err(e) => println!("ERR: {e}"), } diff --git a/crates/devtools/examples/topk_ir.rs b/crates/devtools/examples/topk_ir.rs index 16e800dbe..af21b1e1e 100644 --- a/crates/devtools/examples/topk_ir.rs +++ b/crates/devtools/examples/topk_ir.rs @@ -3,7 +3,7 @@ // Lowers every topk-shaped query from the design discussion and prints the // resulting pre-ASAP IR. Used for interactive exploration; not a test. -use asap_devtools::{lower_promql_with_data_ingestion_interval, lower_sql, SqlCatalog}; +use asap_devtools::{lower_promql_with_sample_interval, lower_sql, SqlCatalog}; use asap_types::pre_asap::schema::{DataType, Field, Schema}; use asap_types::types::AccuracyTarget; @@ -49,7 +49,7 @@ async fn show_sql(label: &str, query: &str) { fn show_promql(label: &str, query: &str) { println!("━━━ {label} ━━━"); println!("{query}"); - match lower_promql_with_data_ingestion_interval(query, AccuracyTarget::exact(), 1_000) { + match lower_promql_with_sample_interval(query, AccuracyTarget::exact(), 1_000) { Ok(qe) => println!("{qe:#?}"), Err(e) => println!("ERR: {e}"), } diff --git a/crates/devtools/src/bin/analyze_corpora.rs b/crates/devtools/src/bin/analyze_corpora.rs index 8279acdf9..2d2f34e7b 100644 --- a/crates/devtools/src/bin/analyze_corpora.rs +++ b/crates/devtools/src/bin/analyze_corpora.rs @@ -4,7 +4,7 @@ // Corpus mode dumps all four PromQL corpora as JSONL and writes a heuristic // anomaly report. The default mode remains the ad-hoc SQL/PromQL inspector. -use asap_devtools::{lower_promql_with_data_ingestion_interval, SqlCatalog}; +use asap_devtools::{lower_promql_with_sample_interval, SqlCatalog}; use asap_frontend_sql::lower_sql_dialect; use asap_types::pre_asap::schema::{DataType, Field, Schema}; use asap_types::types::AccuracyTarget; @@ -206,11 +206,7 @@ fn run_corpus(name: &str, source: &str, interval_ms: u64) -> CorpusResult { for (index, expression) in corpus_lines(source).into_iter().enumerate() { let normalized_expression = normalize(expression); let structural_shape = structural_shape(expression); - match lower_promql_with_data_ingestion_interval( - expression, - AccuracyTarget::exact(), - interval_ms, - ) { + match lower_promql_with_sample_interval(expression, AccuracyTarget::exact(), interval_ms) { Ok(ir) => result.lowered.push(DumpRecord { corpus: name.to_string(), query_number: index + 1, @@ -550,12 +546,12 @@ async fn main() { while let Some(arg) = args.next() { if arg == "--out-dir" { out_dir = PathBuf::from(args.next().expect("--out-dir requires a path")); - } else if arg == "--data-ingestion-interval-ms" { + } else if arg == "--sample-interval-ms" { interval_ms = Some( args.next() - .expect("--data-ingestion-interval-ms requires a value") + .expect("--sample-interval-ms requires a value") .parse() - .expect("--data-ingestion-interval-ms must be an unsigned integer"), + .expect("--sample-interval-ms must be an unsigned integer"), ); } else { panic!("unknown corpus-mode argument: {arg}"); @@ -563,7 +559,7 @@ async fn main() { } run_corpora( out_dir, - interval_ms.expect("--data-ingestion-interval-ms is required for --corpora"), + interval_ms.expect("--sample-interval-ms is required for --corpora"), ); return; } diff --git a/crates/devtools/src/bin/show_logical_dag.rs b/crates/devtools/src/bin/show_logical_dag.rs index f6516279c..9b2ab90c1 100644 --- a/crates/devtools/src/bin/show_logical_dag.rs +++ b/crates/devtools/src/bin/show_logical_dag.rs @@ -17,7 +17,7 @@ // SQL queries run against a fixed `metrics(ts, service, region, latency, // bytes)` catalog — the same table used in cross_language.rs and topk_ir.rs. -use asap_devtools::{lower_promql_with_data_ingestion_interval, lower_sql, SqlCatalog}; +use asap_devtools::{lower_promql_with_sample_interval, lower_sql, SqlCatalog}; use asap_types::pre_asap::schema::{DataType, Field, Schema}; use asap_types::types::AccuracyTarget; use std::io::Read; @@ -48,8 +48,8 @@ async fn main() { let mut args = std::env::args().skip(1); assert_eq!( args.next().as_deref(), - Some("--data-ingestion-interval-ms"), - "usage: show_logical_dag --data-ingestion-interval-ms [queries.txt]" + Some("--sample-interval-ms"), + "usage: show_logical_dag --sample-interval-ms [queries.txt]" ); let interval_ms = args .next() @@ -82,11 +82,8 @@ async fn main() { Err(e) => println!("ERR: {e}"), } } else if let Some(q) = line.strip_prefix("promql>") { - match lower_promql_with_data_ingestion_interval( - q.trim(), - AccuracyTarget::exact(), - interval_ms, - ) { + match lower_promql_with_sample_interval(q.trim(), AccuracyTarget::exact(), interval_ms) + { Ok(qe) => println!("{qe:#?}"), Err(e) => println!("ERR: {e}"), } diff --git a/crates/devtools/src/bin/sketch_coverage.rs b/crates/devtools/src/bin/sketch_coverage.rs index d2d198d58..db9e0d618 100644 --- a/crates/devtools/src/bin/sketch_coverage.rs +++ b/crates/devtools/src/bin/sketch_coverage.rs @@ -1,4 +1,4 @@ -// cargo run -p asap-devtools --bin sketch_coverage -- --data-ingestion-interval-ms [--epsilon ] +// cargo run -p asap-devtools --bin sketch_coverage -- --sample-interval-ms [--epsilon ] // // Lowers every query in every corpus we have (mirrors `variant_coverage`'s // corpus list exactly, so the two reports are directly comparable) with an @@ -7,6 +7,8 @@ // reports which summary candidates Pass 1 offers for it, plus the fraction of // lowered queries with at least one. // +// `--sample-interval-ms ` is the sample interval declared for every +// metric a query reads, which its bare instant selectors look back. // `--epsilon ` (default 0.01) sets the `AccuracyTarget` every query in // every corpus lowers with. Without an approximate target, Pass 1 offers no // summary candidate. @@ -14,7 +16,7 @@ use asap_aware_mapping::per_computation_candidates::{ generate_per_computation_candidates, ComputationCandidate, }; -use asap_devtools::lower_promql_with_data_ingestion_interval; +use asap_devtools::lower_promql_with_sample_interval; use asap_frontend_sql::{lower_sql_dialect, SqlCatalog}; use asap_types::ir::{OperatorNode, QueryRoot}; use asap_types::pre_asap::schema::{DataType, Field, Schema}; @@ -201,24 +203,24 @@ fn parse_epsilon() -> f64 { 0.01 } -fn parse_ingestion_interval() -> u64 { +fn parse_sample_interval() -> u64 { let mut args = std::env::args().skip(1); while let Some(arg) = args.next() { - if arg == "--data-ingestion-interval-ms" { + if arg == "--sample-interval-ms" { return args .next() - .expect("--data-ingestion-interval-ms requires a value") + .expect("--sample-interval-ms requires a value") .parse() - .expect("--data-ingestion-interval-ms must be an unsigned integer"); + .expect("--sample-interval-ms must be an unsigned integer"); } } - panic!("--data-ingestion-interval-ms is required") + panic!("--sample-interval-ms is required") } #[tokio::main] async fn main() { let epsilon = parse_epsilon(); - let interval_ms = parse_ingestion_interval(); + let interval_ms = parse_sample_interval(); let accuracy = AccuracyTarget::max_error(epsilon); let mut results = Vec::new(); @@ -251,7 +253,7 @@ async fn main() { let mut roots = Vec::new(); let mut failed = 0; for (i, q) in promql_lines(corpus).enumerate() { - match lower_promql_with_data_ingestion_interval(q, accuracy.clone(), interval_ms) { + match lower_promql_with_sample_interval(q, accuracy.clone(), interval_ms) { Ok(qe) => roots.push((format!("q{i}"), qe)), Err(_) => failed += 1, } diff --git a/crates/devtools/src/bin/stage_pipeline.rs b/crates/devtools/src/bin/stage_pipeline.rs index dc86ef008..30d8c86de 100644 --- a/crates/devtools/src/bin/stage_pipeline.rs +++ b/crates/devtools/src/bin/stage_pipeline.rs @@ -68,14 +68,15 @@ // `pareto-front`). Cost models 1 and 2 use the illustrative unit costs. // Each shown candidate's `usage` lists what it uses; `cost_per_second` is null under // cost model 3, and `front` lists the Pareto front under `pareto-front`. -// `--interval-ms` is the source cadence (default 15000). `--table ''` -// (repeatable) declares a table the SQL queries read: `{"name": ..., +// `--sample-interval-ms` is the sample interval declared for every metric the +// PromQL queries read (default 15000); the data declares no other facts. +// `--table ''` (repeatable) declares a table the SQL queries read: `{"name": ..., // "columns": [{"name": ..., "type": "timestamp|utf8|string|float64|double| // int64|bigint", "nullable": true}], "time_index": 0}` (`nullable` defaults // to true; without `time_index` the table has no time column; a // `time_index` that is not a column index is an error). -use std::collections::HashSet; +use std::collections::{BTreeSet, HashSet}; use asap_aware_mapping::stage3_plan_selection::{ ClusterLimits, ClusterObjective, CostModel, PlanSelectionSettings, Rejection, ResourceUnitCost, @@ -83,13 +84,14 @@ use asap_aware_mapping::stage3_plan_selection::{ }; use asap_devtools::candidate_labels::candidate_labels; use asap_devtools::planner_layering_examples::{ - declared, example1, example2, pattern_a, pattern_b, PatternARecurrence, + example1, example2, pattern_a, pattern_b, PatternARecurrence, SHARED_ROWS_PER_SECOND, }; use asap_devtools::planning_stages::{ lower_promql_roots, lower_sql_roots, run_planning_stages_over, PlanningStagesError, PlanningStagesRun, }; use asap_devtools::SqlCatalog; +use asap_frontend_promql::promql_metric_names; use asap_types::ir::flat::{flatten, FlatDag}; use asap_types::ir::QueryRoot; use asap_types::pre_asap::schema::{DataType, Field, Schema}; @@ -102,7 +104,7 @@ use serde_json::{json, Value}; const USAGE: &str = "usage: stage_pipeline (--example planner-layering-{1,2,3a,3b} | ((--promql | --sql) \ -[--name ]... [--table ...] [--epsilon --delta --metric ] [--interval-ms ])) \ +[--name ]... [--table ...] [--epsilon --delta --metric ] [--sample-interval-ms ])) \ [--cost-model usage-over-time|provisioned-capacity|cluster-capacity [--cluster-memory-bytes \ --cluster-disk-bytes [--cluster-cpu-cores ] --cluster-objective lowest-latency|least-memory|pareto-front]] \ [--max-combinations ] [--max-displayed ] --out "; @@ -184,7 +186,7 @@ fn run(args: Vec) -> Result<(), Failure> { // (language, text, name) in workload order. let mut queries: Vec<(QueryLanguage, String, Option)> = Vec::new(); let mut tables = Vec::new(); - let (mut epsilon, mut delta, mut interval_ms) = (None, None, 15_000u64); + let (mut epsilon, mut delta, mut sample_interval_ms) = (None, None, 15_000u64); let mut metric: Option = None; let mut max_displayed = 64usize; let mut max_combinations = MAX_COMBINATIONS; @@ -221,7 +223,9 @@ fn run(args: Vec) -> Result<(), Failure> { .map_err(|_| format!("unknown metric {name}"))?, ) } - "--interval-ms" => interval_ms = value()?.parse().map_err(|e| format!("{e}"))?, + "--sample-interval-ms" => { + sample_interval_ms = value()?.parse().map_err(|e| format!("{e}"))? + } "--max-displayed" => max_displayed = value()?.parse().map_err(|e| format!("{e}"))?, "--max-combinations" => { max_combinations = value()?.parse().map_err(|e| format!("{e}"))? @@ -291,7 +295,12 @@ fn run(args: Vec) -> Result<(), Failure> { "planner-layering-1" => (example1(), SqlCatalog::new()), "planner-layering-2" => example2(), "planner-layering-3a" => ( - pattern_a(PatternARecurrence::OnceAdHoc, DataArrival::Mixed), + pattern_a( + PatternARecurrence::OnceAdHoc, + DataArrival::Mixed { + rows_per_second: SHARED_ROWS_PER_SECOND, + }, + ), SqlCatalog::new(), ), "planner-layering-3b" => (pattern_b(), SqlCatalog::new()), @@ -329,6 +338,9 @@ fn run(args: Vec) -> Result<(), Failure> { let catalog = sql_catalog(&tables)?; let mut names = HashSet::new(); let (mut described, mut roots, mut entries) = (Vec::new(), Vec::new(), Vec::new()); + // Every metric the PromQL queries read, each sampled every + // `--sample-interval-ms`. + let mut metrics = BTreeSet::new(); for (index, (language, text, name)) in queries.into_iter().enumerate() { let (name, defaulted) = match name { Some(name) => (name, false), @@ -346,20 +358,27 @@ fn run(args: Vec) -> Result<(), Failure> { .into()); } // Each query lowers in its own language; planning sees one workload. - let one = query_batch(language.clone(), &[text], accuracy.clone(), interval_ms); + let one = query_batch( + language.clone(), + &[text], + accuracy.clone(), + sample_interval_ms, + ); roots.extend(match language { QueryLanguage::PromQL => lower_promql_roots(&one)?, _ => lower_sql_roots(&one, &catalog)?, }); entries.extend(one.query_workload.entries()); + metrics.extend( + one.data_workload + .iter() + .flat_map(|d| d.sources.keys().cloned()), + ); let mut query = workload_queries(&one).remove(0); query["id"] = json!(name); described.push(query); } - let data = DataWorkload { - data_ingestion_interval: declared(DurationMs(interval_ms)), - ..Default::default() - }; + let data = DataWorkload::sampled_every(metrics, DurationMs(sample_interval_ms)); let result = run_planning_stages_over( roots.clone(), &entries, @@ -656,8 +675,17 @@ fn query_batch( language: QueryLanguage, queries: &[String], accuracy: AccuracyTarget, - interval_ms: u64, + sample_interval_ms: u64, ) -> PlanningWorkload { + // Each metric a PromQL query reads is sampled every `sample_interval_ms`; a + // query that does not parse names none and fails when lowered. + let metrics: BTreeSet = match language { + QueryLanguage::PromQL => queries + .iter() + .flat_map(|query| promql_metric_names(query).unwrap_or_default()) + .collect(), + _ => BTreeSet::new(), + }; PlanningWorkload { query_workload: QueryWorkload { language, @@ -678,10 +706,10 @@ fn query_batch( ), repeating_queries: None, }, - data_workload: Some(DataWorkload { - data_ingestion_interval: declared(DurationMs(interval_ms)), - ..Default::default() - }), + data_workload: Some(DataWorkload::sampled_every( + metrics, + DurationMs(sample_interval_ms), + )), } } diff --git a/crates/devtools/src/bin/variant_coverage.rs b/crates/devtools/src/bin/variant_coverage.rs index 9f277df73..56d9596bb 100644 --- a/crates/devtools/src/bin/variant_coverage.rs +++ b/crates/devtools/src/bin/variant_coverage.rs @@ -1,4 +1,4 @@ -// cargo run -p asap-lower --bin variant_coverage -- --data-ingestion-interval-ms 1000 +// cargo run -p asap-lower --bin variant_coverage -- --sample-interval-ms 1000 // // Lowers every query in every corpus we have (PromQL + SQL), walks the // resulting `OperatorNode` DAGs, and reports which IR variants show up — per @@ -6,7 +6,7 @@ // `ASAPOp`, by `Operator::kind_name`) and the scalar-expression vocabulary // (`ScalarExpr`) separately. Used to find the minimal IR node set. -use asap_devtools::lower_promql_with_data_ingestion_interval; +use asap_devtools::lower_promql_with_sample_interval; use asap_frontend_sql::{lower_sql_dialect, SqlCatalog}; use asap_types::ir::{OperatorNode, ScalarExpr}; use asap_types::pre_asap::schema::{DataType, Field, Schema}; @@ -248,23 +248,23 @@ fn report(r: &CorpusResult) { println!(); } -fn parse_ingestion_interval() -> u64 { +fn parse_sample_interval() -> u64 { let mut args = std::env::args().skip(1); while let Some(arg) = args.next() { - if arg == "--data-ingestion-interval-ms" { + if arg == "--sample-interval-ms" { return args .next() - .expect("--data-ingestion-interval-ms requires a value") + .expect("--sample-interval-ms requires a value") .parse() - .expect("--data-ingestion-interval-ms must be an unsigned integer"); + .expect("--sample-interval-ms must be an unsigned integer"); } } - panic!("--data-ingestion-interval-ms is required") + panic!("--sample-interval-ms is required") } #[tokio::main] async fn main() { - let interval_ms = parse_ingestion_interval(); + let interval_ms = parse_sample_interval(); let mut results = Vec::new(); // ── PromQL corpora ── @@ -297,8 +297,7 @@ async fn main() { let mut lowered = 0; let mut failed = 0; for q in promql_lines(corpus) { - match lower_promql_with_data_ingestion_interval(q, AccuracyTarget::exact(), interval_ms) - { + match lower_promql_with_sample_interval(q, AccuracyTarget::exact(), interval_ms) { Ok(qe) => { walk(&qe, &mut variants); lowered += 1; diff --git a/crates/devtools/src/candidate_labels.rs b/crates/devtools/src/candidate_labels.rs index df11501c7..05234f822 100644 --- a/crates/devtools/src/candidate_labels.rs +++ b/crates/devtools/src/candidate_labels.rs @@ -444,7 +444,7 @@ mod tests { /// once per build with both readers each time. #[test] fn identical_pane_chains_in_two_queries_are_listed_once() { - use crate::planner_layering_examples::shared_data_workload; + use crate::planner_layering_examples::{shared_data_workload, SHARED_ROWS_PER_SECOND}; use asap_types::types::AccuracyTarget; use asap_types::workload::{ DataArrival, PlanningWorkload, Predictability, Query, QueryLanguage, QueryRequirements, @@ -469,7 +469,12 @@ mod tests { panel("max_over_time(a[1m])"), ]), }, - data_workload: Some(shared_data_workload(DataArrival::ContinuouslyIngesting)), + data_workload: Some(shared_data_workload( + DataArrival::Ingesting { + rows_per_second: SHARED_ROWS_PER_SECOND, + }, + "a", + )), }; let run = run_planning_stages(&workload, lower_promql_roots(&workload).unwrap(), None).unwrap(); @@ -500,7 +505,7 @@ mod tests { /// avg's exact states as "Sum/Count acc". #[test] fn fused_computations_and_avg_states_have_their_own_labels() { - use crate::planner_layering_examples::shared_data_workload; + use crate::planner_layering_examples::{shared_data_workload, SHARED_ROWS_PER_SECOND}; use asap_types::types::AccuracyTarget; use asap_types::workload::{ DataArrival, PlanningWorkload, Predictability, Query, QueryLanguage, QueryRequirements, @@ -521,7 +526,12 @@ mod tests { predictability: Predictability::Predictable { known_at: None }, }]), }, - data_workload: Some(shared_data_workload(DataArrival::ContinuouslyIngesting)), + data_workload: Some(shared_data_workload( + DataArrival::Ingesting { + rows_per_second: SHARED_ROWS_PER_SECOND, + }, + "m", + )), }; let run = run_planning_stages(&workload, lower_promql_roots(&workload).unwrap(), None) .unwrap(); diff --git a/crates/devtools/src/lib.rs b/crates/devtools/src/lib.rs index b2e40a6d3..60623a7af 100644 --- a/crates/devtools/src/lib.rs +++ b/crates/devtools/src/lib.rs @@ -26,15 +26,17 @@ pub use asap_frontend_promql::{ }; pub use asap_frontend_sql::{lower_sql, lower_sql_batch, SqlCatalog, SqlError, SqlLowerer}; -/// Lower one developer-supplied PromQL query with an explicit source cadence. -/// Tools intentionally require the cadence rather than choosing a default. -pub fn lower_promql_with_data_ingestion_interval( +/// Lower one developer-supplied PromQL query whose metrics are each sampled +/// every `interval_ms`, declared on each metric's source facts. Tools +/// intentionally require the sample interval rather than choosing a +/// default. +pub fn lower_promql_with_sample_interval( query: &str, accuracy: asap_types::types::AccuracyTarget, interval_ms: u64, ) -> Result, PromqlError> { use asap_types::workload::{ - BatchEntry, DataWorkload, DurationMs, Evidence, PlanningWorkload, Predictability, Query, + BatchEntry, DataWorkload, DurationMs, PlanningWorkload, Predictability, Query, QueryRequirements, QueryWorkload, }; @@ -53,20 +55,11 @@ pub fn lower_promql_with_data_ingestion_interval( }]), repeating_queries: None, }, - data_workload: Some(DataWorkload { - data_ingestion_interval: Evidence { - value: Some(DurationMs(interval_ms)), - ..Default::default() - }, - ..Default::default() - }), + data_workload: Some(DataWorkload::sampled_every( + asap_frontend_promql::promql_metric_names(query)?, + DurationMs(interval_ms), + )), }; - lower_promql_workload(&workload, 0)? - .into_iter() - .next() - .ok_or({ - PromqlError::InvalidWorkload( - asap_types::workload::WorkloadError::MissingPromqlDataWorkload, - ) - }) + // One workload entry lowers to one root. + Ok(lower_promql_workload(&workload)?.remove(0)) } diff --git a/crates/devtools/src/planner_layering_examples.rs b/crates/devtools/src/planner_layering_examples.rs index 21676f899..7642004df 100644 --- a/crates/devtools/src/planner_layering_examples.rs +++ b/crates/devtools/src/planner_layering_examples.rs @@ -4,42 +4,40 @@ use asap_types::types::{AccuracyMetric, AccuracyTarget}; use asap_types::workload::{ - BatchEntry, DataArrival, DataWorkload, DurationMs, Evidence, EvidenceSource, MetricType, - PlanningWorkload, Predictability, Query, QueryLanguage, QueryRequirements, QueryWorkload, Rate, - RepeatedDemand, RepeatingEntry, RepetitionInterval, SqlDialect, TimestampMs, + BatchEntry, DataArrival, DataWorkload, DurationMs, MetricType, PlanningWorkload, + Predictability, Query, QueryLanguage, QueryRequirements, QueryWorkload, RepeatedDemand, + RepeatingEntry, RepetitionInterval, SourceFacts, SqlDialect, TimestampMs, }; -pub fn declared(value: T) -> Evidence { - Evidence { - value: Some(value), - source: EvidenceSource::Declared, - ..Default::default() - } -} - pub const MINUTE_MS: u64 = 60_000; /// PromQL's `y` is 365 days. pub const YEAR_MS: u64 = 365 * 24 * 60 * MINUTE_MS; /// Pattern A's batch execution time T (2026-01-01T00:00:00Z). pub const T_MS: u64 = 1_767_225_600_000; -/// The #509 shared data workload with `arrival`. -pub fn shared_data_workload(arrival: DataArrival) -> DataWorkload { +/// Rows per second the #509 shared data arrives at while ingesting: +/// 1,000,000 series, each sampled every 15 s. +pub const SHARED_ROWS_PER_SECOND: f64 = 1_000_000.0 / 15.0; + +/// The #509 shared data, arriving as `arrival` says: one million series +/// of `source`, each sampled every 15 s. +pub fn shared_data_workload(arrival: DataArrival, source: &str) -> DataWorkload { DataWorkload { - data_ingestion_interval: declared(DurationMs(15_000)), - // Data at rest has no ingestion (`validate` rejects a positive rate). - ingestion_rate: match arrival { - DataArrival::AtRest => Evidence::default(), - _ => declared(Rate(1_000_000.0 / 15.0)), - }, - input_cardinality: declared(1_000_000), - stream_norms: Evidence::default(), - max_distinct_per_evaluation: Evidence::default(), arrival, - metric_types: Default::default(), + sources: [( + source.to_string(), + SourceFacts { + series_count: Some(1_000_000), + sample_interval: Some(DurationMs(15_000)), + ..SourceFacts::default() + }, + )] + .into(), } } +/// The metric both Example 1 queries read. +pub const EXAMPLE1_METRIC: &str = "http_requests_total"; pub const EXAMPLE1_Q1: &str = "sum by (job) (rate(http_requests_total[1m]))"; pub const EXAMPLE1_Q2: &str = "topk by (job) (10, sum_over_time(http_requests_total[1m]))"; @@ -69,9 +67,18 @@ pub fn example1() -> PlanningWorkload { ), ]), }, - data_workload: Some(DataWorkload { - metric_types: [("http_requests_total".to_string(), MetricType::Counter)].into(), - ..shared_data_workload(DataArrival::ContinuouslyIngesting) + data_workload: Some({ + let mut data = shared_data_workload( + DataArrival::Ingesting { + rows_per_second: SHARED_ROWS_PER_SECOND, + }, + EXAMPLE1_METRIC, + ); + data.sources + .get_mut(EXAMPLE1_METRIC) + .expect("the shared source") + .metric_type = Some(MetricType::Counter); + data }), } } @@ -127,10 +134,20 @@ pub fn example2() -> (PlanningWorkload, asap_frontend_sql::SqlCatalog) { .collect(), ), }, + // SQL needs no sample interval; `flows` holds 10,000,000 groups + // (one per source IP). data_workload: Some(DataWorkload { - data_ingestion_interval: Evidence::default(), - input_cardinality: declared(10_000_000), - ..shared_data_workload(DataArrival::ContinuouslyIngesting) + arrival: DataArrival::Ingesting { + rows_per_second: 1_000_000.0 / 15.0, + }, + sources: [( + "flows".to_string(), + SourceFacts { + series_count: Some(10_000_000), + ..SourceFacts::default() + }, + )] + .into(), }), }; let catalog = asap_frontend_sql::SqlCatalog::new().with_table( @@ -147,6 +164,9 @@ pub fn example2() -> (PlanningWorkload, asap_frontend_sql::SqlCatalog) { (workload, catalog) } +/// The metric Example 3's Patterns A and B read. +pub const PATTERN_METRIC: &str = "latency_ms"; + /// Pattern A's five queries: (PromQL, its range window in ms, its offset /// before T in ms); the window and offset restate the query text. pub const PATTERN_A: [(&str, u64, u64); 5] = [ @@ -231,7 +251,7 @@ pub fn pattern_a(recurrence: PatternARecurrence, arrival: DataArrival) -> Planni query_batch: batch, repeating_queries: repeating, }, - data_workload: Some(shared_data_workload(arrival)), + data_workload: Some(shared_data_workload(arrival, PATTERN_METRIC)), } } @@ -259,7 +279,12 @@ pub fn pattern_b() -> PlanningWorkload { predictability: Predictability::Predictable { known_at: None }, }]), }, - data_workload: Some(shared_data_workload(DataArrival::ContinuouslyIngesting)), + data_workload: Some(shared_data_workload( + DataArrival::Ingesting { + rows_per_second: SHARED_ROWS_PER_SECOND, + }, + PATTERN_METRIC, + )), } } @@ -267,15 +292,24 @@ pub fn pattern_b() -> PlanningWorkload { mod tests { use super::*; - /// Data at rest has no ingestion rate, so the at-rest shared data - /// workload validates. + /// Every shared arrival validates, at rest included. #[test] - fn at_rest_shared_data_workload_is_valid() { - shared_data_workload(DataArrival::AtRest) - .validate() - .unwrap(); - pattern_a(PatternARecurrence::OnceAdHoc, DataArrival::AtRest) - .validate() - .unwrap(); + fn shared_data_workloads_are_valid() { + for arrival in [ + DataArrival::AtRest, + DataArrival::Ingesting { + rows_per_second: SHARED_ROWS_PER_SECOND, + }, + DataArrival::Mixed { + rows_per_second: SHARED_ROWS_PER_SECOND, + }, + ] { + shared_data_workload(arrival, PATTERN_METRIC) + .validate() + .unwrap(); + pattern_a(PatternARecurrence::OnceAdHoc, arrival) + .validate() + .unwrap(); + } } } diff --git a/crates/devtools/src/planning_stages.rs b/crates/devtools/src/planning_stages.rs index 8b82a335f..15cb4fda6 100644 --- a/crates/devtools/src/planning_stages.rs +++ b/crates/devtools/src/planning_stages.rs @@ -32,7 +32,7 @@ pub struct PlanningStagesRun { /// Lower a PromQL workload, each series' identity as a column (the row /// representation per-series state needs at runtime). pub fn lower_promql_roots(workload: &PlanningWorkload) -> Result, String> { - asap_frontend_promql::lower_promql_query_workload(workload, 0) + asap_frontend_promql::lower_promql_query_workload(workload) .map_err(|e| format!("lowering: {e}"))? .into_iter() .map(|root| match root { diff --git a/crates/devtools/tests/cross_language.rs b/crates/devtools/tests/cross_language.rs index 5c39ba7c2..0d589ed21 100644 --- a/crates/devtools/tests/cross_language.rs +++ b/crates/devtools/tests/cross_language.rs @@ -13,7 +13,7 @@ //! match is the **shape above the leaf**: an outer `Aggregate([TopK{k}])` over an //! explicit inner `Aggregate([Count])`. -use asap_devtools::{lower_promql_with_data_ingestion_interval, lower_sql, SqlCatalog}; +use asap_devtools::{lower_promql_with_sample_interval, lower_sql, SqlCatalog}; use asap_types::ir::{NonASAPOp, OperatorNode}; use asap_types::pre_asap::schema::{DataType, Field, Schema}; use asap_types::pre_asap::{AggIntent, GroupKeys}; @@ -48,7 +48,7 @@ async fn sql(q: &str) -> Rc { } fn promql(q: &str) -> Rc { - lower_promql_with_data_ingestion_interval(q, AccuracyTarget::exact(), 1_000) + lower_promql_with_sample_interval(q, AccuracyTarget::exact(), 1_000) .unwrap_or_else(|e| panic!("PromQL {q:?} failed to lower: {e:?}")) } diff --git a/crates/devtools/tests/stage_pipeline.rs b/crates/devtools/tests/stage_pipeline.rs index 8a5e3443b..8a9484a89 100644 --- a/crates/devtools/tests/stage_pipeline.rs +++ b/crates/devtools/tests/stage_pipeline.rs @@ -3,7 +3,9 @@ use std::collections::HashSet; use std::path::PathBuf; use std::process::Command; -use asap_devtools::planner_layering_examples::{example1, pattern_a, PatternARecurrence}; +use asap_devtools::planner_layering_examples::{ + example1, pattern_a, PatternARecurrence, SHARED_ROWS_PER_SECOND, +}; use asap_devtools::planning_stages::{ lower_promql_roots, run_planning_stages, PlanningStagesError, }; @@ -11,6 +13,7 @@ use asap_types::ir::flat::FlatDag; use asap_types::ir::physical_export::{PhysicalASAPDAG, PhysicalASAPDAGDocument}; use asap_types::ir::QueryRoot; use asap_types::workload::DataArrival; + use serde_json::Value; const COMMITTED: &str = "../../tools/dag-viewer/examples/planner-layering-example1.json"; @@ -203,7 +206,12 @@ fn uncomposable_candidate_is_recorded_with_its_error() { /// displayed with its cost. #[test] fn example3a_selects_the_cheapest_of_all_candidates() { - let workload = pattern_a(PatternARecurrence::OnceAdHoc, DataArrival::Mixed); + let workload = pattern_a( + PatternARecurrence::OnceAdHoc, + DataArrival::Mixed { + rows_per_second: SHARED_ROWS_PER_SECOND, + }, + ); let run = run_planning_stages(&workload, lower_promql_roots(&workload).unwrap(), None).unwrap(); assert_eq!(run.stage1_logical_asap_optimization.len(), 972); let expected = run.stage3_plan_selection.unwrap().selected; @@ -429,6 +437,28 @@ fn planning_stages_without_a_limit_plan_every_candidate() { )); } +/// `--sample-interval-ms` is the sample interval of every metric the +/// queries read, as in the other lowering tools: `sum(up)`'s bare selector +/// looks back 30 s. The former `--interval-ms` is unknown. +#[test] +fn sample_interval_ms_sets_the_instant_selector_lookback() { + let document = document_of(&["--promql", "sum(up)", "--sample-interval-ms", "30000"]); + let ranges: Vec<&Value> = document["stage0_query_language_frontends"]["dag"]["nodes"] + .as_array() + .unwrap() + .iter() + .filter_map(|node| node["operator"]["NonASAP"]["TimeRange"]["range"].as_object()) + .map(|range| &range["secs"]) + .collect(); + assert_eq!(ranges, [&Value::from(30)]); + let (status, stderr) = failure(&["--promql", "sum(up)", "--interval-ms", "30000"]); + assert_eq!(status, Some(2)); + assert!( + stderr.contains("unknown argument --interval-ms"), + "{stderr}" + ); +} + #[test] fn table_declared_twice_is_rejected() { let table = diff --git a/crates/frontend-metricsql/src/lib.rs b/crates/frontend-metricsql/src/lib.rs index 04c93f20d..e983f135d 100644 --- a/crates/frontend-metricsql/src/lib.rs +++ b/crates/frontend-metricsql/src/lib.rs @@ -43,10 +43,11 @@ pub fn canonical_metricsql(query: &str) -> Result { /// Lower every MetricsQL workload entry, as `lower_promql_query_workload` /// does for PromQL: a bare instant selector reads the latest sample within -/// the workload's `data_ingestion_interval`, valid at `now_ms`. +/// its metric's `sample_interval` in the data workload's sources, and fails +/// with [`WorkloadError::MissingSampleInterval`] when that metric declares +/// none. pub fn lower_metricsql_query_workload( workload: &PlanningWorkload, - now_ms: u64, ) -> Result, MetricsqlError> { if !matches!(workload.query_workload.language, QueryLanguage::MetricsQL) { return Err(MetricsqlError::WrongLanguage(format!( @@ -55,43 +56,57 @@ pub fn lower_metricsql_query_workload( ))); } workload.validate()?; - let &DurationMs(interval_ms) = workload - .data_workload - .as_ref() - .expect("validated MetricsQL workload has data_workload") - .data_ingestion_interval - .value_at(now_ms) - .ok_or(WorkloadError::UnavailableDataIngestionInterval)?; + let sample_interval_of = |metric: &str| { + workload + .sample_interval_of(metric) + .map(|DurationMs(ms)| Duration::from_millis(ms)) + }; workload .query_workload .entries() .map(|entry| { - lower_metricsql_query( + lower_with_sample_intervals( &entry.query.0, entry.requirements.accuracy.clone(), - Duration::from_millis(interval_ms), + &sample_interval_of, ) }) .collect() } +/// Lower one MetricsQL query whose metrics are all sampled every +/// `sample_interval`, to an operator root; a scalar query is an error (use +/// [`lower_metricsql_query`]). pub fn lower_metricsql( query: &str, accuracy: AccuracyTarget, - ingestion_interval: Duration, + sample_interval: Duration, ) -> Result, MetricsqlError> { - match lower_metricsql_query(query, accuracy, ingestion_interval)? { + match lower_metricsql_query(query, accuracy, sample_interval)? { asap_types::ir::QueryRoot::Operator(node) => Ok(node), _ => Err(unsupported("scalar root: use lower_metricsql_query")), } } -/// Lower scalar constants without fabricating a relational operator. A bare -/// instant selector looks back `ingestion_interval` for its latest sample. +/// Lower scalar constants without fabricating a relational operator. Every +/// metric is sampled every `sample_interval`: a bare instant selector looks +/// back that long for its latest sample. pub fn lower_metricsql_query( query: &str, accuracy: AccuracyTarget, - ingestion_interval: Duration, + sample_interval: Duration, +) -> Result { + lower_with_sample_intervals(query, accuracy, &|_| Some(sample_interval)) +} + +/// [`lower_metricsql_query`] with each metric's sample interval from +/// `sample_interval_of`, `None` when the metric declares none: a bare +/// instant selector over it then fails with +/// [`WorkloadError::MissingSampleInterval`]. +fn lower_with_sample_intervals( + query: &str, + accuracy: AccuracyTarget, + sample_interval_of: &dyn Fn(&str) -> Option, ) -> Result { let ast = parse_metricsql(query)?; if let Expr::NumberLiteral(number) = &ast { @@ -101,7 +116,7 @@ pub fn lower_metricsql_query( } let unresolved = Lowerer { accuracy, - ingestion_interval, + sample_interval_of, } .lower(&ast)?; resolve_root(&unresolved) @@ -109,20 +124,29 @@ pub fn lower_metricsql_query( .map_err(|e| MetricsqlError::Resolve(e.to_string())) } -struct Lowerer { +struct Lowerer<'a> { accuracy: AccuracyTarget, - ingestion_interval: Duration, + /// What a bare instant selector over a metric looks back; `None` when + /// the metric declares no sample interval. + sample_interval_of: &'a dyn Fn(&str) -> Option, } -impl Lowerer { +impl Lowerer<'_> { fn lower(&self, expr: &Expr) -> Result { match expr { // An instant selector: the latest sample within the interval. - Expr::MetricExpression(e) => Ok(U::TimeRange { - range: self.ingestion_interval, - kind: TimeRangeKind::Instant, - child: Rc::new(self.metric(e)?), - }), + Expr::MetricExpression(e) => { + let scan = self.metric(e)?; + let metric = e + .metric_name() + .expect("a lowered selector names its metric"); + Ok(U::TimeRange { + range: (self.sample_interval_of)(metric) + .ok_or(WorkloadError::MissingSampleInterval)?, + kind: TimeRangeKind::Instant, + child: Rc::new(scan), + }) + } Expr::Rollup(e) => self.rollup(e), Expr::Function(e) => self.function(e), Expr::Aggregation(e) => self.aggregate(e), diff --git a/crates/frontend-metricsql/tests/lowering.rs b/crates/frontend-metricsql/tests/lowering.rs index 089e1d473..a4c8dd689 100644 --- a/crates/frontend-metricsql/tests/lowering.rs +++ b/crates/frontend-metricsql/tests/lowering.rs @@ -8,7 +8,7 @@ use asap_types::ir::{NonASAPOp, OperatorNode, TimeRangeKind}; use asap_types::pre_asap::{AggIntent, Reduction, Source}; use asap_types::types::AccuracyTarget; -/// The workload's ingestion interval, the lookback of a bare selector. +/// Every metric's sample interval, the lookback of a bare selector. const INTERVAL: Duration = Duration::from_secs(15); fn lower(query: &str) -> Rc { diff --git a/crates/frontend-metricsql/tests/lowering_semantics.rs b/crates/frontend-metricsql/tests/lowering_semantics.rs index 438cf1db4..1ae78e857 100644 --- a/crates/frontend-metricsql/tests/lowering_semantics.rs +++ b/crates/frontend-metricsql/tests/lowering_semantics.rs @@ -6,8 +6,8 @@ use asap_types::ir::{NonASAPOp, OperatorNode, QueryRoot, TimeRangeKind}; use asap_types::pre_asap::AggIntent; use asap_types::types::AccuracyTarget; use asap_types::workload::{ - BatchEntry, DataWorkload, DurationMs, Evidence, PlanningWorkload, Query, QueryLanguage, - QueryRequirements, QueryWorkload, + BatchEntry, DataArrival, DataWorkload, DurationMs, PlanningWorkload, Query, QueryLanguage, + QueryRequirements, QueryWorkload, SourceFacts, WorkloadError, }; const INTERVAL: Duration = Duration::from_secs(15); @@ -33,11 +33,15 @@ fn workload(language: QueryLanguage, query: &str) -> PlanningWorkload { repeating_queries: None, }, data_workload: Some(DataWorkload { - data_ingestion_interval: Evidence { - value: Some(DurationMs(15_000)), - ..Default::default() - }, - ..Default::default() + arrival: DataArrival::Unknown, + sources: [( + "up".to_string(), + SourceFacts { + sample_interval: Some(DurationMs(15_000)), + ..SourceFacts::default() + }, + )] + .into(), }), } } @@ -86,10 +90,10 @@ fn rollups_without_an_explicit_range_are_rejected() { assert!(lower_metricsql("rate(m[5m])", AccuracyTarget::exact()).is_ok()); } -/// A bare instant selector reads the latest sample within the ingestion +/// A bare instant selector reads the latest sample within the sample /// interval, as in PromQL; an explicit range keeps its own window. #[test] -fn instant_selectors_look_back_one_ingestion_interval() { +fn instant_selectors_look_back_one_sample_interval() { let node = lower_metricsql("sum(up)", AccuracyTarget::exact()).unwrap(); assert_eq!( time_range_under_aggregate(&node), @@ -102,12 +106,13 @@ fn instant_selectors_look_back_one_ingestion_interval() { ); } -/// The workload entry takes the interval from the workload and checks its -/// language and evidence, as `lower_promql_query_workload` does. +/// The workload entry takes each selector's interval from its metric's +/// source facts and checks its language, as `lower_promql_query_workload` +/// does; a metric without an interval fails, whatever the arrival. #[test] -fn workload_lowering_uses_the_declared_ingestion_interval() { +fn workload_lowering_uses_the_declared_sample_interval() { let roots = - lower_metricsql_query_workload(&workload(QueryLanguage::MetricsQL, "sum(up)"), 0).unwrap(); + lower_metricsql_query_workload(&workload(QueryLanguage::MetricsQL, "sum(up)")).unwrap(); let [QueryRoot::Operator(node)] = roots.as_slice() else { panic!("expected one operator root, got {roots:?}"); }; @@ -117,13 +122,28 @@ fn workload_lowering_uses_the_declared_ingestion_interval() { ); assert!(matches!( - lower_metricsql_query_workload(&workload(QueryLanguage::PromQL, "sum(up)"), 0), + lower_metricsql_query_workload(&workload(QueryLanguage::PromQL, "sum(up)")), Err(MetricsqlError::WrongLanguage(_)) )); let mut missing = workload(QueryLanguage::MetricsQL, "sum(up)"); missing.data_workload = Some(DataWorkload::default()); - assert!(matches!( - lower_metricsql_query_workload(&missing, 0), - Err(MetricsqlError::InvalidWorkload(_)) - )); + let mut other_metric = workload(QueryLanguage::MetricsQL, "sum(up) + sum(down)"); + other_metric.data_workload.as_mut().unwrap().arrival = DataArrival::Ingesting { + rows_per_second: 1.0, + }; + for workload in [missing, other_metric] { + assert!(matches!( + lower_metricsql_query_workload(&workload), + Err(MetricsqlError::InvalidWorkload( + WorkloadError::MissingSampleInterval + )) + )); + } + // A rollup over an explicit range needs no sample interval. + let mut at_rest = workload(QueryLanguage::MetricsQL, "sum(rate(up[5m]))"); + at_rest.data_workload = Some(DataWorkload { + arrival: DataArrival::AtRest, + ..Default::default() + }); + lower_metricsql_query_workload(&at_rest).unwrap(); } diff --git a/crates/frontend-promql/src/lib.rs b/crates/frontend-promql/src/lib.rs index 17601ffb6..c5254f104 100644 --- a/crates/frontend-promql/src/lib.rs +++ b/crates/frontend-promql/src/lib.rs @@ -12,26 +12,27 @@ pub mod error; pub mod histogram; pub mod promql; +use std::collections::BTreeSet; use std::rc::Rc; use asap_types::ir::OperatorNode; -use asap_types::workload::{DurationMs, PlanningWorkload, QueryLanguage, WorkloadError}; +use asap_types::workload::{DurationMs, PlanningWorkload, QueryLanguage}; pub use error::PromqlError; pub use histogram::{HistogramCatalog, HistogramKind}; /// Lower every normalized PromQL workload entry to a plan-ready operator DAG. /// -/// PromQL workloads must declare a non-zero `data_ingestion_interval`; it is -/// injected around each bare instant selector. Explicit range selectors keep -/// their query-specified range. -/// `now_ms` is the planning time in Unix milliseconds; cadence evidence must -/// be valid at that time, using the same clock as downstream planning. +/// A bare instant selector reads the latest sample within its metric's +/// `sample_interval`, declared in the data workload's +/// [`SourceFacts`](asap_types::workload::SourceFacts) whatever the arrival; +/// a query with one fails with `WorkloadError::MissingSampleInterval` when +/// that metric declares none. Explicit range selectors keep their +/// query-specified range and need no interval. pub fn lower_promql_workload( workload: &PlanningWorkload, - now_ms: u64, ) -> Result>, PromqlError> { - lower_promql_workload_inner(workload, now_ms) + lower_promql_workload_inner(workload) } /// Like [`lower_promql_workload`], but uses `histograms` to distinguish classic @@ -39,34 +40,37 @@ pub fn lower_promql_workload( pub fn lower_promql_workload_with_histograms( workload: &PlanningWorkload, histograms: HistogramCatalog, - now_ms: u64, ) -> Result>, PromqlError> { let _guard = histogram::CatalogGuard::install(histograms); - lower_promql_workload_inner(workload, now_ms) + lower_promql_workload_inner(workload) +} + +/// The metrics `query`'s selectors read, by name: the sources whose facts +/// (such as `sample_interval`) its lowering looks up. Selectors that +/// lowering rejects, such as one with no metric name, are skipped. +pub fn promql_metric_names(query: &str) -> Result, PromqlError> { + promql::metric_names(query) } /// Lower scalar and vector query roots without introducing constant operators. pub fn lower_promql_query_workload( workload: &PlanningWorkload, - now_ms: u64, ) -> Result, PromqlError> { - lower_promql_query_workload_inner(workload, now_ms) + lower_promql_query_workload_inner(workload) } pub fn lower_promql_query_workload_with_histograms( workload: &PlanningWorkload, histograms: HistogramCatalog, - now_ms: u64, ) -> Result, PromqlError> { let _guard = histogram::CatalogGuard::install(histograms); - lower_promql_query_workload_inner(workload, now_ms) + lower_promql_query_workload_inner(workload) } fn lower_promql_workload_inner( workload: &PlanningWorkload, - now_ms: u64, ) -> Result>, PromqlError> { - lower_promql_query_workload_inner(workload, now_ms)? + lower_promql_query_workload_inner(workload)? .into_iter() .map(|root| match root { asap_types::ir::QueryRoot::Operator(node) => Ok(node), @@ -79,7 +83,6 @@ fn lower_promql_workload_inner( fn lower_promql_query_workload_inner( workload: &PlanningWorkload, - now_ms: u64, ) -> Result, PromqlError> { if !matches!(workload.query_workload.language, QueryLanguage::PromQL) { return Err(PromqlError::WrongLanguage(format!( @@ -88,21 +91,19 @@ fn lower_promql_query_workload_inner( ))); } workload.validate()?; - let &DurationMs(interval_ms) = workload - .data_workload - .as_ref() - .expect("validated PromQL workload has data_workload") - .data_ingestion_interval - .value_at(now_ms) - .ok_or(WorkloadError::UnavailableDataIngestionInterval)?; + let sample_interval_of = |metric: &str| { + workload + .sample_interval_of(metric) + .map(|DurationMs(ms)| std::time::Duration::from_millis(ms)) + }; workload .query_workload .entries() .map(|entry| { - let root = promql::PromqlLowerer::lower_query_with_ingestion_interval( + let root = promql::PromqlLowerer::lower_query_with_sample_intervals( &entry.query.0, &entry.requirements.accuracy.clone(), - std::time::Duration::from_millis(interval_ms), + &sample_interval_of, )?; Ok(root) }) @@ -111,60 +112,24 @@ fn lower_promql_query_workload_inner( #[cfg(test)] mod tests { - // Expiring evidence without an observation timestamp is never usable. - #[test] - fn rejects_unusable_ingestion_evidence() { - let mut input = workload("sum(data)"); - input - .data_workload - .as_mut() - .unwrap() - .data_ingestion_interval - .valid_for_ms = Some(100); - assert!(lower_promql_workload(&input, 0).is_err()); - } - - // Cadence expiry is inclusive; future and expired evidence cannot set a horizon. - #[test] - fn ingestion_evidence_respects_planning_time_with_and_without_histograms() { - let mut input = workload("sum(data)"); - let evidence = &mut input - .data_workload - .as_mut() - .unwrap() - .data_ingestion_interval; - evidence.observed_at_ms = Some(1_000); - evidence.valid_for_ms = Some(100); - for (now_ms, usable) in [(999, false), (1_000, true), (1_100, true), (1_101, false)] { - assert_eq!(lower_promql_workload(&input, now_ms).is_ok(), usable); - assert_eq!( - lower_promql_workload_with_histograms(&input, HistogramCatalog::default(), now_ms) - .is_ok(), - usable - ); - } - input - .data_workload - .as_mut() - .unwrap() - .data_ingestion_interval - .observed_at_ms = None; - assert!( - lower_promql_workload_with_histograms(&input, HistogramCatalog::default(), 1_000) - .is_err() - ); - } use std::time::Duration; - use asap_types::ir::{NonASAPOp, TimeRangeKind}; + use asap_types::ir::{NonASAPOp, OperatorNode, TimeRangeKind}; + use asap_types::pre_asap::Source; use asap_types::workload::{ - BatchEntry, DataWorkload, Evidence, PlanningWorkload, Query, QueryRequirements, - QueryWorkload, + BatchEntry, DataArrival, DataWorkload, PlanningWorkload, Query, QueryRequirements, + QueryWorkload, SourceFacts, WorkloadError, }; use super::*; + /// `query` over data whose metrics `data` and `other` are sampled every + /// 1 s and 30 s. fn workload(query: &str) -> PlanningWorkload { + let sampled = |ms: u64| SourceFacts { + sample_interval: Some(DurationMs(ms)), + ..SourceFacts::default() + }; PlanningWorkload { query_workload: QueryWorkload { language: QueryLanguage::PromQL, @@ -178,20 +143,42 @@ mod tests { repeating_queries: None, }, data_workload: Some(DataWorkload { - data_ingestion_interval: Evidence { - value: Some(DurationMs(1_000)), - ..Default::default() - }, - ..Default::default() + arrival: DataArrival::Unknown, + sources: [ + ("data".to_string(), sampled(1_000)), + ("other".to_string(), sampled(30_000)), + ] + .into(), }), } } - // A bare instant selector reads the latest sample within the declared - // ingestion interval: an `Instant` lookback of that length. + /// Each `Instant` lookback under `node`, with the metric it reads. + fn instant_lookbacks(node: &Rc) -> Vec<(String, Duration)> { + OperatorNode::reachable(node) + .iter() + .filter_map(|node| match node.non_asap()? { + NonASAPOp::TimeRange { + range, + kind: TimeRangeKind::Instant, + child, + } => match child.non_asap()? { + NonASAPOp::Scan { + source: Source::TimeSeries { metric }, + .. + } => Some((metric.clone(), *range)), + _ => None, + }, + _ => None, + }) + .collect() + } + + // A bare instant selector reads the latest sample within its metric's + // declared sample interval: an `Instant` lookback of that length. #[test] - fn instant_selector_uses_declared_ingestion_interval() { - let query = lower_promql_workload(&workload("sum by (job) (data)"), 0).unwrap(); + fn instant_selector_uses_declared_sample_interval() { + let query = lower_promql_workload(&workload("sum by (job) (data)")).unwrap(); let NonASAPOp::Aggregate { child, .. } = query[0].expect_non_asap() else { panic!("expected aggregate") }; @@ -203,10 +190,26 @@ mod tests { ); } + // Two metrics sampled at different intervals: each selector looks back + // its own metric's interval. + #[test] + fn each_instant_selector_uses_its_own_metrics_sample_interval() { + let query = lower_promql_workload(&workload("sum(data) + sum(other)")).unwrap(); + let mut lookbacks = instant_lookbacks(&query[0]); + lookbacks.sort(); + assert_eq!( + lookbacks, + [ + ("data".to_string(), Duration::from_secs(1)), + ("other".to_string(), Duration::from_secs(30)), + ] + ); + } + // An explicit `m[5m]` keeps its own window as a `Range` selection. #[test] fn explicit_range_selector_keeps_its_query_range() { - let query = lower_promql_workload(&workload("sum_over_time(data[5m])"), 0).unwrap(); + let query = lower_promql_workload(&workload("sum_over_time(data[5m])")).unwrap(); let NonASAPOp::Aggregate { child, .. } = query[0].expect_non_asap() else { panic!("expected aggregate") }; @@ -218,15 +221,61 @@ mod tests { ); } + // A metric without a declared sample interval, whatever the arrival + // (and with no data workload): an instant selector over it (bare, under + // an aggregation, or inside a subquery) fails, with and without + // histograms, even when another metric declares one; range-only + // queries still lower. #[test] - fn workload_without_interval_fails_loudly() { - let mut workload = workload("sum(data)"); - workload.data_workload = Some(DataWorkload::default()); - assert!(matches!( - lower_promql_workload(&workload, 0), - Err(PromqlError::InvalidWorkload( - asap_types::workload::WorkloadError::MissingDataIngestionInterval - )) - )); + fn instant_selector_needs_its_metrics_sample_interval() { + let arrivals = [ + Some(DataArrival::AtRest), + Some(DataArrival::Unknown), + Some(DataArrival::Ingesting { + rows_per_second: 1.0, + }), + None, + ]; + for arrival in arrivals { + let with = |query: &str| { + let mut input = workload(query); + match arrival { + Some(arrival) => input.data_workload.as_mut().unwrap().arrival = arrival, + None => input.data_workload = None, + } + input + }; + for query in [ + "undeclared", + "sum(undeclared)", + "max_over_time(undeclared[5m:1m])", + "sum(data) + sum(undeclared)", + ] { + assert!( + matches!( + lower_promql_workload(&with(query)), + Err(PromqlError::InvalidWorkload( + WorkloadError::MissingSampleInterval + )) + ), + "{arrival:?} {query}" + ); + assert!(matches!( + lower_promql_workload_with_histograms( + &with(query), + HistogramCatalog::default() + ), + Err(PromqlError::InvalidWorkload( + WorkloadError::MissingSampleInterval + )) + )); + } + lower_promql_workload(&with("quantile_over_time(0.99, undeclared[5y] offset 1y)")) + .unwrap(); + // The declared metric lowers whatever the arrival. + if arrival.is_some() { + lower_promql_workload(&with("sum(data)")).unwrap(); + } + } } } diff --git a/crates/frontend-promql/src/promql.rs b/crates/frontend-promql/src/promql.rs index 82cd8df61..e510a2e08 100644 --- a/crates/frontend-promql/src/promql.rs +++ b/crates/frontend-promql/src/promql.rs @@ -56,6 +56,7 @@ //! | `expr[r:res]` | `PromqlSubquery{r, res}` | //! | ` offset ` / ` @ `/`start()`/`end()` | `TimeShift{shift}` over the selector's `Scan` — pass-through schema; a ranged selector shifts under its `TimeRange` (issue #40) | +use std::collections::BTreeMap; use std::rc::Rc; use std::time::{Duration, SystemTime}; @@ -80,6 +81,7 @@ use asap_types::pre_asap::{ ArithmeticOpKind, ColumnRef, CompareOpKind, InfoMatcher, SampleKind, ScalarValue, }; use asap_types::types::AccuracyTarget; +use asap_types::workload::WorkloadError; /// Every scalar expression this front end builds follows PromQL's numeric rules. const PROMQL: ExprSemantics = ExprSemantics::Promql; @@ -181,17 +183,25 @@ struct Inner { const MAX_DEPTH: usize = 256; impl PromqlLowerer { - pub(crate) fn lower_query_with_ingestion_interval( + /// Lower `query`; a bare instant selector over metric `m` reads the + /// latest sample within `sample_interval_of(m)`, and fails with + /// [`WorkloadError::MissingSampleInterval`] when it is `None`. + pub(crate) fn lower_query_with_sample_intervals( query: &str, accuracy: &AccuracyTarget, - interval: Duration, + sample_interval_of: &dyn Fn(&str) -> Option, ) -> Result { let _guard = AccuracyGuard::install(accuracy.clone()); - let _interval = IngestionIntervalGuard::install(interval); let ast = parser::parse(query).map_err(LoweringError::Parse)?; check_depth(&ast, MAX_DEPTH)?; let mut metrics = Vec::new(); collect_metric_names(&ast, &mut metrics); + let _intervals = SampleIntervalGuard::install( + metrics + .iter() + .filter_map(|metric| Some((metric.clone(), sample_interval_of(metric)?))) + .collect(), + ); if metrics.iter().any(|metric| { crate::histogram::current_kind_of(metric) == Some(crate::histogram::HistogramKind::Native) @@ -213,10 +223,20 @@ impl PromqlLowerer { } } +/// The metric names `query`'s selectors read ([`crate::promql_metric_names`]). +pub(crate) fn metric_names(query: &str) -> Result> { + let ast = parser::parse(query).map_err(LoweringError::Parse)?; + check_depth(&ast, MAX_DEPTH)?; + let mut metrics = Vec::new(); + collect_metric_names(&ast, &mut metrics); + Ok(metrics.into_iter().collect()) +} + std::thread_local! { static ACCURACY: std::cell::RefCell = const { std::cell::RefCell::new(AccuracyTarget::exact()) }; - static INGESTION_INTERVAL: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; + static SAMPLE_INTERVALS: std::cell::RefCell> = + const { std::cell::RefCell::new(BTreeMap::new()) }; } /// RAII guard installing `accuracy` as the ambient accuracy target for the @@ -243,26 +263,30 @@ fn current_accuracy() -> AccuracyTarget { ACCURACY.with(|a| a.borrow().clone()) } -struct IngestionIntervalGuard(Option); +/// RAII guard installing the sample interval of each metric the query reads +/// that declares one, for the current thread's lowering. +struct SampleIntervalGuard(BTreeMap); -impl IngestionIntervalGuard { - fn install(interval: Duration) -> Self { - Self(INGESTION_INTERVAL.with(|current| current.replace(Some(interval)))) +impl SampleIntervalGuard { + fn install(sample_intervals: BTreeMap) -> Self { + Self(SAMPLE_INTERVALS.with(|current| current.replace(sample_intervals))) } } -impl Drop for IngestionIntervalGuard { +impl Drop for SampleIntervalGuard { fn drop(&mut self) { - INGESTION_INTERVAL.with(|current| *current.borrow_mut() = self.0.take()); + SAMPLE_INTERVALS.with(|current| *current.borrow_mut() = std::mem::take(&mut self.0)); } } -fn current_ingestion_interval() -> Duration { - INGESTION_INTERVAL.with(|current| { - current - .borrow() - .expect("ingestion interval is installed for workload lowering") - }) +/// `metric`'s installed sample interval, which a bare instant selector over +/// it needs. +fn current_sample_interval(metric: &str) -> Result { + SAMPLE_INTERVALS + .with(|current| current.borrow().get(metric).copied()) + .ok_or(LoweringError::InvalidWorkload( + WorkloadError::MissingSampleInterval, + )) } /// Bounded depth check over the parser AST: errors once nesting would exceed @@ -354,7 +378,7 @@ fn walk(expr: &Expr) -> Result { } Expr::VectorSelector(vs) => { let (metric, matchers, shift) = vs_parts(vs)?; - Ok(instant_source(metric, matchers, shift)) + instant_source(metric, matchers, shift) } Expr::MatrixSelector(ms) => { let (metric, matchers, shift) = vs_parts(&ms.vs)?; @@ -1358,23 +1382,21 @@ fn histogram_arg_is_sketchable(arg: &Expr) -> Result { } } -/// Collect the metric names of every vector/matrix selector reachable in `expr` -/// (for the metadata lookup in [`histogram_arg_is_sketchable`]). Skips -/// name-less selectors like `{le="…"}`. +/// Collect the metric names of every vector/matrix selector reachable in +/// `expr`: the metrics whose histogram kind ([`histogram_arg_is_sketchable`]) +/// and sample interval (`lower_query_with_sample_intervals`) lowering looks +/// up. Skips selectors that lowering rejects, such as `{le="…"}` with no +/// metric name. fn collect_metric_names(expr: &Expr, out: &mut Vec) { match expr { Expr::VectorSelector(vs) => { if let Ok((metric, ..)) = vs_parts(vs) { - if !metric.is_empty() { - out.push(metric); - } + out.push(metric); } } Expr::MatrixSelector(ms) => { if let Ok((metric, ..)) = vs_parts(&ms.vs) { - if !metric.is_empty() { - out.push(metric); - } + out.push(metric); } } Expr::Paren(p) => collect_metric_names(&p.expr, out), @@ -1630,10 +1652,10 @@ fn lower_inner_call(call: &Call) -> Result { fn build(inner: Inner, keys: Vec, outer: Outer) -> Result { match outer { Outer::None => match &inner.func { - None => Ok(instant_source(inner.metric, inner.matchers, inner.shift)), + None => instant_source(inner.metric, inner.matchers, inner.shift), Some(f) => { let intent = inner_intent(f); - Ok(windowed_aggregate(inner, keys, intent)) + windowed_aggregate(inner, keys, intent) } }, // An OUTER aggregation operator (`sum`/`avg`/…/`count`) over an inner @@ -1642,26 +1664,26 @@ fn build(inner: Inner, keys: Vec, outer: Outer) -> Result // then aggregates across series. Collapsing them into one aggregate // silently drops a level — e.g. `sum(rate(m[w]))` must keep the `sum`. Outer::Plain(intent) => Ok(match &inner.func { - None => windowed_aggregate(inner, keys, outer_intent(&intent)), + None => windowed_aggregate(inner, keys, outer_intent(&intent))?, Some(f) => { let inner_i = inner_intent(f); - let inner_agg = windowed_aggregate(inner, vec![], inner_i); + let inner_agg = windowed_aggregate(inner, vec![], inner_i)?; outer_aggregate(keys, outer_intent(&intent), inner_agg) } }), Outer::Count => Ok(match &inner.func { - None => windowed_aggregate(inner, keys, count()), + None => windowed_aggregate(inner, keys, count())?, Some(f) => { let inner_i = inner_intent(f); - let inner_agg = windowed_aggregate(inner, vec![], inner_i); + let inner_agg = windowed_aggregate(inner, vec![], inner_i)?; outer_aggregate(keys, count(), inner_agg) } }), Outer::CountValues { label } => Ok(match &inner.func { - None => windowed_aggregate(inner, keys, AggIntent::CountValues { label }), + None => windowed_aggregate(inner, keys, AggIntent::CountValues { label })?, Some(f) => { let inner_i = inner_intent(f); - let inner_agg = windowed_aggregate(inner, vec![], inner_i); + let inner_agg = windowed_aggregate(inner, vec![], inner_i)?; outer_aggregate(keys, AggIntent::CountValues { label }, inner_agg) } }), @@ -1671,8 +1693,8 @@ fn build(inner: Inner, keys: Vec, outer: Outer) -> Result // preserving), a bare selector is sampled directly; neither is // wrapped in a reducing aggregate (issue #86). let base = match inner.func.as_ref().map(inner_intent) { - Some(intent) => windowed_aggregate(inner, vec![], intent), - None => instant_source(inner.metric, inner.matchers, inner.shift), + Some(intent) => windowed_aggregate(inner, vec![], intent)?, + None => instant_source(inner.metric, inner.matchers, inner.shift)?, }; Ok(Unresolved::PromqlSeriesSample { by: keys.into(), @@ -1686,7 +1708,7 @@ fn build(inner: Inner, keys: Vec, outer: Outer) -> Result // neither is allowed to sum raw counter samples as ranking weights. if descending && matches!(inner.func, Some(InnerFunc::Rate | InnerFunc::Increase)) { let intent = inner_intent(inner.func.as_ref().expect("counter function")); - let ranked = windowed_aggregate(inner, vec![], intent); + let ranked = windowed_aggregate(inner, vec![], intent)?; return Ok(Unresolved::Aggregate { reduction: Reduction::Reduce(keys.into()), measures: vec![AggIntent::TopK { @@ -1726,7 +1748,7 @@ fn build(inner: Inner, keys: Vec, outer: Outer) -> Result unreachable!("heavy-hitter gate rejected non-additive ranking") } }; - let ranked_agg = windowed_aggregate(inner, vec![], inner_intent(&ranked)); + let ranked_agg = windowed_aggregate(inner, vec![], inner_intent(&ranked))?; Ok(Unresolved::Aggregate { // A ranking always reduces (a `by`-empty TopK ranks the // whole input into one ordering, never per-entity). @@ -1753,8 +1775,8 @@ fn build(inner: Inner, keys: Vec, outer: Outer) -> Result // resolve (issue #30). Keep the selector label-preserving so // `Sort.partition_by` can rank within each group (issue #12). let base = match inner.func.as_ref().map(inner_intent) { - Some(intent) => windowed_aggregate(inner, vec![], intent), - None => instant_source(inner.metric, inner.matchers, inner.shift), + Some(intent) => windowed_aggregate(inner, vec![], intent)?, + None => instant_source(inner.metric, inner.matchers, inner.shift)?, }; Ok(ranked_by_value(keys, k, descending, base)) } @@ -1787,18 +1809,17 @@ fn windowed_aggregate( inner: Inner, keys: Vec, intent: AggIntent, -) -> Unresolved { - let base = filtered_source(inner.metric, inner.matchers, inner.shift); +) -> Result { let child = match inner.window { Some(w) => Unresolved::TimeRange { range: w, kind: TimeRangeKind::Range, - child: Rc::new(base), + child: Rc::new(filtered_source(inner.metric, inner.matchers, inner.shift)), }, - None => ingestion_lookback(base), + None => instant_source(inner.metric, inner.matchers, inner.shift)?, }; let reduction = reduction_for(&keys, inner.window.is_some() || intent.is_per_series()); - Unresolved::Aggregate { + Ok(Unresolved::Aggregate { reduction, measures: vec![intent], // A single empty entry — never an override — so the resolver keeps @@ -1808,7 +1829,7 @@ fn windowed_aggregate( filters: vec![], having: None, child: Rc::new(child), - } + }) } /// `Aggregate{reduction, [intent]}` directly over an existing Unresolved sub-DAG — the @@ -1867,18 +1888,15 @@ fn filtered_source(metric: String, matchers: Vec, shift: TimeShift) -> U } } -/// An instant selector: the latest sample per series within the workload's -/// ingestion interval, so the lookback is an `Instant` `TimeRange`. -fn instant_source(metric: String, matchers: Vec, shift: TimeShift) -> Unresolved { - ingestion_lookback(filtered_source(metric, matchers, shift)) -} - -fn ingestion_lookback(child: Unresolved) -> Unresolved { - Unresolved::TimeRange { - range: current_ingestion_interval(), +/// An instant selector: the latest sample per series within its metric's +/// sample interval, so the lookback is an `Instant` `TimeRange`. +fn instant_source(metric: String, matchers: Vec, shift: TimeShift) -> Result { + let interval = current_sample_interval(&metric)?; + Ok(Unresolved::TimeRange { + range: interval, kind: TimeRangeKind::Instant, - child: Rc::new(child), - } + child: Rc::new(filtered_source(metric, matchers, shift)), + }) } /// Count vector elements regardless of their sample values. @@ -2039,14 +2057,23 @@ fn vs_parts(vs: &VectorSelector) -> Result<(String, Vec, TimeShift)> { m.name, m.op ))); } - let metric = vs.name.clone().unwrap_or_else(|| { - vs.matchers - .matchers - .iter() - .find(|m| m.name == "__name__") - .map(|m| m.value.clone()) - .unwrap_or_default() - }); + // A selector with no metric name (`{job="a"}`) selects across every + // metric, which one `Source::TimeSeries { metric }` cannot represent + // either; reject it, as the MetricsQL frontend does. + let metric = vs + .name + .clone() + .or_else(|| { + vs.matchers + .matchers + .iter() + .find(|m| m.name == "__name__") + .map(|m| m.value.clone()) + }) + .filter(|metric| !metric.is_empty()) + .ok_or_else(|| { + LoweringError::UnsupportedFeature("selector without one exact metric name".into()) + })?; // Label matchers are an unordered set: `{a="1",b="2"}` and `{b="2",a="1"}` // select the same series. Canonicalise by (name, value) so equivalent // selectors lower to identical predicates. diff --git a/crates/frontend-promql/tests/observability/awesome_prometheus_alerts.rs b/crates/frontend-promql/tests/observability/awesome_prometheus_alerts.rs index a2dececd1..85cca5f52 100644 --- a/crates/frontend-promql/tests/observability/awesome_prometheus_alerts.rs +++ b/crates/frontend-promql/tests/observability/awesome_prometheus_alerts.rs @@ -132,7 +132,7 @@ fn corpus_lowering_is_total_and_fully_parseable() { fn vector_vs_vector_comparison_lowers_to_binaryop() { // node-exporter: `node_hwmon_temp_celsius > node_hwmon_temp_max_celsius`. // Both operands are instant vectors → a `BinaryOp{Compare}` of two - // ingestion-interval-bounded scans. + // sample-interval-bounded scans. let qe = ok("node_hwmon_temp_celsius > node_hwmon_temp_max_celsius"); let NonASAPOp::BinaryOp { operator: BinaryOperator { kind: op, .. }, diff --git a/crates/frontend-promql/tests/observability/promql_corpus.rs b/crates/frontend-promql/tests/observability/promql_corpus.rs index d460a9257..6f96c901e 100644 --- a/crates/frontend-promql/tests/observability/promql_corpus.rs +++ b/crates/frontend-promql/tests/observability/promql_corpus.rs @@ -30,16 +30,23 @@ fn queries(corpus: &str) -> impl Iterator { .filter(|l| !l.is_empty() && !l.starts_with('#')) } +/// The lowering error of a selector with no metric name (`{job="a"}`). +const NO_METRIC_NAME: &str = "selector without one exact metric name"; + #[derive(Default, Debug)] struct Tally { lowered: usize, + /// Rejected because a selector has no metric name: expected, since a + /// `Source::TimeSeries` names exactly one metric. + no_metric_name: usize, + /// Rejected for any other unsupported feature. rejected: usize, unparseable: usize, } impl Tally { fn total(&self) -> usize { - self.lowered + self.rejected + self.unparseable + self.lowered + self.no_metric_name + self.rejected + self.unparseable } } @@ -47,12 +54,13 @@ impl Tally { fn tally(corpus: &str) -> Tally { let mut t = Tally::default(); for q in queries(corpus) { - match asap_frontend_promql::lower_promql_query_workload( - &support::workload(q, AccuracyTarget::exact()), - 0, - ) { + match asap_frontend_promql::lower_promql_query_workload(&support::workload( + q, + AccuracyTarget::exact(), + )) { Ok(_) => t.lowered += 1, Err(LoweringError::Parse(_)) => t.unparseable += 1, + Err(e) if e.to_string().contains(NO_METRIC_NAME) => t.no_metric_name += 1, Err(_) => t.rejected += 1, } } @@ -123,16 +131,25 @@ fn lowering_is_total_over_the_entire_corpus() { ); // Coverage tripwire after rejecting unrepresented native histogram samples: - // docs 48 lowered / 1 rejected; testdata 1121 lowered / 469 rejected / - // 233 parser gaps. Earlier coverage counted native histogram operations - // incorrectly treated as float quantiles. Keep the rejection cases in the - // corpus: accepting them requires a native histogram sample representation. + // docs 48 lowered / 1 rejected; testdata 1097 lowered / 29 without a + // metric name / 464 rejected / 233 parser gaps. Earlier coverage counted + // native histogram operations incorrectly treated as float quantiles. Keep + // the rejection cases in the corpus: accepting them requires a native + // histogram sample representation. + // + // Expected unsupported: the 29 testdata queries whose selectors name no + // metric (`sum({idx="0"})`, `absent_over_time({job="grok"}[20m])`, + // `rate({env="1"}[10m])`, ...). Such a selector reads every metric, and a + // `Source::TimeSeries` names exactly one, so lowering rejects it as the + // MetricsQL frontend does. + assert_eq!(docs.no_metric_name, 0, "{docs:?}"); + assert_eq!(td.no_metric_name, 29, "{td:?}"); assert!( docs.lowered >= 47, "docs lowering coverage regressed: {docs:?}" ); assert!( - td.lowered >= 1121, + td.lowered >= 1097, "testdata lowering coverage regressed: {td:?}" ); } diff --git a/crates/frontend-promql/tests/promql_conformance.rs b/crates/frontend-promql/tests/promql_conformance.rs index 45e919f8c..62d0c769d 100644 --- a/crates/frontend-promql/tests/promql_conformance.rs +++ b/crates/frontend-promql/tests/promql_conformance.rs @@ -185,6 +185,28 @@ fn name_regex_matcher_is_rejected__GAP() { assert_eq!(metric, "up"); } +#[test] +fn selector_without_metric_name_is_rejected__GAP() { + // A selector with no metric name (`{job="a"}`) selects across every + // metric, which the single-metric `Source::TimeSeries { metric }` can't + // represent; it is rejected, as in the MetricsQL frontend, rather than + // scanning a source named "". + for query in [ + r#"{job="a"}"#, + r#"rate({job="a"}[5m])"#, + r#"sum by (job) ({job="a"})"#, + r#"histogram_quantile(0.9, sum by (le) (rate({le!=""}[5m])))"#, + ] { + let error = rejected(query); + assert!( + error + .to_string() + .contains("selector without one exact metric name"), + "{query}: {error}" + ); + } +} + #[test] fn range_vector_selector_is_time_range() { // SEMANTICS: `[5m]` turns an instant vector into a range vector, @@ -203,8 +225,8 @@ fn range_vector_selector_is_time_range() { #[test] fn selector_time_ranges_carry_their_kind() { - // SEMANTICS: an instant selector reads the latest sample within the - // ingestion interval (`Instant`); `m[5m]` is a range selection (`Range`). + // SEMANTICS: an instant selector reads the latest sample within its + // metric's sample interval (`Instant`); `m[5m]` is a range selection (`Range`). // Same length is not the same shape: `m` and `m[1s]` stay distinct. assert!(matches!( ok("node_cpu_seconds_total").expect_non_asap(), diff --git a/crates/frontend-promql/tests/promql_lowering.rs b/crates/frontend-promql/tests/promql_lowering.rs index 81fdeec23..b23fb174a 100644 --- a/crates/frontend-promql/tests/promql_lowering.rs +++ b/crates/frontend-promql/tests/promql_lowering.rs @@ -11,8 +11,8 @@ use asap_types::pre_asap::{ }; use asap_types::types::AccuracyTarget; use asap_types::workload::{ - BatchEntry, DataWorkload, DurationMs, Evidence, PlanningWorkload, Predictability, Query, - QueryLanguage, QueryRequirements, QueryWorkload, + BatchEntry, PlanningWorkload, Predictability, Query, QueryLanguage, QueryRequirements, + QueryWorkload, }; use asap_frontend_promql::{lower_promql_workload, PromqlError as LoweringError}; @@ -1188,15 +1188,10 @@ fn batch_lowers_each_entry_and_reads_per_query_accuracy() { ]), repeating_queries: None, }, - data_workload: Some(DataWorkload { - data_ingestion_interval: Evidence { - value: Some(DurationMs(1_000)), - ..Default::default() - }, - ..Default::default() - }), + // Range selectors only: no sample interval is needed. + data_workload: None, }; - let results = lower_promql_workload(&workload, 0).expect("valid workload"); + let results = lower_promql_workload(&workload).expect("valid workload"); assert_eq!(results.len(), 2); } @@ -1218,7 +1213,7 @@ fn batch_rejects_non_promql_language() { data_workload: None, }; assert!(matches!( - lower_promql_workload(&workload, 0), + lower_promql_workload(&workload), Err(LoweringError::WrongLanguage(_)) )); } diff --git a/crates/frontend-promql/tests/scalar_design.rs b/crates/frontend-promql/tests/scalar_design.rs index ca51bd0dc..888d7a086 100644 --- a/crates/frontend-promql/tests/scalar_design.rs +++ b/crates/frontend-promql/tests/scalar_design.rs @@ -5,10 +5,10 @@ use asap_types::pre_asap::{ArithmeticOpKind, CompareOpKind, ScalarValue}; use asap_types::types::AccuracyTarget; fn root(query: &str) -> QueryRoot { - asap_frontend_promql::lower_promql_query_workload( - &support::workload(query, AccuracyTarget::exact()), - 0, - ) + asap_frontend_promql::lower_promql_query_workload(&support::workload( + query, + AccuracyTarget::exact(), + )) .unwrap() .remove(0) } diff --git a/crates/frontend-promql/tests/support.rs b/crates/frontend-promql/tests/support.rs index d972f48ab..1c730b5ea 100644 --- a/crates/frontend-promql/tests/support.rs +++ b/crates/frontend-promql/tests/support.rs @@ -7,8 +7,8 @@ use asap_types::ir::{NonASAPOp, OperatorNode, ScalarExpr}; use asap_types::pre_asap::ScalarValue; use asap_types::types::AccuracyTarget; use asap_types::workload::{ - BatchEntry, DataWorkload, DurationMs, Evidence, PlanningWorkload, Predictability, Query, - QueryLanguage, QueryRequirements, QueryWorkload, + BatchEntry, DataWorkload, DurationMs, PlanningWorkload, Predictability, Query, QueryLanguage, + QueryRequirements, QueryWorkload, }; pub fn workload(query: &str, accuracy: AccuracyTarget) -> PlanningWorkload { @@ -27,13 +27,10 @@ pub fn workload(query: &str, accuracy: AccuracyTarget) -> PlanningWorkload { }]), repeating_queries: None, }, - data_workload: Some(DataWorkload { - data_ingestion_interval: Evidence { - value: Some(DurationMs(1_000)), - ..Default::default() - }, - ..Default::default() - }), + data_workload: Some(DataWorkload::sampled_every( + asap_frontend_promql::promql_metric_names(query).unwrap_or_default(), + DurationMs(1_000), + )), } } @@ -42,7 +39,7 @@ pub fn lower_promql( query: &str, accuracy: AccuracyTarget, ) -> Result, PromqlError> { - let mut lowered = lower_promql_workload(&workload(query, accuracy), 0)?; + let mut lowered = lower_promql_workload(&workload(query, accuracy))?; Ok(lowered.remove(0)) } @@ -53,7 +50,7 @@ pub fn lower_promql_with_histograms( histograms: HistogramCatalog, ) -> Result, PromqlError> { let mut lowered = - lower_promql_workload_with_histograms(&workload(query, accuracy), histograms, 0)?; + lower_promql_workload_with_histograms(&workload(query, accuracy), histograms)?; Ok(lowered.remove(0)) } @@ -86,10 +83,10 @@ pub fn post_asap_dag(root: &Rc) -> asap_types::ir::physical_export #[allow(dead_code)] pub fn scalar_root(query: &str) -> ScalarExpr { - match asap_frontend_promql::lower_promql_query_workload( - &workload(query, AccuracyTarget::exact()), - 0, - ) + match asap_frontend_promql::lower_promql_query_workload(&workload( + query, + AccuracyTarget::exact(), + )) .unwrap() .remove(0) { diff --git a/crates/integration-tests/src/lib.rs b/crates/integration-tests/src/lib.rs index e4fb361c7..0618b7441 100644 --- a/crates/integration-tests/src/lib.rs +++ b/crates/integration-tests/src/lib.rs @@ -17,7 +17,7 @@ pub mod fixtures { use asap_types::pre_asap::schema::{DataType, Field, Schema}; use asap_types::types::AccuracyTarget; use asap_types::workload::{ - BatchEntry, DataWorkload, DurationMs, Evidence, PlanningWorkload, Predictability, Query, + BatchEntry, DataWorkload, DurationMs, PlanningWorkload, Predictability, Query, QueryLanguage, QueryRequirements, QueryWorkload, }; use std::rc::Rc; @@ -55,15 +55,12 @@ pub mod fixtures { }]), repeating_queries: None, }, - data_workload: Some(DataWorkload { - data_ingestion_interval: Evidence { - value: Some(DurationMs(1_000)), - ..Default::default() - }, - ..Default::default() - }), + data_workload: Some(DataWorkload::sampled_every( + asap_frontend_promql::promql_metric_names(query).unwrap_or_default(), + DurationMs(1_000), + )), }; - let mut lowered = asap_frontend_promql::lower_promql_query_workload(&workload, 0)?; + let mut lowered = asap_frontend_promql::lower_promql_query_workload(&workload)?; Ok(lowered.remove(0)) } diff --git a/crates/integration-tests/tests/per_computation_candidates_sql.rs b/crates/integration-tests/tests/per_computation_candidates_sql.rs index 39b149006..5742bcd36 100644 --- a/crates/integration-tests/tests/per_computation_candidates_sql.rs +++ b/crates/integration-tests/tests/per_computation_candidates_sql.rs @@ -151,7 +151,7 @@ fn reads_ddsketch(node: &std::rc::Rc) -> bool { } /// A SQL fold over the groups of an approximate subquery counts rows the -/// data workload's input cardinality (a series count) does not bound, so +/// series count of its source does not bound, so /// it leaves its own row-count statistic, which plan selection does not /// bind: the target stays unmet. #[tokio::test] diff --git a/crates/integration-tests/tests/planner_layering_common/mod.rs b/crates/integration-tests/tests/planner_layering_common/mod.rs index 0eed6e175..e176d4a48 100644 --- a/crates/integration-tests/tests/planner_layering_common/mod.rs +++ b/crates/integration-tests/tests/planner_layering_common/mod.rs @@ -11,7 +11,7 @@ //! only possible answer; the implementer of each feature replaces the body //! with a read of the new IR. Tests that depend on them are `#[ignore]`d //! with the feature. -#![allow(dead_code)] +#![allow(dead_code, unused_imports)] use std::collections::{BTreeMap, BTreeSet, HashSet}; diff --git a/crates/integration-tests/tests/planner_layering_example1.rs b/crates/integration-tests/tests/planner_layering_example1.rs index 8c1361555..74e2b1e27 100644 --- a/crates/integration-tests/tests/planner_layering_example1.rs +++ b/crates/integration-tests/tests/planner_layering_example1.rs @@ -626,8 +626,10 @@ fn stage3_plan_selection_count_min_needs_the_counter_declaration() { .data_workload .as_mut() .unwrap() - .metric_types - .clear(); + .sources + .get_mut(EXAMPLE1_METRIC) + .unwrap() + .metric_type = None; let run = run_promql(&workload); let invalid = run.invalid(); let cms = &physical_with(&run, Q2Option::CountMinHeapPerJob).id; diff --git a/crates/integration-tests/tests/planner_layering_example2.rs b/crates/integration-tests/tests/planner_layering_example2.rs index 62a378d8c..7077122be 100644 --- a/crates/integration-tests/tests/planner_layering_example2.rs +++ b/crates/integration-tests/tests/planner_layering_example2.rs @@ -27,8 +27,8 @@ use asap_types::pre_asap::DataType; use asap_types::types::{AccuracyScope, AccuracyTarget}; use asap_types::workload::{ DataArrival, DataWorkload, DurationMs, PlanningWorkload, Predictability, Query, QueryLanguage, - QueryRequirements, QueryWorkload, Rate, RepeatedDemand, RepeatingEntry, RepetitionInterval, - SqlDialect, + QueryRequirements, QueryWorkload, RepeatedDemand, RepeatingEntry, RepetitionInterval, + SourceFacts, SqlDialect, }; use planner_layering_common::*; @@ -45,6 +45,8 @@ const SQL_Q3: &str = "SELECT SQRT(SUM(CAST(c AS DOUBLE) * CAST(c AS DOUBLE))) FR SELECT src_ip, COUNT(*) AS c FROM flows \ WHERE ts >= now() - INTERVAL '1 minute' GROUP BY src_ip)"; +/// The metric the PromQL stand-in reads. +const STANDIN_METRIC: &str = "flows_src_ip"; /// PromQL stand-in for Q1–Q3 (see the module docs). const PROMQL_Q1: &str = "distinct_over_time(flows_src_ip[1m])"; const PROMQL_Q2: &str = "entropy_over_time(flows_src_ip[1m])"; @@ -68,26 +70,33 @@ fn panel(query: &str, (epsilon, delta): (f64, f64), scope: AccuracyScope) -> Rep } } -/// The shared data workload with 10,000,000 distinct source IPs. -fn data_workload(ingestion_interval: bool) -> DataWorkload { +/// The shared data workload with 10,000,000 distinct source IPs, in the +/// SQL `flows` table and the PromQL stand-in's `flows_src_ip` metric +/// (sampled every 15 s; SQL needs no sample interval). +fn data_workload() -> DataWorkload { + let ten_million = SourceFacts { + series_count: Some(10_000_000), + ..SourceFacts::default() + }; + let sampled = SourceFacts { + sample_interval: Some(DurationMs(15_000)), + ..ten_million.clone() + }; DataWorkload { - arrival: DataArrival::ContinuouslyIngesting, - data_ingestion_interval: if ingestion_interval { - declared(DurationMs(15_000)) - } else { - Default::default() + arrival: DataArrival::Ingesting { + rows_per_second: 1_000_000.0 / 15.0, }, - ingestion_rate: declared(Rate(1_000_000.0 / 15.0)), - input_cardinality: declared(10_000_000), - stream_norms: Default::default(), - max_distinct_per_evaluation: Default::default(), - metric_types: Default::default(), + sources: [ + ("flows".to_string(), ten_million), + (STANDIN_METRIC.to_string(), sampled), + ] + .into(), } } /// The SQL queries' targets cover their whole result, as the doc's do. The /// PromQL stand-in states them per value: it reads one series, but the -/// data workload's input cardinality (the 10M source IPs) is what plan +/// series count of `flows_src_ip` (the 10M source IPs) is what plan /// selection takes as the number of values a PromQL vector returns, so a /// whole-result target would be checked over 10M values (#669b). fn workload(language: QueryLanguage, queries: [&str; 3]) -> PlanningWorkload { @@ -109,8 +118,7 @@ fn workload(language: QueryLanguage, queries: [&str; 3]) -> PlanningWorkload { .collect(), ), }, - // `data_ingestion_interval` is not needed for SQL; PromQL needs it. - data_workload: Some(data_workload(!sql)), + data_workload: Some(data_workload()), } } @@ -171,8 +179,10 @@ fn standin_with_distinct_bound(bound: Option) -> PlanningWorkload { .data_workload .as_mut() .expect("data workload") - .max_distinct_per_evaluation = - declared(BTreeMap::from([("flows_src_ip".into(), bound)])); + .sources + .get_mut(STANDIN_METRIC) + .expect("the stand-in's source") + .max_distinct_per_evaluation = Some(bound); } workload } diff --git a/crates/integration-tests/tests/planner_layering_example3.rs b/crates/integration-tests/tests/planner_layering_example3.rs index 0d1828a4a..814fb122e 100644 --- a/crates/integration-tests/tests/planner_layering_example3.rs +++ b/crates/integration-tests/tests/planner_layering_example3.rs @@ -15,12 +15,15 @@ use std::collections::BTreeSet; use asap_types::ir::flat::NodeId; use asap_types::post_asap::{SketchAlgorithm, SketchParams, SketchStatistic}; use asap_types::workload::DataArrival; + use planner_layering_common::*; fn run_a() -> Run { run_promql(&pattern_a( PatternARecurrence::OnceAdHoc, - DataArrival::Mixed, + DataArrival::Mixed { + rows_per_second: SHARED_ROWS_PER_SECOND, + }, )) } @@ -56,7 +59,12 @@ fn windowed_builds(c: &Logical) -> Vec<(NodeId, SketchAlgorithm, WindowForm, BTr /// Pattern A is one ad hoc batch of five queries run once at T; Pattern B one repeating query. #[test] fn workload_encodes_example3() { - let a = pattern_a(PatternARecurrence::OnceAdHoc, DataArrival::Mixed); + let a = pattern_a( + PatternARecurrence::OnceAdHoc, + DataArrival::Mixed { + rows_per_second: SHARED_ROWS_PER_SECOND, + }, + ); a.validate().expect("valid Pattern A"); let queries: Vec<_> = a.query_workload.entries().map(|e| e.query.0).collect(); assert_eq!(queries, PATTERN_A.map(|(q, ..)| q)); @@ -72,7 +80,9 @@ fn workload_encodes_example3() { fn stage0_query_language_frontends_a_lowers_each_query_to_its_interval() { let roots = lower_promql(&pattern_a( PatternARecurrence::OnceAdHoc, - DataArrival::Mixed, + DataArrival::Mixed { + rows_per_second: SHARED_ROWS_PER_SECOND, + }, )); let ops: Vec<_> = roots.iter().map(frontend_operations).collect(); let plain = vec![op("aggregate:quantile"), op("scan"), op("time_range")]; diff --git a/crates/integration-tests/tests/planner_layering_example4.rs b/crates/integration-tests/tests/planner_layering_example4.rs index 34d995923..2bb9cde7d 100644 --- a/crates/integration-tests/tests/planner_layering_example4.rs +++ b/crates/integration-tests/tests/planner_layering_example4.rs @@ -25,11 +25,21 @@ use planner_layering_common::*; /// Pattern A as given: one ad hoc batch at T over mixed data. fn once() -> PlanningWorkload { - pattern_a(PatternARecurrence::OnceAdHoc, DataArrival::Mixed) + pattern_a( + PatternARecurrence::OnceAdHoc, + DataArrival::Mixed { + rows_per_second: SHARED_ROWS_PER_SECOND, + }, + ) } fn monthly() -> PlanningWorkload { - pattern_a(PatternARecurrence::MonthlyPredictable, DataArrival::Mixed) + pattern_a( + PatternARecurrence::MonthlyPredictable, + DataArrival::Mixed { + rows_per_second: SHARED_ROWS_PER_SECOND, + }, + ) } fn at_rest() -> PlanningWorkload { diff --git a/crates/integration-tests/tests/precompute_raw_samples.rs b/crates/integration-tests/tests/precompute_raw_samples.rs index 1410093f8..ed9331ce5 100644 --- a/crates/integration-tests/tests/precompute_raw_samples.rs +++ b/crates/integration-tests/tests/precompute_raw_samples.rs @@ -56,10 +56,14 @@ fn canonical(labels: &Series) -> Series { fn candidates(query: &str, accuracy: AccuracyTarget) -> Vec> { use asap_aware_mapping::stage3_plan_selection::with_confirmed_weights; use asap_types::ir::QueryRoot; - use asap_types::workload::{DataWorkload, MetricType}; + use asap_types::workload::{DataWorkload, MetricType, SourceFacts}; let root = lower_promql(query, accuracy.clone()).expect("lowering failed"); + let counter = SourceFacts { + metric_type: Some(MetricType::Counter), + ..SourceFacts::default() + }; let data = DataWorkload { - metric_types: [("m".to_string(), MetricType::Counter)].into(), + sources: [("m".to_string(), counter)].into(), ..Default::default() }; logical_candidates(root, accuracy) diff --git a/crates/integration-tests/tests/reuse_rules.rs b/crates/integration-tests/tests/reuse_rules.rs index 7ae2cbd05..b88a86f80 100644 --- a/crates/integration-tests/tests/reuse_rules.rs +++ b/crates/integration-tests/tests/reuse_rules.rs @@ -12,7 +12,7 @@ use asap_aware_mapping::pass::Pass2Candidate; use asap_aware_mapping::stage3_plan_selection::select_plan; use asap_aware_mapping::{run_stage1_to_stage3, PlanningModels, Stage1ToStage3Run}; use asap_devtools::candidate_labels::pass2_label; -use asap_devtools::planner_layering_examples::shared_data_workload; +use asap_devtools::planner_layering_examples::{shared_data_workload, SHARED_ROWS_PER_SECOND}; use asap_integration_tests::fixtures::lower_promql; use asap_physical_operators::physical_planner::{compile, promql_rows, InputContract}; use asap_physical_operators::runtime::Scope; @@ -63,7 +63,12 @@ fn exact(recurrence: QueryRecurrence) -> QueryWorkloadEntry { /// The #509 shared data workload: 1,000,000 series sampled every 15 s. fn data() -> DataWorkload { - shared_data_workload(DataArrival::ContinuouslyIngesting) + shared_data_workload( + DataArrival::Ingesting { + rows_per_second: SHARED_ROWS_PER_SECOND, + }, + "m", + ) } /// Every minute. diff --git a/crates/integration-tests/tests/stage1_logical_asap_optimization.rs b/crates/integration-tests/tests/stage1_logical_asap_optimization.rs index 7f377b2f0..21c173b2d 100644 --- a/crates/integration-tests/tests/stage1_logical_asap_optimization.rs +++ b/crates/integration-tests/tests/stage1_logical_asap_optimization.rs @@ -25,7 +25,7 @@ use asap_types::pre_asap::schema::{DataType, Field, Schema}; use asap_types::pre_asap::{ColumnRef, CompareOpKind, ScalarValue}; use asap_types::types::AccuracyTarget; use asap_types::workload::{ - BatchEntry, DataWorkload, DurationMs, Evidence, PlanningWorkload, Query, QueryLanguage, + BatchEntry, DataWorkload, DurationMs, PlanningWorkload, Query, QueryLanguage, QueryRequirements, QueryWorkload, }; use futures::StreamExt; @@ -67,15 +67,12 @@ fn promql(query: &str) -> QueryRoot { }]), repeating_queries: None, }, - data_workload: Some(DataWorkload { - data_ingestion_interval: Evidence { - value: Some(DurationMs(1000)), - ..Default::default() - }, - ..Default::default() - }), + data_workload: Some(DataWorkload::sampled_every( + asap_frontend_promql::promql_metric_names(query).unwrap_or_default(), + DurationMs(1000), + )), }; - asap_frontend_promql::lower_promql_query_workload(&workload, 0) + asap_frontend_promql::lower_promql_query_workload(&workload) .unwrap_or_else(|e| panic!("lower failed for {query:?}: {e}")) .remove(0) } diff --git a/crates/planner/src/lib.rs b/crates/planner/src/lib.rs index a68f6ffa9..025d2c64b 100644 --- a/crates/planner/src/lib.rs +++ b/crates/planner/src/lib.rs @@ -44,16 +44,9 @@ pub enum FrontendInput<'a> { catalog: &'a SqlCatalog, }, Promql { - /// Planning clock. PromQL lowering resolves selection horizons against - /// it and checks the ingestion-interval evidence's freshness at it. - now_ms: u64, histograms: Option, }, - Metricsql { - /// Planning clock, as for PromQL: the ingestion-interval evidence - /// must be valid at it. - now_ms: u64, - }, + Metricsql, } #[non_exhaustive] @@ -96,7 +89,7 @@ impl<'a> UserInput<'a> { QueryLanguage::SQL(_) | QueryLanguage::DataFusion, FrontendInput::Sql { .. } ) | (QueryLanguage::PromQL, FrontendInput::Promql { .. }) - | (QueryLanguage::MetricsQL, FrontendInput::Metricsql { .. }) + | (QueryLanguage::MetricsQL, FrontendInput::Metricsql) ); if !matches_frontend { return Err(UserInputError::FrontendMismatch { @@ -150,8 +143,8 @@ pub enum PlanError { #[error("input: {0}")] Input(#[from] UserInputError), /// `entry_index` is `None` when the frontend fails the whole batch rather - /// than one query — the PromQL frontend resolves one ingestion interval for - /// the workload before lowering anything. + /// than one query — the PromQL and MetricsQL frontends report it for + /// the workload as a whole. #[error("lowering{}: {source}", .entry_index.map(|i| format!(" entry {i}")).unwrap_or_default())] Lowering { entry_index: Option, @@ -217,16 +210,12 @@ async fn lower(input: &UserInput<'_>) -> Result, } Ok(lowered) } - FrontendInput::Promql { - now_ms, histograms, .. - } => { + FrontendInput::Promql { histograms, .. } => { let lowered = match histograms { - Some(histograms) => lower_promql_query_workload_with_histograms( - input.workload, - histograms.clone(), - *now_ms, - ), - None => lower_promql_query_workload(input.workload, *now_ms), + Some(histograms) => { + lower_promql_query_workload_with_histograms(input.workload, histograms.clone()) + } + None => lower_promql_query_workload(input.workload), } .map_err(|source| PlanError::Lowering { entry_index: None, @@ -234,12 +223,10 @@ async fn lower(input: &UserInput<'_>) -> Result, })?; Ok(lowered) } - FrontendInput::Metricsql { now_ms } => { - lower_metricsql_query_workload(input.workload, *now_ms).map_err(|source| { - PlanError::Lowering { - entry_index: None, - source: LoweringError::Metricsql(source), - } + FrontendInput::Metricsql => { + lower_metricsql_query_workload(input.workload).map_err(|source| PlanError::Lowering { + entry_index: None, + source: LoweringError::Metricsql(source), }) } } diff --git a/crates/planner/tests/e2e_plan.rs b/crates/planner/tests/e2e_plan.rs index 71062a4d7..7dff4f324 100644 --- a/crates/planner/tests/e2e_plan.rs +++ b/crates/planner/tests/e2e_plan.rs @@ -12,13 +12,11 @@ use asap_planner::{e2e_plan, FrontendInput, PlanError, UserInput, UserInputError use asap_types::pre_asap::schema::{DataType, Field, Schema}; use asap_types::types::{AccuracyMetric, AccuracyTarget}; use asap_types::workload::{ - BatchEntry, DataArrival, DataWorkload, DurationMs, Evidence, PlanningWorkload, Predictability, - Query, QueryLanguage, QueryRequirements, QueryWorkload, RepeatedDemand, RepeatingEntry, + BatchEntry, DataArrival, DataWorkload, DurationMs, PlanningWorkload, Predictability, Query, + QueryLanguage, QueryRequirements, QueryWorkload, RepeatedDemand, RepeatingEntry, RepetitionInterval, SqlDialect, }; -const NOW_MS: u64 = 1_700_000_000_000; - fn approximate() -> QueryRequirements { QueryRequirements { accuracy: AccuracyTarget::max_error(0.01), @@ -289,10 +287,7 @@ async fn rejects_a_frontend_that_does_not_match_the_workload_language() { ); let input = UserInput::new( &workload, - FrontendInput::Promql { - now_ms: NOW_MS, - histograms: None, - }, + FrontendInput::Promql { histograms: None }, PlanningModels::builtin(), ); @@ -317,21 +312,17 @@ async fn each_plan_carries_its_selected_root() { predictability: Predictability::Unknown, }]), }, + // A range selector only: no sample interval is needed. data_workload: Some(DataWorkload { - arrival: DataArrival::ContinuouslyIngesting, - data_ingestion_interval: Evidence { - value: Some(DurationMs(15_000)), - ..Default::default() + arrival: DataArrival::Ingesting { + rows_per_second: 1_000.0, }, ..Default::default() }), }; let input = UserInput::new( &workload, - FrontendInput::Promql { - now_ms: NOW_MS, - histograms: None, - }, + FrontendInput::Promql { histograms: None }, PlanningModels::builtin(), ); @@ -355,20 +346,16 @@ async fn scalar_roots_survive_planning_in_workload_order() { query_batch: Some(queries.iter().map(|q| batch(q)).collect()), repeating_queries: None, }, - data_workload: Some(DataWorkload { - data_ingestion_interval: Evidence { - value: Some(DurationMs(1000)), - ..Default::default() - }, - ..Default::default() - }), + data_workload: Some(DataWorkload::sampled_every( + queries + .iter() + .flat_map(|q| asap_frontend_promql::promql_metric_names(q).unwrap()), + DurationMs(1000), + )), }; let output = e2e_plan(UserInput::new( &workload, - FrontendInput::Promql { - now_ms: NOW_MS, - histograms: None, - }, + FrontendInput::Promql { histograms: None }, PlanningModels::builtin(), )) .await diff --git a/crates/planner/tests/plan_stages.rs b/crates/planner/tests/plan_stages.rs index 33d50d249..3eaa119bc 100644 --- a/crates/planner/tests/plan_stages.rs +++ b/crates/planner/tests/plan_stages.rs @@ -5,12 +5,10 @@ use asap_aware_mapping::pass::{run_stage1_to_stage3, PlanningModels, Stage1ToSta use asap_frontend_promql::lower_promql_query_workload; use asap_types::types::AccuracyTarget; use asap_types::workload::{ - BatchEntry, DataWorkload, DurationMs, Evidence, PlanningWorkload, Query, QueryLanguage, + BatchEntry, DataWorkload, DurationMs, PlanningWorkload, Query, QueryLanguage, QueryRequirements, QueryWorkload, }; -const NOW_MS: u64 = 1_700_000_000_000; - fn promql_stages(query: &str, epsilon: f64) -> Stage1ToStage3Run { let target = AccuracyTarget::max_error(epsilon); let workload = PlanningWorkload { @@ -28,15 +26,12 @@ fn promql_stages(query: &str, epsilon: f64) -> Stage1ToStage3Run { }]), repeating_queries: None, }, - data_workload: Some(DataWorkload { - data_ingestion_interval: Evidence { - value: Some(DurationMs(15_000)), - ..Default::default() - }, - ..Default::default() - }), + data_workload: Some(DataWorkload::sampled_every( + asap_frontend_promql::promql_metric_names(query).unwrap_or_default(), + DurationMs(15_000), + )), }; - let roots = lower_promql_query_workload(&workload, NOW_MS).expect("lowers"); + let roots = lower_promql_query_workload(&workload).expect("lowers"); run_stage1_to_stage3( roots.into_iter().enumerate().collect(), &workload.query_workload.entries().collect::>(), diff --git a/crates/planner/tests/summary_sharing.rs b/crates/planner/tests/summary_sharing.rs index 1e8411b2e..a1b042dfd 100644 --- a/crates/planner/tests/summary_sharing.rs +++ b/crates/planner/tests/summary_sharing.rs @@ -11,7 +11,7 @@ use asap_aware_mapping::pass::{ run_stage1_to_stage3, PlanOutput, PlanningModels, QueryPlan, Stage1ToStage3Run, }; use asap_aware_mapping::realization::{default_size_params, DEFAULT_DELTA}; -use asap_frontend_promql::lower_promql_query_workload; +use asap_frontend_promql::{lower_promql_query_workload, promql_metric_names}; use asap_frontend_sql::SqlCatalog; use asap_planner::{e2e_plan, FrontendInput, UserInput}; use asap_types::post_asap::{FieldDataType, SketchAlgorithm, SketchParams}; @@ -19,13 +19,11 @@ use asap_types::pre_asap::agg_intent::default_quantile; use asap_types::pre_asap::schema::{DataType, Field, Schema}; use asap_types::types::{AccuracyMetric, AccuracyTarget}; use asap_types::workload::{ - DataArrival, DataWorkload, DurationMs, Evidence, PlanningWorkload, Predictability, Query, - QueryLanguage, QueryRequirements, QueryWorkload, Rate, RepeatedDemand, RepeatingEntry, - RepetitionInterval, SqlDialect, + DataArrival, DataWorkload, DurationMs, PlanningWorkload, Predictability, Query, QueryLanguage, + QueryRequirements, QueryWorkload, RepeatedDemand, RepeatingEntry, RepetitionInterval, + SqlDialect, }; -const NOW_MS: u64 = 1_700_000_000_000; - /// `epsilon` as a query's requested accuracy; a quantile query's target /// names its metric (a rank error). fn requested(query: &str, epsilon: f64) -> AccuracyTarget { @@ -67,16 +65,15 @@ fn promql_workload(queries: &[(&str, f64)]) -> PlanningWorkload { ), }, data_workload: Some(DataWorkload { - arrival: DataArrival::ContinuouslyIngesting, - data_ingestion_interval: Evidence { - value: Some(DurationMs(15_000)), - ..Default::default() - }, - ingestion_rate: Evidence { - value: Some(Rate(1.0)), - ..Default::default() + arrival: DataArrival::Ingesting { + rows_per_second: 1.0, }, - ..Default::default() + ..DataWorkload::sampled_every( + queries + .iter() + .flat_map(|(query, _)| promql_metric_names(query).unwrap_or_default()), + DurationMs(15_000), + ) }), } } @@ -85,10 +82,7 @@ async fn plan_promql(queries: &[(&str, f64)]) -> PlanOutput { let workload = promql_workload(queries); let input = UserInput::new( &workload, - FrontendInput::Promql { - now_ms: NOW_MS, - histograms: None, - }, + FrontendInput::Promql { histograms: None }, PlanningModels::builtin(), ); e2e_plan(input).await.expect("workload plans") @@ -97,7 +91,7 @@ async fn plan_promql(queries: &[(&str, f64)]) -> PlanOutput { /// Every planning stage's output for `queries`, run over the frontend's roots. fn promql_stages(queries: &[(&str, f64)]) -> Stage1ToStage3Run { let workload = promql_workload(queries); - let roots = lower_promql_query_workload(&workload, NOW_MS).expect("lowers"); + let roots = lower_promql_query_workload(&workload).expect("lowers"); run_stage1_to_stage3( roots.into_iter().enumerate().collect(), &workload.query_workload.entries().collect::>(), diff --git a/crates/types/src/post_asap/sketch.rs b/crates/types/src/post_asap/sketch.rs index b8b1eb7fc..1a1edd968 100644 --- a/crates/types/src/post_asap/sketch.rs +++ b/crates/types/src/post_asap/sketch.rs @@ -692,7 +692,7 @@ pub enum WeightDomain { /// are non-negative exactly when `metric` is a counter. Logical /// optimization records it without reading the data workload; plan /// selection confirms it from the declared metric types - /// ([`DataWorkload::metric_types`](crate::workload::DataWorkload::metric_types)). + /// ([`SourceFacts::metric_type`](crate::workload::SourceFacts::metric_type)). /// Unconfirmed, it counts as not proven. SamplesOfMetric { metric: String, diff --git a/crates/types/src/resources/cache.rs b/crates/types/src/resources/cache.rs index f3e476309..a5dd8e466 100644 --- a/crates/types/src/resources/cache.rs +++ b/crates/types/src/resources/cache.rs @@ -25,7 +25,7 @@ pub struct CacheEvidence { pub result_cache: CacheCapacityEvidence, pub buffer_cache: CacheCapacityEvidence, /// Fraction of otherwise-resident result entries invalidated by arriving - /// data. Required for continuously ingesting data; `AtRest` permits only + /// data. Required for ingesting data; `AtRest` permits only /// zero or omitted invalidation evidence. pub result_invalidation_ratio: Option, } diff --git a/crates/types/src/workload.rs b/crates/types/src/workload.rs index a25823d19..2f101dba5 100644 --- a/crates/types/src/workload.rs +++ b/crates/types/src/workload.rs @@ -112,52 +112,20 @@ pub enum Predictability { Unknown, } -/// Confidence assigned to an estimated demand value, expressed in `[0, 1]`. -/// Validation rejects values outside that range. -/// -/// For example, `Confidence(0.95)` says the demand estimate is supplied with -/// 95% confidence. -#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] -#[serde(transparent)] -pub struct Confidence(pub f64); - -/// Event-time interval from which a demand estimate was learned. -/// -/// For example, `{ start: TimestampMs(0), end: TimestampMs(60_000) }` -/// describes an estimate based on the first minute of observations. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ObservationWindow { - pub start: TimestampMs, - pub end: TimestampMs, -} - /// Evidence-backed forecast used when recurrence is not a fixed interval or /// an explicit schedule. Demand is normalized to invocations per second at /// the input boundary, so this type never mixes counts and rates. /// /// # Example /// -/// This estimate predicts ten executions per second, with a possible peak of -/// twenty, based on one minute of observed data. It is usable for five minutes -/// after `observed_at`. +/// This estimate predicts ten executions per second. It is usable for five +/// minutes after `observed_at`. /// /// ``` -/// use asap_types::workload::{ -/// Confidence, DemandEstimate, DurationMs, EvidenceSource, ObservationWindow, -/// Rate, TimestampMs, -/// }; +/// use asap_types::workload::{DemandEstimate, DurationMs, Rate, TimestampMs}; /// /// let estimate = DemandEstimate { -/// observation_window: ObservationWindow { -/// start: TimestampMs(0), -/// end: TimestampMs(60_000), -/// }, /// expected_rate: Rate(10.0), -/// peak_rate: Some(Rate(20.0)), -/// max_concurrency: Some(4), -/// confidence: Confidence(0.95), -/// source: EvidenceSource::Observed, /// observed_at: Some(TimestampMs(60_000)), /// valid_for: Some(DurationMs(5 * 60 * 1_000)), /// }; @@ -165,12 +133,7 @@ pub struct ObservationWindow { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct DemandEstimate { - pub observation_window: ObservationWindow, pub expected_rate: Rate, - pub peak_rate: Option, - pub max_concurrency: Option, - pub confidence: Confidence, - pub source: EvidenceSource, pub observed_at: Option, pub valid_for: Option, } @@ -349,63 +312,42 @@ impl From<&RepeatingEntry> for QueryWorkloadEntry { // ── Data workload ───────────────────────────────────────────────────────────── -/// Whether the data queried by this workload is static, still arriving, or a -/// mixture of both. This is independent of whether queries repeat. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] +/// How the queried data arrives: already stored, still arriving, or both. +/// This is independent of whether queries repeat. +/// +/// JSON: `"at_rest"`, `{"ingesting": {"rows_per_second": 1000.0}}`, +/// `{"mixed": {"rows_per_second": 1000.0}}` or `"unknown"` (the default). +#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case", deny_unknown_fields)] pub enum DataArrival { + /// Every row is already stored; nothing arrives, so no summary can be + /// built at ingestion time. AtRest, - ContinuouslyIngesting, - Mixed, - #[default] - Unknown, -} - -/// Where an empirical workload value came from. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum EvidenceSource { - Declared, - Observed, - Derived, + /// Rows keep arriving while the queries run. + Ingesting { + /// Rows (PromQL samples) arriving per second over all sources. + /// Plan selection prices scans and ingestion-time work with it. + rows_per_second: f64, + }, + /// Older rows are stored and new rows keep arriving. + Mixed { + /// Rows (PromQL samples) arriving per second over all sources, as + /// for [`DataArrival::Ingesting`]. + rows_per_second: f64, + }, + /// Not declared: no summary is built at ingestion time. #[default] Unknown, } -/// A workload value together with the provenance and freshness needed to -/// decide whether it is safe to use. Times and durations are milliseconds. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct Evidence { - pub value: Option, - pub source: EvidenceSource, - pub observed_at_ms: Option, - pub valid_for_ms: Option, -} - -impl Default for Evidence { - fn default() -> Self { - Self { - value: None, - source: EvidenceSource::Unknown, - observed_at_ms: None, - valid_for_ms: None, - } - } -} - -impl Evidence { - /// Return the value only while its freshness contract holds. Declared or - /// timeless evidence with no `valid_for_ms` does not expire. - pub fn value_at(&self, now_ms: u64) -> Option<&T> { - let value = self.value.as_ref()?; - match (self.observed_at_ms, self.valid_for_ms) { - (Some(observed), _) if observed > now_ms => None, - (Some(observed), Some(valid_for)) if now_ms > observed.saturating_add(valid_for) => { - None +impl DataArrival { + /// Rows arriving per second, when the data is ingesting or mixed. + pub fn rows_per_second(&self) -> Option { + match self { + Self::Ingesting { rows_per_second } | Self::Mixed { rows_per_second } => { + Some(*rows_per_second) } - (None, Some(_)) => None, - _ => Some(value), + Self::AtRest | Self::Unknown => None, } } } @@ -430,42 +372,65 @@ impl DemandEstimate { #[serde(transparent)] pub struct Rate(pub f64); -/// Workload-level facts about the data being queried. Unlike the former -/// ingestion-only `DataCharacteristics`, this also represents data at rest. +/// Facts about the data being queried: how it arrives, and what is known +/// about each source. Every fact is declared: the planner has no clock, so a +/// caller drops facts that no longer hold before planning. +/// +/// JSON: `{"arrival": {"ingesting": {"rows_per_second": 66666.7}}, +/// "sources": {"http_requests_total": {"series_count": 1000000, +/// "metric_type": "counter", "sample_interval": 15000}}}`. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct DataWorkload { + /// How the data arrives (default: unknown). pub arrival: DataArrival, - /// Cadence at which each PromQL source supplies a sample. PromQL instant - /// selectors use this as their explicit selection horizon. + /// Facts per source, keyed by PromQL metric name or SQL table name + /// (default: none). A source without an entry has no known facts. + #[serde(default)] + pub sources: BTreeMap, +} + +/// What is known about one source (a PromQL metric or a SQL table). Every +/// fact is optional; an unknown fact is never read as zero. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SourceFacts { + /// How many series (PromQL) or groups of rows (SQL) the source holds + /// (default: unknown). Plan selection bounds the values a query returns + /// (`G`) by it, to split a whole-result failure probability, and prices + /// scans and selectors with it (1,000 when unknown). Must be positive. #[serde(default)] - pub data_ingestion_interval: Evidence, - pub ingestion_rate: Evidence, - pub input_cardinality: Evidence, - /// Stream norms per source (PromQL metric or SQL table name): the - /// inputs Count-Min and Count Sketch additive error bounds are - /// normalized by. Plan selection binds them to turn those bounds into - /// absolute errors; a source without a norm leaves that bound unmet. + pub series_count: Option, + /// Declared Prometheus type of the metric (`# TYPE` metadata). The + /// planner never infers a type from a name such as `_total`; an + /// undeclared metric's samples may take any value. #[serde(default)] - pub stream_norms: Evidence>, - /// Upper bound per source (PromQL metric or SQL table name) on the - /// distinct items any one distinct-count summary over the source's own - /// rows counts in one complete evaluation, including every merged pane. - /// One bound serves every query over the source, so it must hold for - /// the longest evaluation window any of them uses; a filter or a - /// shorter window only lowers it. Each group's own items count - /// separately: the bound is per output row. The deployment must - /// enforce it; an observed distinct count is not a bound. Plan selection - /// binds it to give classic HLL (and a distinct-count UnivMon) its - /// failure probability per output row; a source without a bound leaves - /// that probability unknown. KMV's guarantee needs no bound. + pub metric_type: Option, + /// PromQL and MetricsQL only: how often each series of the metric + /// receives a sample (default: unknown). A bare instant selector over + /// the metric reads the latest sample within it; lowering such a + /// selector without it fails with + /// [`WorkloadError::MissingSampleInterval`]. #[serde(default)] - pub max_distinct_per_evaluation: Evidence>, - /// Declared type of each metric, by metric name (Prometheus `# TYPE` - /// metadata). The planner never infers a type from a name such as - /// `_total`; an undeclared metric's samples may take any value. - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub metric_types: BTreeMap, + pub sample_interval: Option, + /// Bounds on the source's stream norms: the inputs Count-Min and Count + /// Sketch additive error bounds are normalized by. Plan selection binds + /// them to turn those bounds into absolute errors; without them that + /// bound is unmet. + #[serde(default)] + pub stream_norms: Option, + /// Upper bound on the distinct items any one distinct-count summary over + /// the source's own rows counts in one complete evaluation, including + /// every merged pane. One bound serves every query over the source, so + /// it must hold for the longest evaluation window any of them uses; a + /// filter or a shorter window only lowers it. Each group's own items + /// count separately: the bound is per output row. The deployment must + /// enforce it; an observed distinct count is not a bound. Plan + /// selection binds it to give classic HLL (and a distinct-count UnivMon) + /// its failure probability per output row; without it that probability + /// is unknown. KMV's guarantee needs no bound. + #[serde(default)] + pub max_distinct_per_evaluation: Option, } /// Upper bounds on the norms of the frequency (weight) vector any one @@ -542,55 +507,73 @@ impl QueryWorkload { } impl DataWorkload { - /// Validate source facts independently of query demand or language. - pub fn validate(&self) -> Result<(), WorkloadError> { - if matches!(self.arrival, DataArrival::AtRest) - && self.ingestion_rate.value.is_some_and(|rate| rate.0 > 0.0) - { - return Err(WorkloadError::AtRestWithPositiveIngestionRate); + /// Data of unknown arrival whose `metrics` are each sampled every + /// `sample_interval`, with no other facts: what PromQL or MetricsQL + /// queries over them need to lower their bare instant selectors. + pub fn sampled_every( + metrics: impl IntoIterator, + sample_interval: DurationMs, + ) -> Self { + let sampled = SourceFacts { + sample_interval: Some(sample_interval), + ..SourceFacts::default() + }; + Self { + arrival: DataArrival::Unknown, + sources: metrics + .into_iter() + .map(|metric| (metric, sampled.clone())) + .collect(), } - validate_optional_rate(self.ingestion_rate.value)?; - for norms in self.stream_norms.value.iter().flat_map(BTreeMap::values) { - for value in [norms.l1, norms.l2].into_iter().flatten() { - if !(value.is_finite() && value >= 0.0) { - return Err(WorkloadError::InvalidStreamNorm(value)); + } + + /// Validate the arrival and source facts independently of query demand + /// or language. + pub fn validate(&self) -> Result<(), WorkloadError> { + validate_optional_rate(self.arrival.rows_per_second().map(Rate))?; + for facts in self.sources.values() { + if facts.series_count == Some(0) { + return Err(WorkloadError::ZeroSeriesCount); + } + if facts.sample_interval == Some(DurationMs(0)) { + return Err(WorkloadError::ZeroSampleInterval); + } + if let Some(norms) = &facts.stream_norms { + for value in [norms.l1, norms.l2].into_iter().flatten() { + if !(value.is_finite() && value >= 0.0) { + return Err(WorkloadError::InvalidStreamNorm(value)); + } } } - } - let bounds = self.max_distinct_per_evaluation.value.iter(); - if bounds.flat_map(BTreeMap::values).any(|bound| *bound == 0) { - return Err(WorkloadError::ZeroMaxDistinct); + if facts.max_distinct_per_evaluation == Some(0) { + return Err(WorkloadError::ZeroMaxDistinct); + } } Ok(()) } } impl PlanningWorkload { + /// Validate the query workload and, when present, the data workload. A + /// PromQL or MetricsQL sample interval is checked by the frontend, only + /// for a query with a bare instant selector + /// ([`WorkloadError::MissingSampleInterval`]). pub fn validate(&self) -> Result<(), WorkloadError> { self.query_workload.validate()?; if let Some(data) = &self.data_workload { data.validate()?; } - // Both read the latest sample of a bare selector within the ingestion - // interval. - if matches!( - self.query_workload.language, - QueryLanguage::PromQL | QueryLanguage::MetricsQL - ) { - let data = self - .data_workload - .as_ref() - .ok_or(WorkloadError::MissingPromqlDataWorkload)?; - let interval = data - .data_ingestion_interval - .value - .ok_or(WorkloadError::MissingDataIngestionInterval)?; - if interval.0 == 0 { - return Err(WorkloadError::ZeroDataIngestionInterval); - } - } Ok(()) } + + /// The sample interval the data workload declares for `metric`, if any. + pub fn sample_interval_of(&self, metric: &str) -> Option { + self.data_workload + .as_ref()? + .sources + .get(metric)? + .sample_interval + } } fn validate_entry(entry: &QueryWorkloadEntry) -> Result<(), WorkloadError> { @@ -612,15 +595,7 @@ fn validate_entry(entry: &QueryWorkloadEntry) -> Result<(), WorkloadError> { return Err(WorkloadError::EmptySchedule) } QueryRecurrence::Repeated(RepeatedDemand::EstimatedRate(estimate)) => { - if estimate.observation_window.start >= estimate.observation_window.end { - return Err(WorkloadError::EmptyObservationWindow); - } - if !(estimate.confidence.0.is_finite() && (0.0..=1.0).contains(&estimate.confidence.0)) - { - return Err(WorkloadError::InvalidConfidence(estimate.confidence.0)); - } validate_rate(estimate.expected_rate)?; - validate_optional_rate(estimate.peak_rate)?; } _ => {} } @@ -639,36 +614,49 @@ fn validate_rate(rate: Rate) -> Result { } } +/// Why a planning workload is rejected. Every variant is bad input. #[derive(Debug, Clone, Copy, PartialEq, thiserror::Error)] pub enum WorkloadError { - #[error("data_ingestion_interval evidence is unavailable at planning time")] - UnavailableDataIngestionInterval, - #[error("a PromQL or MetricsQL workload requires data_workload")] - MissingPromqlDataWorkload, - #[error("a PromQL or MetricsQL workload requires data_ingestion_interval")] - MissingDataIngestionInterval, - #[error("data_ingestion_interval must be greater than zero")] - ZeroDataIngestionInterval, + /// Bad input: a PromQL or MetricsQL query has a bare instant selector, + /// which reads the latest sample within its metric's sample interval, + /// but the data workload declares no `sample_interval` for that metric. + #[error( + "a PromQL or MetricsQL instant selector needs its metric's sample_interval \ + in data_workload.sources" + )] + MissingSampleInterval, + /// Bad input: a source declares a sample interval of zero. + #[error("sample_interval must be greater than zero")] + ZeroSampleInterval, + /// Bad input: a one-time query with zero invocations. #[error("a one-time query must have at least one invocation")] ZeroInvocations, + /// Bad input: a repeating query whose fixed interval is zero. #[error("a fixed repetition interval must be greater than zero")] ZeroRepetitionInterval, + /// Bad input: a scheduled repeating query with no timestamps. #[error("a repeated-query schedule must not be empty")] EmptySchedule, - #[error("a demand-estimate observation window must have start < end")] - EmptyObservationWindow, - #[error("confidence must be finite and in [0, 1], got {0}")] - InvalidConfidence(f64), + /// Bad input: a rate (a demand estimate's, or the arrival's + /// `rows_per_second`) that is negative or not finite. #[error("rate must be finite and non-negative, got {0}")] InvalidRate(f64), + /// Bad input: a query's `max_latency_ms` that is negative or not + /// finite. #[error("response latency must be finite and non-negative, got {0} ms")] InvalidLatency(f64), - #[error("data at rest cannot have a positive ingestion rate")] - AtRestWithPositiveIngestionRate, + /// Bad input: a source's stream norm bound that is negative or not + /// finite. #[error("a stream norm must be finite and non-negative, got {0}")] InvalidStreamNorm(f64), + /// Bad input: a source's `max_distinct_per_evaluation` of zero. #[error("a source's max_distinct_per_evaluation must be greater than zero")] ZeroMaxDistinct, + /// Bad input: a source declares a `series_count` of zero. A source + /// with no series has nothing to plan for, and `absent(m)` over it + /// still returns one row. + #[error("a source's series_count must be greater than zero")] + ZeroSeriesCount, } #[cfg(test)] @@ -707,16 +695,16 @@ mod tests { #[test] fn stream_norms_must_be_finite_and_non_negative() { let with = |l1: f64| DataWorkload { - stream_norms: Evidence { - value: Some(BTreeMap::from([( - "m".to_string(), - StreamNorms { + sources: BTreeMap::from([( + "m".to_string(), + SourceFacts { + stream_norms: Some(StreamNorms { l1: Some(l1), l2: None, - }, - )])), - ..Evidence::default() - }, + }), + ..SourceFacts::default() + }, + )]), ..DataWorkload::default() }; assert!(with(10.0).validate().is_ok()); @@ -735,16 +723,63 @@ mod tests { #[test] fn max_distinct_per_evaluation_must_be_positive() { let with = |bound: u64| DataWorkload { - max_distinct_per_evaluation: Evidence { - value: Some(BTreeMap::from([("m".to_string(), bound)])), - ..Evidence::default() - }, + sources: BTreeMap::from([( + "m".to_string(), + SourceFacts { + max_distinct_per_evaluation: Some(bound), + ..SourceFacts::default() + }, + )]), ..DataWorkload::default() }; assert!(with(1).validate().is_ok()); assert_eq!(with(0).validate(), Err(WorkloadError::ZeroMaxDistinct)); } + /// A declared series count is positive: with zero series, `absent(m)` + /// would still return one row the bound does not count. + #[test] + fn series_count_must_be_positive() { + let with = |series_count: u64| DataWorkload { + sources: BTreeMap::from([( + "m".to_string(), + SourceFacts { + series_count: Some(series_count), + ..SourceFacts::default() + }, + )]), + ..DataWorkload::default() + }; + assert!(with(1).validate().is_ok()); + assert_eq!(with(0).validate(), Err(WorkloadError::ZeroSeriesCount)); + } + + /// The arrival's rate is finite and non-negative, and each source's + /// declared sample interval is positive. + #[test] + fn arrival_rate_and_sample_interval_are_validated() { + let with = |rows_per_second: f64, sample_interval: Option| DataWorkload { + arrival: DataArrival::Mixed { rows_per_second }, + sources: BTreeMap::from([( + "m".to_string(), + SourceFacts { + sample_interval: sample_interval.map(DurationMs), + ..SourceFacts::default() + }, + )]), + }; + assert!(with(10.0, Some(15_000)).validate().is_ok()); + assert!(with(0.0, None).validate().is_ok()); + assert_eq!( + with(-1.0, None).validate(), + Err(WorkloadError::InvalidRate(-1.0)) + ); + assert_eq!( + with(1.0, Some(0)).validate(), + Err(WorkloadError::ZeroSampleInterval) + ); + } + fn base_workload() -> QueryWorkload { QueryWorkload { language: QueryLanguage::PromQL, @@ -784,38 +819,9 @@ mod tests { } #[test] - fn stale_evidence_is_unknown_at_planning_time() { - let evidence = Evidence { - value: Some(Rate(10.0)), - source: EvidenceSource::Observed, - observed_at_ms: Some(1_000), - valid_for_ms: Some(500), - }; - assert_eq!(evidence.value_at(1_500), Some(&Rate(10.0))); - assert_eq!(evidence.value_at(1_501), None); - } - - #[test] - fn future_evidence_and_demand_estimates_are_not_fresh() { - let evidence = Evidence { - value: Some(Rate(2.0)), - source: EvidenceSource::Observed, - observed_at_ms: Some(2_000), - valid_for_ms: Some(1_000), - }; - assert_eq!(evidence.value_at(1_999), None); - assert_eq!(evidence.value_at(2_000), Some(&Rate(2.0))); - + fn future_demand_estimates_are_not_fresh() { let estimate = DemandEstimate { - observation_window: ObservationWindow { - start: TimestampMs(0), - end: TimestampMs(1_000), - }, expected_rate: Rate(1.0), - peak_rate: None, - max_concurrency: None, - confidence: Confidence(1.0), - source: EvidenceSource::Observed, observed_at: Some(TimestampMs(2_000)), valid_for: Some(DurationMs(1_000)), }; @@ -823,25 +829,6 @@ mod tests { assert!(estimate.is_fresh_at(2_000)); } - #[test] - fn at_rest_rejects_a_positive_ingestion_rate() { - let workload = PlanningWorkload { - query_workload: base_workload(), - data_workload: Some(DataWorkload { - arrival: DataArrival::AtRest, - ingestion_rate: Evidence { - value: Some(Rate(1.0)), - ..Default::default() - }, - ..Default::default() - }), - }; - assert_eq!( - workload.validate(), - Err(WorkloadError::AtRestWithPositiveIngestionRate) - ); - } - #[test] fn canonical_workloads_have_a_strict_json_round_trip() { let workload = PlanningWorkload { @@ -859,30 +846,93 @@ mod tests { }]), }, data_workload: Some(DataWorkload { - arrival: DataArrival::ContinuouslyIngesting, - ingestion_rate: Evidence { - value: Some(Rate(1_000.0)), - source: EvidenceSource::Declared, - observed_at_ms: None, - valid_for_ms: None, + arrival: DataArrival::Ingesting { + rows_per_second: 1_000.0, }, - input_cardinality: Evidence { - value: Some(10_000), - source: EvidenceSource::Declared, - observed_at_ms: None, - valid_for_ms: None, - }, - ..Default::default() + sources: BTreeMap::from([( + "requests_total".to_string(), + SourceFacts { + series_count: Some(10_000), + metric_type: Some(MetricType::Counter), + sample_interval: Some(DurationMs(15_000)), + stream_norms: Some(StreamNorms { + l1: Some(1e9), + l2: None, + }), + max_distinct_per_evaluation: Some(100), + }, + )]), }), }; - let json = serde_json::to_string_pretty(&workload).expect("serialize workload"); - let decoded: PlanningWorkload = serde_json::from_str(&json).expect("deserialize workload"); + let json = serde_json::to_value(&workload).expect("serialize workload"); + assert_eq!( + json["data_workload"], + serde_json::json!({ + "arrival": {"ingesting": {"rows_per_second": 1000.0}}, + "sources": {"requests_total": { + "series_count": 10000, + "metric_type": "counter", + "sample_interval": 15000, + "stream_norms": {"l1": 1e9, "l2": null}, + "max_distinct_per_evaluation": 100 + }} + }) + ); + let decoded: PlanningWorkload = serde_json::from_value(json.clone()).unwrap(); assert_eq!(decoded, workload); - assert!(json.contains("\"continuously_ingesting\"")); - let with_unknown = json.replacen("{", "{\"unknown\":true,", 1); + let text = serde_json::to_string(&workload).unwrap(); + let with_unknown = text.replacen("{", "{\"unknown\":true,", 1); assert!(serde_json::from_str::(&with_unknown).is_err()); + + // Omitted facts default to unknown. + let minimal: DataWorkload = serde_json::from_value(serde_json::json!({ + "arrival": {"mixed": {"rows_per_second": 5.0}}, + "sources": {"flows": {"series_count": 7}} + })) + .unwrap(); + assert_eq!( + minimal.sources["flows"], + SourceFacts { + series_count: Some(7), + ..SourceFacts::default() + } + ); + } + + /// The former flat shape (per-field evidence, one global cardinality, + /// per-fact source maps, `continuously_ingesting`) is rejected, as are + /// freshness evidence on a source and a sample interval on the arrival. + #[test] + fn the_former_data_workload_shape_is_rejected() { + let declared = |value: serde_json::Value| { + serde_json::json!({"value": value, "source": "declared", + "observed_at_ms": null, "valid_for_ms": null}) + }; + for old in [ + serde_json::json!({ + "arrival": "continuously_ingesting", + "data_ingestion_interval": declared(15000.into()), + "ingestion_rate": declared(1000.0.into()), + "input_cardinality": declared(10000.into()), + }), + serde_json::json!({"arrival": "continuously_ingesting"}), + serde_json::json!({"arrival": "at_rest", "input_cardinality": declared(1.into())}), + serde_json::json!({"arrival": "at_rest", + "metric_types": {"m": "counter"}}), + serde_json::json!({"arrival": "at_rest", + "sources": {"m": {"series_count": 1, "evidence": {"observed_at_ms": 1}}}}), + serde_json::json!({"arrival": "at_rest", "sources": {"m": {"series": 1}}}), + serde_json::json!({"arrival": {"ingesting": + {"rows_per_second": 1.0, "sample_interval": 15000}}}), + serde_json::json!({"arrival": {"at_rest": {"rows_per_second": 1.0}}}), + ] { + assert!( + serde_json::from_value::(old.clone()).is_err(), + "{old}" + ); + } } #[test] @@ -924,29 +974,18 @@ mod tests { } #[test] - fn estimated_demand_validates_window_rate_and_confidence() { + fn estimated_demand_validates_its_rate() { let mut workload = base_workload(); workload.repeating_queries = Some(vec![RepeatingEntry { query: Query("estimated".into()), demand: RepeatedDemand::EstimatedRate(DemandEstimate { - observation_window: ObservationWindow { - start: TimestampMs(10), - end: TimestampMs(10), - }, - expected_rate: Rate(1.0), - peak_rate: None, - max_concurrency: None, - confidence: Confidence(0.9), - source: EvidenceSource::Observed, + expected_rate: Rate(-1.0), observed_at: None, valid_for: None, }), requirements: QueryRequirements::default(), predictability: Predictability::Unknown, }]); - assert_eq!( - workload.validate(), - Err(WorkloadError::EmptyObservationWindow) - ); + assert_eq!(workload.validate(), Err(WorkloadError::InvalidRate(-1.0))); } } diff --git a/crates/types/tests/workload_compatibility.rs b/crates/types/tests/workload_compatibility.rs index e5c31a5b4..9deaff29a 100644 --- a/crates/types/tests/workload_compatibility.rs +++ b/crates/types/tests/workload_compatibility.rs @@ -1,35 +1,23 @@ -use asap_types::workload::{ - DataWorkload, PlanningWorkload, QueryLanguage, QueryWorkload, SqlDialect, -}; +use asap_types::workload::PlanningWorkload; -// Non-PromQL wire workloads should not require a PromQL-only cadence fact. +// A SQL workload over ingesting data omits the PromQL-only sample interval. +// Validation never requires it, for PromQL either: the PromQL frontend +// checks a metric's interval only for a bare instant selector over it. #[test] -fn non_promql_payload_accepts_absent_ingestion_interval() { +fn ingesting_payload_without_sample_interval_validates() { for language in [ - QueryLanguage::DataFusion, - QueryLanguage::SQL(SqlDialect::DataFusionSQL), + serde_json::json!("datafusion"), + serde_json::json!({"sql": "datafusion_sql"}), + serde_json::json!("promql"), ] { - let workload = PlanningWorkload { - query_workload: QueryWorkload { - language, - query_batch: None, - repeating_queries: None, - }, - data_workload: Some(DataWorkload::default()), - }; + let payload = serde_json::json!({ + "query_workload": {"language": language, "query_batch": null, + "repeating_queries": null}, + "data_workload": {"arrival": {"ingesting": {"rows_per_second": 10.0}}, + "sources": {"m": {"series_count": 10}}} + }); + let workload = serde_json::from_value::(payload).unwrap(); workload.validate().unwrap(); - let mut payload = serde_json::to_value(&workload).unwrap(); - payload["data_workload"] - .as_object_mut() - .unwrap() - .remove("data_ingestion_interval"); - let decoded = serde_json::from_value::(payload.clone()).unwrap(); - decoded.validate().unwrap(); - payload["query_workload"]["language"] = serde_json::json!("promql"); - let promql = serde_json::from_value::(payload).unwrap(); - assert_eq!( - promql.validate(), - Err(asap_types::workload::WorkloadError::MissingDataIngestionInterval) - ); + assert_eq!(workload.sample_interval_of("m"), None); } } diff --git a/docs/design_docs/architecture/input-output-workflow.md b/docs/design_docs/architecture/input-output-workflow.md index 586580186..a079b3c2e 100644 --- a/docs/design_docs/architecture/input-output-workflow.md +++ b/docs/design_docs/architecture/input-output-workflow.md @@ -15,8 +15,8 @@ Post-ASAP alternatives for the workload. | Input | Fields | Required | |---|---|---:| | `PlanningWorkload.query_workload` | Query language and one-time/repeating query workloads | Yes | -| `PlanningWorkload.data_workload` | Data arrival and optional evidence about ingestion and cardinality | No implicit default. Set `None` when unavailable for non-PromQL workloads; PromQL requires `Some(DataWorkload)` with a nonzero ingestion interval. | -| Frontend-specific dependencies (outside `PlanningWorkload`) | `SqlCatalog` for SQL; `now_ms` and, when needed, `HistogramCatalog` for PromQL | `SqlCatalog` is required for SQL lowering; `now_ms` is required for PromQL lowering | +| `PlanningWorkload.data_workload` | How the data arrives, and facts about each source | No implicit default. Set `None` when unavailable; a PromQL query with a bare instant selector needs a nonzero `sample_interval` on the metric it reads. | +| Frontend-specific dependencies (outside `PlanningWorkload`) | `SqlCatalog` for SQL; when needed, `HistogramCatalog` for PromQL | `SqlCatalog` is required for SQL lowering | | Planning models | Candidate cost/ranking and accuracy composition/checking | Used by the relevant APIs; built-in `DefaultCostModel` and `DefaultAccuracyModel` are available | | External evidence and capabilities | Domain facts, measured costs, workload statistics, and runtime support | Supply when available and when the chosen optimization depends on them; absence is not proof | @@ -64,8 +64,7 @@ inputs and the two possible uses of the same candidate space: ```mermaid flowchart TD Q["query_workload: PromQL; repeating query count_over_time(up[5m]); every 60 s; exact; real-time; predictable"] - D["data_workload: continuous arrival; declared ingestion interval 15 s"] - T["Frontend argument: now_ms"] + D["data_workload: ingesting; up sampled every 15 s"] F["PromQL lowering"] R["One canonical OperatorNode root"] S["Candidate search"] @@ -76,7 +75,6 @@ flowchart TD B["Backend: bind physical operators, deploy, and execute"] Q --> F D --> F - T --> F F --> R --> S --> P P --> I P --> G --> L --> B @@ -126,7 +124,7 @@ struct PlanningWorkload { | Field | Required | Purpose | |---|---:|---| | `query_workload` | Yes | Contains the source language and every one-time or repeating query. | -| `data_workload` | Optional generally; required for PromQL | There is no automatic default: explicitly set `None` when unavailable for non-PromQL workloads. PromQL requires `Some(DataWorkload)` with a nonzero `data_ingestion_interval`. | +| `data_workload` | Optional | There is no automatic default: explicitly set `None` when unavailable. A PromQL or MetricsQL query with a bare instant selector needs a nonzero `sample_interval` on the metric it reads. | #### `query_workload: QueryWorkload` @@ -206,39 +204,41 @@ entry for later recurrence and materialization planning. #### `data_workload: Option` -`DataWorkload` describes the data being queried. Each empirical field uses -`Evidence` so a value is accompanied by its source and freshness. +`DataWorkload` describes the data being queried: how it arrives, and what +is known about each source. ```rust struct DataWorkload { arrival: DataArrival, - data_ingestion_interval: Evidence, - ingestion_rate: Evidence, - input_cardinality: Evidence, + sources: BTreeMap, // key: PromQL metric or SQL table +} +enum DataArrival { + AtRest, + Ingesting { rows_per_second: f64 }, + Mixed { rows_per_second: f64 }, + Unknown, +} +struct SourceFacts { + series_count: Option, + metric_type: Option, + sample_interval: Option, + stream_norms: Option, + max_distinct_per_evaluation: Option, } ``` | Field | Required | Purpose and behavior when unavailable | |---|---:|---| -| `arrival` | Present; may be `Unknown` | Distinguishes data at rest, continuously ingesting data, and mixed data. Continuous-maintenance decisions are limited when unknown. | -| `data_ingestion_interval` | Required and nonzero for PromQL; otherwise optional | Sampling cadence for each PromQL source. Bare instant selectors use it as their explicit selection horizon. | -| `ingestion_rate` | Optional evidence | Updates per second used to price continuous maintenance. It must be finite and non-negative; at-rest data cannot declare a positive rate. | -| `input_cardinality` | Optional evidence | Input row/sample count used by applicable sizing, accuracy, or cost rules. | - -##### `Evidence` fields - -Every empirical field in `DataWorkload` uses `Evidence`, which has four -fields: - -| Field | Meaning | -|---|---| -| `value: Option` | The fact itself; `None` means unavailable. | -| `source: EvidenceSource` | `Declared`, `Observed`, `Derived`, or `Unknown`. | -| `observed_at_ms: Option` | Observation timestamp used for freshness checks. | -| `valid_for_ms: Option` | Validity duration. A duration without an observation timestamp is unusable. | +| `arrival` | Present; may be `Unknown` | Distinguishes data at rest, ingesting data, and mixed data. Ingestion-time builds are possible only for ingesting or mixed data. | +| `rows_per_second` | With `Ingesting`/`Mixed` | Rows arriving per second over all sources, used to price scans and continuous maintenance. It must be finite and non-negative. | +| `series_count` | Optional | Series in the source. Plan selection bounds a query's number of values `G`, and its input row count, by the series counts of the sources it reads (each branch of an `or` counted); unknown, they stay unbound. | +| `metric_type` | Optional | Declared Prometheus type; a counter proves samples non-negative. Never inferred from a name. | +| `sample_interval` | PromQL and MetricsQL; nonzero | How often each series of the metric is sampled. Bare instant selectors over it use it as their selection horizon; without it, such a query fails to lower. | +| `stream_norms`, `max_distinct_per_evaluation` | Optional | Bounds that Count-Min/Count Sketch and HLL/UnivMon guarantees are bound with. | -Unavailable or stale data evidence stays unknown. Planner does not reinterpret -it as zero ingestion, zero cardinality, or a favorable distribution. +Every fact is declared: the planner has no clock, so a caller drops facts +that no longer hold before planning. Unavailable data facts stay unknown. Planner does not reinterpret +them as zero ingestion, zero cardinality, or a favorable distribution. ### Frontend-specific dependencies @@ -247,7 +247,7 @@ inside it: | Frontend | Additional lowering inputs | |---|---| -| PromQL | `now_ms`, plus an optional `HistogramCatalog` when histogram semantics must be resolved | +| PromQL | An optional `HistogramCatalog` when histogram semantics must be resolved | | SQL | `SqlCatalog`; single-query APIs also receive the accuracy target and optionally an explicit SQL dialect | | MetricsQL | No catalog; the single-query API receives the query text and accuracy target directly | diff --git a/docs/design_docs/architecture/updated_interface_with_pluggable_optimization.md b/docs/design_docs/architecture/updated_interface_with_pluggable_optimization.md index a283fb0be..2c31be92e 100644 --- a/docs/design_docs/architecture/updated_interface_with_pluggable_optimization.md +++ b/docs/design_docs/architecture/updated_interface_with_pluggable_optimization.md @@ -61,7 +61,7 @@ Details of these types are provided below. | Field | Meaning | |---|---| | `workload` | `&PlanningWorkload` | -| `frontend_specific` | `Sql { catalog }` / `Promql { now_ms, histograms }` / `Metricsql`; fixed by `query_workload.language` | +| `frontend_specific` | `Sql { catalog }` / `Promql { histograms }` / `Metricsql`; fixed by `query_workload.language` | | `models` | Cost model, accuracy model, evidence provider; `PlanningModels::builtin()` for the defaults. `Stage1ToStage3Pass` reads the accuracy model and the evidence provider, not the cost model | | `pass` | `None` uses `Stage1ToStage3Pass` | diff --git a/docs/develop_docs/library-api.md b/docs/develop_docs/library-api.md index bea56ae4d..9968148a7 100644 --- a/docs/develop_docs/library-api.md +++ b/docs/develop_docs/library-api.md @@ -37,7 +37,7 @@ asap-types = { git = "https://github.com/ProjectASAP/ASAPPlanner", rev = "e7fdb2 | Public function | Required input | Output | | --- | --- | --- | -| `asap_frontend_promql::lower_promql_workload` | PromQL `PlanningWorkload` with a nonzero `data_ingestion_interval` | All-or-nothing `Result>, PromqlError>` for normalized batch and repeating entries | +| `asap_frontend_promql::lower_promql_workload` | PromQL `PlanningWorkload`; a bare instant selector needs its metric's nonzero `sample_interval` in `DataWorkload.sources` | All-or-nothing `Result>, PromqlError>` for normalized batch and repeating entries | | `asap_frontend_metricsql::lower_metricsql` | Query string, `AccuracyTarget` | `Result, MetricsqlError>` | | `asap_frontend_sql::lower_sql` | Query string, `SqlCatalog`, accuracy | Async `Result, SqlError>`; default SQL dialect is DataFusionSQL | | `asap_frontend_sql::lower_sql_dialect` | Same inputs plus `SqlDialect` | Async resolved Pre-ASAP query or error | @@ -58,18 +58,16 @@ those entries with the single-query frontend. PromQL's public signature (types are imported from their respective crates): ```text -lower_promql_workload(workload: &PlanningWorkload, now_ms: u64) +lower_promql_workload(workload: &PlanningWorkload) -> Result>, PromqlError> ``` -`DataWorkload.data_ingestion_interval` must contain a nonzero `Evidence`. -Pass the actual planning time as `now_ms` (Unix milliseconds), consistently with -downstream planning. Expired or future cadence evidence is rejected, -as is expiring evidence without an observation timestamp. The histogram variant -takes the same timestamp after its histogram catalog argument. The examples use -`0` only because their explicitly supplied cadence is timeless. -Bare instant selectors receive this selection horizon; explicit range selectors -retain their query-specified range. Use `lower_promql_workload_with_histograms` +A bare instant selector reads the latest sample within the sample interval +its metric declares (`DataWorkload.sources[metric].sample_interval`, +nonzero), whatever the arrival. A query with one fails with +`WorkloadError::MissingSampleInterval` when that metric declares no +interval. Explicit range selectors retain their query-specified range and +need no interval. Use `lower_promql_workload_with_histograms` to supply a histogram catalog for the whole workload. Each entry carries its own accuracy requirement, with these explicit target choices: @@ -86,7 +84,7 @@ that a supported candidate exists. ```rust use asap_frontend_promql::lower_promql_workload; use asap_types::workload::{ - BatchEntry, DataWorkload, DurationMs, Evidence, Query, + BatchEntry, DataWorkload, DurationMs, Query, PlanningWorkload, QueryLanguage, QueryRequirements, QueryWorkload, }; use asap_types::types::AccuracyTarget; @@ -107,15 +105,13 @@ fn main() -> Result<(), Box> { }]), repeating_queries: None, }, - data_workload: Some(DataWorkload { - data_ingestion_interval: Evidence { - value: Some(DurationMs(1_000)), - ..Default::default() - }, - ..Default::default() - }), + // `latency` is sampled every second. + data_workload: Some(DataWorkload::sampled_every( + ["latency".to_string()], + DurationMs(1_000), + )), }; - let pre_asap = lower_promql_workload(&workload, 0)?; + let pre_asap = lower_promql_workload(&workload)?; println!("{pre_asap:#?}"); Ok(()) } @@ -199,7 +195,7 @@ The default cost model is suitable for inspection, not deployment calibration. ```rust use asap_frontend_promql::lower_promql_workload; use asap_types::workload::{ - BatchEntry, DataWorkload, DurationMs, Evidence, Query, + BatchEntry, DataWorkload, DurationMs, Query, PlanningWorkload, QueryLanguage, QueryRequirements, QueryWorkload, }; use asap_aware_mapping::{ @@ -225,15 +221,13 @@ fn main() -> Result<(), Box> { }]), repeating_queries: None, }, - data_workload: Some(DataWorkload { - data_ingestion_interval: Evidence { - value: Some(DurationMs(1_000)), - ..Default::default() - }, - ..Default::default() - }), + // `latency` is sampled every second. + data_workload: Some(DataWorkload::sampled_every( + ["latency".to_string()], + DurationMs(1_000), + )), }; - let root = lower_promql_workload(&workload, 0)?.remove(0); + let root = lower_promql_workload(&workload)?.remove(0); let cost_model = DefaultCostModel; let strategies = default_strategies_with(&cost_model); let space = search_workload_with_targets( @@ -346,7 +340,7 @@ replacement::default_strategies_with_evidence<'a>( ```rust use asap_frontend_promql::lower_promql_workload; use asap_types::workload::{ - BatchEntry, DataWorkload, DurationMs, Evidence, Query, + BatchEntry, DataWorkload, DurationMs, Query, PlanningWorkload, QueryLanguage, QueryRequirements, QueryWorkload, }; use asap_aware_mapping::{ @@ -372,15 +366,13 @@ fn main() -> Result<(), Box> { }]), repeating_queries: None, }, - data_workload: Some(DataWorkload { - data_ingestion_interval: Evidence { - value: Some(DurationMs(1_000)), - ..Default::default() - }, - ..Default::default() - }), + // `latency` is sampled every second. + data_workload: Some(DataWorkload::sampled_every( + ["latency".to_string()], + DurationMs(1_000), + )), }; - let root = lower_promql_workload(&workload, 0)?.remove(0); + let root = lower_promql_workload(&workload)?.remove(0); let model = DefaultCostModel; let strategies: Vec> = vec![ Box::new(ASAPStrategies::new(&model)), @@ -411,7 +403,6 @@ Module-qualified paths below are relative to `asap_aware_mapping`. | `&dyn AccuracyModel` | `DefaultAccuracyModel` | Built-in guarantee rules and satisfaction checks | | `&dyn AccuracyBudgetAllocator` | `EqualSplitAllocator` | Built-in allocation of composition accuracy budgets | | `&dyn AccuracyEvidenceProvider` | `NoAccuracyEvidence` | No extra planning-time statistics; evidence-dependent claims remain unavailable | -| `&dyn AccuracyEvidenceProvider` | `WorkloadAccuracyEvidence { data: &data, now_ms }` | Uses fresh data-workload evidence at the planning time | | Any provider trait above | Your implementation | Supplies alternative models/evidence under the same contracts | ### Offline measurements versus physical-plan costing @@ -494,15 +485,30 @@ Keep each provider's evidence scope and freshness valid for the query population inputs. `QueryWorkload` contains the language and optional batch/repeating entries. Entries carry requirements, predictability, recurrence and time selection. These facts are separate: repeated queries can read data at rest. -`DataWorkload::validate()` checks the independent data evidence: ingestion -rates must be finite and nonnegative, and data at rest cannot have a positive -ingestion rate. `PlanningWorkload::validate()` shares these checks. +`DataWorkload` holds `arrival` (`DataArrival`: `AtRest`, `Ingesting` or +`Mixed` with `rows_per_second`, or `Unknown`) and `sources`, a map from +PromQL metric or SQL table name to `SourceFacts`: `series_count`, +`metric_type`, `sample_interval` (PromQL and MetricsQL), `stream_norms` and +`max_distinct_per_evaluation`. Every fact is declared: the planner has no +clock, so a caller drops facts that no longer hold before planning. Plan +selection bounds a query's number of values `G`, and its input row count, +by the `series_count` of the sources it reads, counting each branch of an +`or` or union. `DataWorkload::validate()` checks the data facts: the +arrival rate must be finite and nonnegative, a sample interval nonzero, +stream norms finite and nonnegative, and distinct bounds positive. +`PlanningWorkload::validate()` shares these checks. + +```json +{"arrival": {"ingesting": {"rows_per_second": 66666.7}}, + "sources": {"http_requests_total": + {"series_count": 1000000, "metric_type": "counter", "sample_interval": 15000}}} +``` | Type/input | Current behavior | Caller responsibility | | --- | --- | --- | | `QueryRequirements::default()` | `AccuracyTarget::exact()`, no latency bound (`max_latency_ms: None`) | Pass approximation explicitly and thread per-root requirements into search | -| `DataWorkload::default()` | Unknown arrival, unknown evidence | Supply facts needed for the requested comparisons | -| `Evidence::default()` | No value, unknown source | Unknown/stale evidence is not zero; provide scoped valid observations | +| `DataWorkload::default()` | Unknown arrival, no source facts | Supply facts needed for the requested comparisons | +| `SourceFacts::default()` | Every fact unknown | Unknown facts are not zero; drop facts that no longer hold before planning | | `DefaultCostModel` | Built-in ordering/sizing and structural cost hooks | Supply deployment evidence for calibrated comparisons | `Default` is a Rust constructor contract, not a general serde omission rule. @@ -575,7 +581,7 @@ planning. ```rust use asap_frontend_promql::lower_promql_workload; use asap_types::workload::{ - BatchEntry, DataWorkload, DurationMs, Evidence, Query, + BatchEntry, DataWorkload, DurationMs, Query, PlanningWorkload, QueryLanguage, QueryRequirements, QueryWorkload, }; use asap_aware_mapping::{search_workload, DefaultCostModel}; @@ -597,15 +603,13 @@ fn main() -> Result<(), Box> { }]), repeating_queries: None, }, - data_workload: Some(DataWorkload { - data_ingestion_interval: Evidence { - value: Some(DurationMs(1_000)), - ..Default::default() - }, - ..Default::default() - }), + // `latency` is sampled every second. + data_workload: Some(DataWorkload::sampled_every( + ["latency".to_string()], + DurationMs(1_000), + )), }; - let root = lower_promql_workload(&workload, 0)?.remove(0); + let root = lower_promql_workload(&workload)?.remove(0); let space = search_workload(vec![("q1", root)]); let selection = space.global_selection(&DefaultCostModel); // Search may canonicalize roots; use the root returned by CandidateLogicalASAPDAGs. diff --git a/docs/develop_docs/pre-asap-ir.md b/docs/develop_docs/pre-asap-ir.md index 60db2f5fc..51d79dc2a 100644 --- a/docs/develop_docs/pre-asap-ir.md +++ b/docs/develop_docs/pre-asap-ir.md @@ -423,7 +423,7 @@ explicit concern. `kind` records which samples a PromQL selector reads: - `TimeRangeKind::Instant` — an instant selector: `range` is the lookback horizon and the latest eligible sample per series is selected (the planner injects the declared - `data_ingestion_interval` around a bare selector). + `sample_interval` of the selector's metric around a bare selector). - `TimeRangeKind::Range` — a range selector (`m[5m]`): every sample in the window. ```promql diff --git a/docs/user_guide_docs/run-a-query.md b/docs/user_guide_docs/run-a-query.md index c6f52aa61..1f172a81c 100644 --- a/docs/user_guide_docs/run-a-query.md +++ b/docs/user_guide_docs/run-a-query.md @@ -15,14 +15,14 @@ tool on first use. | Command (`cargo run -p asap-devtools --bin … -- …`) | Input / options | Result | | --- | --- | --- | -| `show_pre_asap_ir --data-ingestion-interval-ms 1000 queries.txt` | File path, or stdin when omitted | Prints canonical Pre-ASAP IR | -| `show_post_asap_ir --data-ingestion-interval-ms 1000 queries.txt` | Same query file format | Prints all sketch-strategy Post-ASAP candidates using a fixed approximate target, in cost-model order | -| `dag_export --data-ingestion-interval-ms 1000 --promql ""` | One PromQL expression | Exports a query DAG for inspection | +| `show_pre_asap_ir --sample-interval-ms 1000 queries.txt` | File path, or stdin when omitted | Prints canonical Pre-ASAP IR | +| `show_post_asap_ir --sample-interval-ms 1000 queries.txt` | Same query file format | Prints all sketch-strategy Post-ASAP candidates using a fixed approximate target, in cost-model order | +| `dag_export --sample-interval-ms 1000 --promql ""` | One PromQL expression | Exports a query DAG for inspection | | `dag_export --sql ""` | One SQL expression using the tool's catalog | Exports a query DAG for inspection | -| `analyze_corpora --corpora --data-ingestion-interval-ms 1000 --out-dir ` | Repository PromQL corpora, output directory | Writes successful/error IR dumps and summary reports | +| `analyze_corpora --corpora --sample-interval-ms 1000 --out-dir ` | Repository PromQL corpora, output directory | Writes successful/error IR dumps and summary reports | | `analyze_corpora --sql-corpora --out-dir ` | Repository SQL corpora, output directory | Writes SQL corpus reports | -| `variant_coverage --data-ingestion-interval-ms 1000` | Repository corpora | Reports Pre-ASAP IR variant coverage | -| `sketch_coverage --data-ingestion-interval-ms 1000 --epsilon 0.01` | Repository corpora; epsilon defaults to `0.01` | Reports sketch/reuse opportunities among successfully lowered queries | +| `variant_coverage --sample-interval-ms 1000` | Repository corpora | Reports Pre-ASAP IR variant coverage | +| `sketch_coverage --sample-interval-ms 1000 --epsilon 0.01` | Repository corpora; epsilon defaults to `0.01` | Reports sketch/reuse opportunities among successfully lowered queries | ## Inspect a query from the command line @@ -51,19 +51,19 @@ For your own schema, provide a `SqlCatalog` through the library API. Run: ```sh -cargo run -p asap-devtools --bin show_pre_asap_ir -- --data-ingestion-interval-ms 1000 queries.txt +cargo run -p asap-devtools --bin show_pre_asap_ir -- --sample-interval-ms 1000 queries.txt ``` You can also provide the queries through stdin: ```sh -cargo run -p asap-devtools --bin show_pre_asap_ir -- --data-ingestion-interval-ms 1000 < queries.txt +cargo run -p asap-devtools --bin show_pre_asap_ir -- --sample-interval-ms 1000 < queries.txt ``` To dump and compare every PromQL corpus, run: ```sh -cargo run -p asap-devtools --bin analyze_corpora -- --corpora --data-ingestion-interval-ms 1000 --out-dir artifacts/promql_pre_asap +cargo run -p asap-devtools --bin analyze_corpora -- --corpora --sample-interval-ms 1000 --out-dir artifacts/promql_pre_asap ``` This writes one successful-lowering JSONL dump and one error JSONL dump per @@ -83,13 +83,13 @@ cargo run -p asap-devtools --bin analyze_corpora -- --sql-corpora --out-dir arti Run: ```sh -cargo run -p asap-devtools --bin show_post_asap_ir -- --data-ingestion-interval-ms 1000 queries.txt +cargo run -p asap-devtools --bin show_post_asap_ir -- --sample-interval-ms 1000 queries.txt ``` Or through stdin: ```sh -cargo run -p asap-devtools --bin show_post_asap_ir -- --data-ingestion-interval-ms 1000 < queries.txt +cargo run -p asap-devtools --bin show_post_asap_ir -- --sample-interval-ms 1000 < queries.txt ``` `show_post_asap_ir` uses an approximation target of ε = 0.01 and prints every @@ -129,7 +129,7 @@ cargo run -p asap-devtools --bin dag_export -- --sql "" or: ```sh -cargo run -p asap-devtools --bin dag_export -- --data-ingestion-interval-ms 1000 --promql "" +cargo run -p asap-devtools --bin dag_export -- --sample-interval-ms 1000 --promql "" ``` See [`tools/dag-viewer/RUNNING.md`](../../tools/dag-viewer/RUNNING.md) for instructions on running the DAG viewer. @@ -139,7 +139,7 @@ See [`tools/dag-viewer/RUNNING.md`](../../tools/dag-viewer/RUNNING.md) for instr Parse the query corpora in the repository and report which pre-ASAP IR variants are exercised: ```sh -cargo run -p asap-devtools --bin variant_coverage -- --data-ingestion-interval-ms 1000 +cargo run -p asap-devtools --bin variant_coverage -- --sample-interval-ms 1000 ``` ### Check sketch-replacement coverage @@ -147,7 +147,7 @@ cargo run -p asap-devtools --bin variant_coverage -- --data-ingestion-interval-m Parse the same query corpora, lower them with an approximate `AccuracyTarget`, and report what fraction of each corpus's successfully-lowered queries got a genuine sketch alternative (`SketchApproximation`, e.g. KLL vs. DDSketch) and/or a cross-query common-subexpression-reuse candidate (`CommonSubexpressionReuse`): ```sh -cargo run -p asap-devtools --bin sketch_coverage -- --data-ingestion-interval-ms 1000 --epsilon 0.01 +cargo run -p asap-devtools --bin sketch_coverage -- --sample-interval-ms 1000 --epsilon 0.01 ``` `--epsilon` is optional (defaults to `0.01`). See the binary's own doc comment for exactly how "coverage" is defined and attributed back to each query. @@ -174,7 +174,7 @@ cargo run -p asap-devtools --example canonical_examples - [Pre-ASAP IR reference](../develop_docs/pre-asap-ir.md) - [Post-ASAP IR reference](../design_docs/concepts/post-asap-ir.md) -PromQL commands require `--data-ingestion-interval-ms` with the nonzero source -sample cadence in milliseconds. The examples use a one-second cadence; supply -the interval for your data. The mixed-input `show_pre_asap_ir` and +PromQL commands require `--sample-interval-ms`, the nonzero sample interval in +milliseconds declared for every metric a query reads. The examples use one +second; supply the interval for your data. The mixed-input `show_pre_asap_ir` and `show_post_asap_ir` tools require this option even for SQL-only input files. diff --git a/docs/user_guide_docs/stage-viewer.md b/docs/user_guide_docs/stage-viewer.md index 48d7d30df..956bc7c64 100644 --- a/docs/user_guide_docs/stage-viewer.md +++ b/docs/user_guide_docs/stage-viewer.md @@ -212,7 +212,7 @@ cargo run -p asap-devtools --bin stage_pipeline -- \ | --- | --- | | `--promql ` | A query; repeat for several | | `--epsilon`, `--delta` | Accuracy for every query; without them the queries are exact | -| `--interval-ms ` | Sample interval (default 15000) | +| `--sample-interval-ms ` | Sample interval of every metric the queries read (default 15000) | | `--example ` | A built-in example instead of `--promql`: `planner-layering-1`, `-2`, `-3a`, `-3b`, `-4a` or `-4b` | | `--max-displayed ` | How many plans the document shows (default 64), in enumeration order, plus the selected plan. The planner still optimizes and prices every plan, so the selection does not depend on it. | | `--out ` | Where to write the document | diff --git a/tools/dag-viewer/server.py b/tools/dag-viewer/server.py index c6d358182..8150b5af3 100644 --- a/tools/dag-viewer/server.py +++ b/tools/dag-viewer/server.py @@ -139,7 +139,7 @@ def plan_args(payload: object, out: str) -> list[str]: interval = payload.get("interval_ms", 15_000) if isinstance(interval, bool) or not isinstance(interval, int) or interval <= 0: raise ValueError("interval_ms must be a positive integer") - args.extend(["--interval-ms", str(interval), "--out", out]) + args.extend(["--sample-interval-ms", str(interval), "--out", out]) return args diff --git a/tools/dag-viewer/test_server.py b/tools/dag-viewer/test_server.py index 3e8a447f0..56e5a7b7e 100644 --- a/tools/dag-viewer/test_server.py +++ b/tools/dag-viewer/test_server.py @@ -31,7 +31,7 @@ def test_promql_queries_with_epsilon(self): ) self.assertEqual( args[1:], - ["--promql", "sum(rate(m[1m]))", "--name", "q1", "--epsilon", "0.01", "--interval-ms", "15000", "--out", "out.json"], + ["--promql", "sum(rate(m[1m]))", "--name", "q1", "--epsilon", "0.01", "--sample-interval-ms", "15000", "--out", "out.json"], ) def test_metric_names_the_targets_error_metric(self):