From c832404aca0feda63dd94792c1487ebfe5237550 Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Fri, 16 Oct 2026 01:46:19 +0000 Subject: [PATCH 01/12] refactor(types)!: DataWorkload as arrival plus per-source facts DataArrival carries rows_per_second and the PromQL sample_interval; sources maps each PromQL metric or SQL table to SourceFacts (series, metric_type, stream_norms, max_distinct_per_evaluation, one Evidence). Stage 3 binds G and the input row count from the series of the sources a query reads, and pricing from the sources each node reads. The frontends require the sample interval only for a bare instant selector, and no longer take a planning clock. WorkloadAccuracyEvidence, the generic Evidence and the per-field evidence are removed. --- .../src/accuracy/evidence.rs | 67 --- crates/asap-aware-mapping/src/accuracy/mod.rs | 5 +- .../asap-aware-mapping/src/analytical_cost.rs | 18 +- crates/asap-aware-mapping/src/lib.rs | 2 +- .../src/physical_operator_statistics.rs | 2 +- .../src/query_physical_lowering.rs | 10 +- crates/asap-aware-mapping/src/recurrence.rs | 28 +- crates/asap-aware-mapping/src/replacement.rs | 4 +- .../src/stage2_physical_asap_optimization.rs | 14 +- .../src/stage3_plan_selection.rs | 405 ++++++++++------ crates/asap-aware-mapping/src/test_support.rs | 10 +- .../tests/per_computation_candidates.rs | 8 +- .../src/physical_planner/candidates.rs | 8 +- .../tests/common/mod.rs | 10 +- .../tests/current_series_heap.rs | 8 +- .../tests/deployment_computation.rs | 8 +- .../tests/planspace_series_identity_heap.rs | 12 +- .../tests/precompute_candidates.rs | 16 +- .../tests/weighted_topk_binding.rs | 12 +- .../devtools/examples/canonical_examples.rs | 4 +- crates/devtools/examples/topk_ir.rs | 4 +- crates/devtools/src/bin/analyze_corpora.rs | 8 +- crates/devtools/src/bin/show_logical_dag.rs | 9 +- crates/devtools/src/bin/sketch_coverage.rs | 4 +- crates/devtools/src/bin/stage_pipeline.rs | 32 +- crates/devtools/src/bin/variant_coverage.rs | 5 +- crates/devtools/src/candidate_labels.rs | 6 +- crates/devtools/src/lib.rs | 25 +- .../devtools/src/planner_layering_examples.rs | 123 +++-- crates/devtools/src/planning_stages.rs | 2 +- crates/devtools/tests/cross_language.rs | 4 +- crates/devtools/tests/stage_pipeline.rs | 8 +- crates/frontend-metricsql/src/lib.rs | 38 +- .../tests/lowering_semantics.rs | 31 +- crates/frontend-promql/src/lib.rs | 158 +++--- crates/frontend-promql/src/promql.rs | 91 ++-- .../tests/observability/promql_corpus.rs | 8 +- .../frontend-promql/tests/promql_lowering.rs | 12 +- crates/frontend-promql/tests/scalar_design.rs | 8 +- crates/frontend-promql/tests/support.rs | 20 +- crates/integration-tests/src/lib.rs | 10 +- .../tests/planner_layering_common/mod.rs | 2 +- .../tests/planner_layering_example1.rs | 6 +- .../tests/planner_layering_example2.rs | 39 +- .../tests/planner_layering_example3.rs | 8 +- .../tests/planner_layering_example4.rs | 8 +- .../tests/precompute_raw_samples.rs | 8 +- crates/integration-tests/tests/reuse_rules.rs | 7 +- .../tests/stage1_logical_asap_optimization.rs | 10 +- crates/planner/src/lib.rs | 35 +- crates/planner/tests/e2e_plan.rs | 34 +- crates/planner/tests/plan_stages.rs | 12 +- crates/planner/tests/summary_sharing.rs | 26 +- crates/types/src/post_asap/sketch.rs | 2 +- crates/types/src/resources/cache.rs | 2 +- crates/types/src/workload.rs | 456 +++++++++++------- crates/types/tests/workload_compatibility.rs | 43 +- 57 files changed, 1071 insertions(+), 884 deletions(-) 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..b874d6fd3 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,14 @@ 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, + sample_interval: None + } + ), Err(AnalyticalCostError::InvalidCacheEvidence(_)) )); } @@ -4009,7 +4016,10 @@ 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, + sample_interval: None, + }; 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/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..947671937 100644 --- a/crates/asap-aware-mapping/src/query_physical_lowering.rs +++ b/crates/asap-aware-mapping/src/query_physical_lowering.rs @@ -2867,7 +2867,7 @@ mod tests { ) { use crate::physical_operator_statistics::ScalarInputStatistics; use asap_types::workload::{ - BatchEntry, DataWorkload, Evidence, PlanningWorkload, Predictability, Query, + BatchEntry, DataArrival, DataWorkload, PlanningWorkload, Predictability, Query, QueryLanguage, QueryRequirements, QueryWorkload, }; use std::cell::RefCell; @@ -2888,14 +2888,14 @@ mod tests { repeating_queries: None, }, data_workload: Some(DataWorkload { - data_ingestion_interval: Evidence { - value: Some(DurationMs(1_000)), - ..Default::default() + arrival: DataArrival::Ingesting { + rows_per_second: 1_000.0, + sample_interval: Some(DurationMs(1_000)), }, ..Default::default() }), }; - 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..acba01fd9 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,11 @@ 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, + sample_interval: None, }, ..Default::default() }; @@ -689,9 +689,9 @@ 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, + sample_interval: None, }, ..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..5110a8e6c 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,10 @@ mod tests { fn arriving() -> DataWorkload { DataWorkload { - arrival: DataArrival::ContinuouslyIngesting, + arrival: DataArrival::Ingesting { + rows_per_second: 1_000.0, + sample_interval: None, + }, ..Default::default() } } @@ -1109,7 +1112,10 @@ mod tests { }; let table = QueryRoot::Operator(to_table(node)); let mixed = DataWorkload { - arrival: DataArrival::Mixed, + arrival: DataArrival::Mixed { + rows_per_second: 1_000.0, + sample_interval: None, + }, ..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..08fe77ee2 100644 --- a/crates/asap-aware-mapping/src/stage3_plan_selection.rs +++ b/crates/asap-aware-mapping/src/stage3_plan_selection.rs @@ -142,10 +142,10 @@ 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, + DataWorkload, MetricType, QueryRecurrence, QueryWorkloadEntry, RepeatedDemand, SourceFacts, }; use thiserror::Error; @@ -899,21 +899,64 @@ fn describe_usage(usage: &ResourceUsage) -> String { /// Without a planning time, only evidence that cannot expire is fresh. const ANY_TIME: u64 = u64::MAX; +/// `source`'s facts in `data` while their evidence holds at any planning +/// time (no validity window): the facts accuracy guarantees may bind. +fn fresh_source_facts<'a>(data: &'a DataWorkload, source: &str) -> Option<&'a SourceFacts> { + data.sources + .get(source) + .filter(|facts| facts.evidence.is_fresh_at(ANY_TIME)) +} + +/// The sources (PromQL metric or SQL table names) the scans under `nodes` +/// read. +fn sources_read<'a>(nodes: impl IntoIterator>) -> BTreeSet { + nodes + .into_iter() + .flat_map(OperatorNode::reachable) + .filter_map(|node| match node.non_asap() { + Some(NonASAPOp::Scan { source, .. }) => Some(source_name(source).to_string()), + _ => None, + }) + .collect() +} + +/// 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, + } +} + +/// The series the sources `root` reads hold together, from fresh source +/// facts: an upper bound on the series (and rows per evaluation) any of +/// its folds sees. `None` when it reads no source or any source's series +/// is unknown or not fresh. +fn series_read(root: &QueryRoot, data: &DataWorkload) -> Option { + let sources = sources_read(root_operators(root)); + if sources.is_empty() { + return None; + } + sources + .iter() + .map(|source| fresh_source_facts(data, source)?.series) + .sum() +} + /// 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. +/// provable from the query; otherwise, from `data`, at most the series of +/// the sources it reads ([`series_read`]) 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 { 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(root, data).map(|series| series.max(1)) + } QueryRoot::Operator(_) => None, } } @@ -1067,16 +1110,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 = fresh_source_facts(data, &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), } } @@ -1208,7 +1246,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 +1319,8 @@ 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, from the series of the sources `root` reads +/// ([`series_read`]); /// - 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 @@ -1293,9 +1340,11 @@ fn bound_root_guarantee( models: &PlanningModels<'_>, ) -> Option { let guarantee = root_guarantee(root)?; - let domains = operand_domain_bounds(root, data, models); + let series = series_read(root, data); + 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 +1365,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 = fresh_source_facts(data, 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 = fresh_source_facts(data, 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 = fresh_source_facts(data, source)?.stream_norms?; match norm { STREAM_L1_NORM => norms.l1, _ => norms.l2, @@ -1408,9 +1453,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 +1505,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 +1546,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 +1616,24 @@ 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, [`DEFAULT_SERIES`] for each source without one +/// and for a node that reads none. Pricing reads a source's series whatever +/// its evidence's freshness. +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) + .unwrap_or(DEFAULT_SERIES) + }) + .sum::() + .max(1) } /// Evaluations per second of a node computed once for `queries` together. @@ -1746,19 +1810,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, @@ -1788,6 +1846,17 @@ fn price( .filter(|e| e.consumer == node.id) .map(|e| output[&e.producer]) .collect(); + let sources: BTreeSet<&str> = match &node.payload { + Payload::NonASAP(NonASAPOp::Scan { source, .. }) => [source_name(source)].into(), + _ => dag + .edges + .iter() + .filter(|e| e.consumer == node.id) + .flat_map(|e| sources_read_by[&e.producer].iter().copied()) + .collect(), + }; + let series = priced_series(&sources, data); + sources_read_by.insert(node.id, sources); let input = inputs .first() .copied() @@ -1806,7 +1875,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 +1894,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 +2004,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 { @@ -2570,7 +2639,7 @@ 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, Evidence, MetricType}; /// `n` queries repeating every 10 s, as Example 1's panels do. fn recurrences(n: usize) -> Vec { @@ -2584,18 +2653,44 @@ 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, + sample_interval: None, }, - input_cardinality: Evidence { - value: Some(10_000), - ..Default::default() - }, - ..Default::default() + sources: TEST_SOURCES + .iter() + .map(|source| { + let facts = SourceFacts { + 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 @@ -2604,16 +2699,17 @@ mod tests { #[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 +2830,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,10 +2938,7 @@ 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()) .unwrap() @@ -2876,14 +2966,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 +3021,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), @@ -3245,10 +3325,7 @@ mod tests { #[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_cardinality = with_series(None); let Err(SelectionError::NoValidCandidate(rejected)) = select_plan( &candidates, &[entry_with(whole, repeating(10_000))], @@ -3345,6 +3422,54 @@ mod tests { ); } + /// `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 unknown or expired source + /// 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: 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_per_result(&x, &series(&[("x", 10)])), Some(10)); + assert_eq!(values_per_result(&x, &series(&[("m", 10)])), None); + let declared = series(&[("x", 10), ("m", 20)]); + assert_eq!(values_per_result(&x, &declared), Some(10)); + assert_eq!(values_per_result(&both, &declared), Some(30)); + assert_eq!(series_read(&both, &declared), Some(30)); + assert_eq!(values_per_result(&both, &series(&[("x", 10)])), None); + let mut expired = declared.clone(); + expired.sources.get_mut("m").unwrap().evidence = Evidence { + observed_at_ms: Some(0), + valid_for_ms: Some(1), + }; + assert_eq!(values_per_result(&x, &expired), Some(10)); + assert_eq!(values_per_result(&both, &expired), 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!( + priced_series(&["x", "y"].into(), &declared), + 10 + DEFAULT_SERIES + ); + assert_eq!(priced_series(&BTreeSet::new(), &declared), DEFAULT_SERIES); + } + /// Deployment evidence that every output was measured exact. struct EveryOutputMeasuredExact; @@ -3375,10 +3500,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 +3815,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( @@ -4069,10 +4185,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 +4257,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 @@ -4324,15 +4428,13 @@ mod tests { fn stage3_binds_only_fresh_input_cardinality() { 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), + let mut expiring = data(); + for facts in expiring.sources.values_mut() { + facts.evidence = Evidence { 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 ddsketch: Vec<_> = candidates .iter() @@ -4772,10 +4874,9 @@ 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, + sample_interval: None, }, ..data() } diff --git a/crates/asap-aware-mapping/src/test_support.rs b/crates/asap-aware-mapping/src/test_support.rs index c34cb8f9b..215af769e 100644 --- a/crates/asap-aware-mapping/src/test_support.rs +++ b/crates/asap-aware-mapping/src/test_support.rs @@ -6,7 +6,7 @@ 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, + BatchEntry, DataArrival, DataWorkload, DurationMs, PlanningWorkload, Predictability, Query, QueryLanguage, QueryRequirements, QueryWorkload, }; @@ -27,14 +27,14 @@ 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() + arrival: asap_types::workload::DataArrival::Ingesting { + rows_per_second: 1_000.0, + sample_interval: Some(asap_types::workload::DurationMs(1000)), }, ..Default::default() }), }; - 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..ff9407b36 100644 --- a/crates/asap-physical-operators/src/physical_planner/candidates.rs +++ b/crates/asap-physical-operators/src/physical_planner/candidates.rs @@ -360,14 +360,14 @@ mod tests { repeating_queries: None, }, data_workload: Some(DataWorkload { - data_ingestion_interval: Evidence { - value: Some(DurationMs(1000)), - ..Default::default() + arrival: DataArrival::Ingesting { + rows_per_second: 1_000.0, + sample_interval: Some(DurationMs(1000)), }, ..Default::default() }), }; - 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..a837d1ae7 100644 --- a/crates/asap-physical-operators/tests/common/mod.rs +++ b/crates/asap-physical-operators/tests/common/mod.rs @@ -58,13 +58,11 @@ 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, + sample_interval: None, }, ..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..d11c07c16 100644 --- a/crates/asap-physical-operators/tests/current_series_heap.rs +++ b/crates/asap-physical-operators/tests/current_series_heap.rs @@ -315,14 +315,14 @@ 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() + arrival: DataArrival::Ingesting { + rows_per_second: 1_000.0, + sample_interval: Some(DurationMs(60_000)), }, ..Default::default() }), }; - 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..56a1a5cc5 100644 --- a/crates/asap-physical-operators/tests/deployment_computation.rs +++ b/crates/asap-physical-operators/tests/deployment_computation.rs @@ -35,14 +35,14 @@ 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() + arrival: DataArrival::Ingesting { + rows_per_second: 1_000.0, + sample_interval: Some(DurationMs(1_000)), }, ..Default::default() }), }; - 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..d0e781058 100644 --- a/crates/asap-physical-operators/tests/precompute_candidates.rs +++ b/crates/asap-physical-operators/tests/precompute_candidates.rs @@ -42,14 +42,14 @@ 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..7a2365676 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, diff --git a/crates/devtools/src/bin/show_logical_dag.rs b/crates/devtools/src/bin/show_logical_dag.rs index f6516279c..e7919f578 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; @@ -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..7002b5ca0 100644 --- a/crates/devtools/src/bin/sketch_coverage.rs +++ b/crates/devtools/src/bin/sketch_coverage.rs @@ -14,7 +14,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}; @@ -251,7 +251,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..370def0cc 100644 --- a/crates/devtools/src/bin/stage_pipeline.rs +++ b/crates/devtools/src/bin/stage_pipeline.rs @@ -68,8 +68,9 @@ // `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": ..., +// `--interval-ms` is the PromQL sample interval (default 15000); the data +// is taken as ingesting at 1,000 rows per second, with no source 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 @@ -83,7 +84,7 @@ 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, SharedArrival, }; use asap_devtools::planning_stages::{ lower_promql_roots, lower_sql_roots, run_planning_stages_over, PlanningStagesError, @@ -291,7 +292,7 @@ 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, SharedArrival::Mixed), SqlCatalog::new(), ), "planner-layering-3b" => (pattern_b(), SqlCatalog::new()), @@ -356,10 +357,7 @@ fn run(args: Vec) -> Result<(), Failure> { query["id"] = json!(name); described.push(query); } - let data = DataWorkload { - data_ingestion_interval: declared(DurationMs(interval_ms)), - ..Default::default() - }; + let data = adhoc_data_workload(interval_ms); let result = run_planning_stages_over( roots.clone(), &entries, @@ -678,10 +676,20 @@ fn query_batch( ), repeating_queries: None, }, - data_workload: Some(DataWorkload { - data_ingestion_interval: declared(DurationMs(interval_ms)), - ..Default::default() - }), + data_workload: Some(adhoc_data_workload(interval_ms)), + } +} + +/// The data `--promql`/`--sql` queries read: ingesting, each series +/// sampled every `interval_ms` (`--interval-ms`), at Stage 3's default rate +/// of 1,000 rows per second, with no source facts. +fn adhoc_data_workload(interval_ms: u64) -> DataWorkload { + DataWorkload { + arrival: DataArrival::Ingesting { + rows_per_second: 1_000.0, + sample_interval: Some(DurationMs(interval_ms)), + }, + ..Default::default() } } diff --git a/crates/devtools/src/bin/variant_coverage.rs b/crates/devtools/src/bin/variant_coverage.rs index 9f277df73..ba5645a50 100644 --- a/crates/devtools/src/bin/variant_coverage.rs +++ b/crates/devtools/src/bin/variant_coverage.rs @@ -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}; @@ -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..dfd0c9cb8 100644 --- a/crates/devtools/src/candidate_labels.rs +++ b/crates/devtools/src/candidate_labels.rs @@ -444,10 +444,10 @@ 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, SharedArrival}; use asap_types::types::AccuracyTarget; use asap_types::workload::{ - DataArrival, PlanningWorkload, Predictability, Query, QueryLanguage, QueryRequirements, + PlanningWorkload, Predictability, Query, QueryLanguage, QueryRequirements, QueryWorkload, RepeatedDemand, RepeatingEntry, RepetitionInterval, }; let panel = |query: &str| RepeatingEntry { @@ -469,7 +469,7 @@ mod tests { panel("max_over_time(a[1m])"), ]), }, - data_workload: Some(shared_data_workload(DataArrival::ContinuouslyIngesting)), + data_workload: Some(shared_data_workload(SharedArrival::Ingesting, "a")), }; 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..1ecaf20af 100644 --- a/crates/devtools/src/lib.rs +++ b/crates/devtools/src/lib.rs @@ -26,15 +26,16 @@ 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 over ingesting data whose +/// series are sampled every `interval_ms`. 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, DataArrival, DataWorkload, DurationMs, PlanningWorkload, Predictability, Query, QueryRequirements, QueryWorkload, }; @@ -54,19 +55,13 @@ 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() + arrival: DataArrival::Ingesting { + rows_per_second: 1_000.0, + sample_interval: Some(DurationMs(interval_ms)), }, ..Default::default() }), }; - 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..acf097397 100644 --- a/crates/devtools/src/planner_layering_examples.rs +++ b/crates/devtools/src/planner_layering_examples.rs @@ -4,42 +4,57 @@ 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 { +/// How the #509 shared data workload arrives. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SharedArrival { + /// Stored, nothing arriving. + AtRest, + /// Arriving while the queries run. + Ingesting, + /// Stored history with new rows arriving. + Mixed, +} + +/// The #509 shared data: one million series of `source`, each sampled +/// every 15 s, so 1,000,000 / 15 rows per second arrive while ingesting. +pub fn shared_data_workload(arrival: SharedArrival, source: &str) -> DataWorkload { + let (rows_per_second, sample_interval) = (1_000_000.0 / 15.0, Some(DurationMs(15_000))); 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)), + arrival: match arrival { + SharedArrival::AtRest => DataArrival::AtRest, + SharedArrival::Ingesting => DataArrival::Ingesting { + rows_per_second, + sample_interval, + }, + SharedArrival::Mixed => DataArrival::Mixed { + rows_per_second, + sample_interval, + }, }, - 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: Some(1_000_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 +84,13 @@ 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(SharedArrival::Ingesting, EXAMPLE1_METRIC); + data.sources + .get_mut(EXAMPLE1_METRIC) + .expect("the shared source") + .metric_type = Some(MetricType::Counter); + data }), } } @@ -127,10 +146,20 @@ pub fn example2() -> (PlanningWorkload, asap_frontend_sql::SqlCatalog) { .collect(), ), }, + // SQL needs no sample interval; `flows` has 10,000,000 sources. 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, + sample_interval: None, + }, + sources: [( + "flows".to_string(), + SourceFacts { + series: Some(10_000_000), + ..SourceFacts::default() + }, + )] + .into(), }), }; let catalog = asap_frontend_sql::SqlCatalog::new().with_table( @@ -147,6 +176,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] = [ @@ -189,7 +221,7 @@ fn pattern_a_requirements() -> QueryRequirements { } /// Example 3, Pattern A: p99 over five historical intervals. -pub fn pattern_a(recurrence: PatternARecurrence, arrival: DataArrival) -> PlanningWorkload { +pub fn pattern_a(recurrence: PatternARecurrence, arrival: SharedArrival) -> PlanningWorkload { let (batch, repeating) = match recurrence { PatternARecurrence::OnceAdHoc => ( Some( @@ -231,7 +263,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 +291,10 @@ 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( + SharedArrival::Ingesting, + PATTERN_METRIC, + )), } } @@ -267,15 +302,21 @@ 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: Pattern A reads + /// only explicit ranges, so it needs no sample interval. #[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 [ + SharedArrival::AtRest, + SharedArrival::Ingesting, + SharedArrival::Mixed, + ] { + 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..4d0a83089 100644 --- a/crates/devtools/tests/stage_pipeline.rs +++ b/crates/devtools/tests/stage_pipeline.rs @@ -3,14 +3,16 @@ 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, SharedArrival, +}; use asap_devtools::planning_stages::{ lower_promql_roots, run_planning_stages, PlanningStagesError, }; 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 +205,7 @@ 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, SharedArrival::Mixed); 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; diff --git a/crates/frontend-metricsql/src/lib.rs b/crates/frontend-metricsql/src/lib.rs index 04c93f20d..9beff39bc 100644 --- a/crates/frontend-metricsql/src/lib.rs +++ b/crates/frontend-metricsql/src/lib.rs @@ -43,10 +43,10 @@ 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`. +/// the data workload's `sample_interval`, and fails with +/// [`WorkloadError::MissingSampleInterval`] when none is declared. 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,21 +55,17 @@ 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 = workload + .sample_interval() + .map(|DurationMs(ms)| Duration::from_millis(ms)); workload .query_workload .entries() .map(|entry| { - lower_metricsql_query( + lower_with_sample_interval( &entry.query.0, entry.requirements.accuracy.clone(), - Duration::from_millis(interval_ms), + sample_interval, ) }) .collect() @@ -92,6 +88,17 @@ pub fn lower_metricsql_query( query: &str, accuracy: AccuracyTarget, ingestion_interval: Duration, +) -> Result { + lower_with_sample_interval(query, accuracy, Some(ingestion_interval)) +} + +/// [`lower_metricsql_query`] with the sample interval the data workload +/// declares, `None` when it declares none: a bare instant selector then +/// fails with [`WorkloadError::MissingSampleInterval`]. +fn lower_with_sample_interval( + query: &str, + accuracy: AccuracyTarget, + sample_interval: Option, ) -> Result { let ast = parse_metricsql(query)?; if let Expr::NumberLiteral(number) = &ast { @@ -101,7 +108,7 @@ pub fn lower_metricsql_query( } let unresolved = Lowerer { accuracy, - ingestion_interval, + sample_interval, } .lower(&ast)?; resolve_root(&unresolved) @@ -111,7 +118,8 @@ pub fn lower_metricsql_query( struct Lowerer { accuracy: AccuracyTarget, - ingestion_interval: Duration, + /// What a bare instant selector looks back; `None` when undeclared. + sample_interval: Option, } impl Lowerer { @@ -119,7 +127,9 @@ impl Lowerer { match expr { // An instant selector: the latest sample within the interval. Expr::MetricExpression(e) => Ok(U::TimeRange { - range: self.ingestion_interval, + range: self + .sample_interval + .ok_or(WorkloadError::MissingSampleInterval)?, kind: TimeRangeKind::Instant, child: Rc::new(self.metric(e)?), }), diff --git a/crates/frontend-metricsql/tests/lowering_semantics.rs b/crates/frontend-metricsql/tests/lowering_semantics.rs index 438cf1db4..ccd22a36d 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, WorkloadError, }; const INTERVAL: Duration = Duration::from_secs(15); @@ -33,9 +33,9 @@ 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() + arrival: DataArrival::Ingesting { + rows_per_second: 1_000.0, + sample_interval: Some(DurationMs(15_000)), }, ..Default::default() }), @@ -103,11 +103,11 @@ 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. +/// language, as `lower_promql_query_workload` does. #[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 +117,22 @@ 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(_)) + lower_metricsql_query_workload(&missing), + 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..66886fa62 100644 --- a/crates/frontend-promql/src/lib.rs +++ b/crates/frontend-promql/src/lib.rs @@ -15,23 +15,23 @@ pub mod promql; 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 the data +/// workload's `sample_interval` (declared by an `Ingesting` or `Mixed` +/// [`DataArrival`](asap_types::workload::DataArrival)); a query with one +/// fails with `WorkloadError::MissingSampleInterval` when none is declared. +/// 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 +39,30 @@ 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) } /// 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 +75,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 +83,17 @@ 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 = workload + .sample_interval() + .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_interval( &entry.query.0, &entry.requirements.accuracy.clone(), - std::time::Duration::from_millis(interval_ms), + sample_interval, )?; Ok(root) }) @@ -111,55 +102,12 @@ 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::workload::{ - BatchEntry, DataWorkload, Evidence, PlanningWorkload, Query, QueryRequirements, - QueryWorkload, + BatchEntry, DataArrival, DataWorkload, PlanningWorkload, Query, QueryRequirements, + QueryWorkload, WorkloadError, }; use super::*; @@ -178,9 +126,9 @@ mod tests { repeating_queries: None, }, data_workload: Some(DataWorkload { - data_ingestion_interval: Evidence { - value: Some(DurationMs(1_000)), - ..Default::default() + arrival: DataArrival::Ingesting { + rows_per_second: 1_000.0, + sample_interval: Some(DurationMs(1_000)), }, ..Default::default() }), @@ -188,10 +136,10 @@ mod tests { } // A bare instant selector reads the latest sample within the declared - // ingestion interval: an `Instant` lookback of that length. + // 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") }; @@ -206,7 +154,7 @@ mod tests { // 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 +166,51 @@ mod tests { ); } + // Data at rest, an unknown arrival, no data workload, or an ingesting + // arrival without `sample_interval` declare no interval: an instant + // selector (bare, under an aggregation, or inside a subquery) fails, + // with and without histograms, while 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_a_sample_interval() { + let arrivals = [ + Some(DataArrival::AtRest), + Some(DataArrival::Unknown), + Some(DataArrival::Ingesting { + rows_per_second: 1.0, + sample_interval: None, + }), + None, + ]; + for arrival in arrivals { + let with = |query: &str| { + let mut input = workload(query); + input.data_workload = arrival.map(|arrival| DataWorkload { + arrival, + ..Default::default() + }); + input + }; + for query in ["data", "sum(data)", "max_over_time(data[5m:1m])"] { + 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, data[5y] offset 1y)")).unwrap(); + } } } diff --git a/crates/frontend-promql/src/promql.rs b/crates/frontend-promql/src/promql.rs index 82cd8df61..3508754a1 100644 --- a/crates/frontend-promql/src/promql.rs +++ b/crates/frontend-promql/src/promql.rs @@ -80,6 +80,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,13 +182,16 @@ struct Inner { const MAX_DEPTH: usize = 256; impl PromqlLowerer { - pub(crate) fn lower_query_with_ingestion_interval( + /// Lower `query`; a bare instant selector reads the latest sample within + /// `sample_interval`, and fails with + /// [`WorkloadError::MissingSampleInterval`] when it is `None`. + pub(crate) fn lower_query_with_sample_interval( query: &str, accuracy: &AccuracyTarget, - interval: Duration, + sample_interval: Option, ) -> Result { let _guard = AccuracyGuard::install(accuracy.clone()); - let _interval = IngestionIntervalGuard::install(interval); + let _interval = SampleIntervalGuard::install(sample_interval); let ast = parser::parse(query).map_err(LoweringError::Parse)?; check_depth(&ast, MAX_DEPTH)?; let mut metrics = Vec::new(); @@ -216,7 +220,7 @@ impl PromqlLowerer { 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_INTERVAL: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; } /// RAII guard installing `accuracy` as the ambient accuracy target for the @@ -243,26 +247,29 @@ fn current_accuracy() -> AccuracyTarget { ACCURACY.with(|a| a.borrow().clone()) } -struct IngestionIntervalGuard(Option); +/// RAII guard installing the workload's sample interval (`None` when it +/// declares none) for the current thread's lowering. +struct SampleIntervalGuard(Option); -impl IngestionIntervalGuard { - fn install(interval: Duration) -> Self { - Self(INGESTION_INTERVAL.with(|current| current.replace(Some(interval)))) +impl SampleIntervalGuard { + fn install(sample_interval: Option) -> Self { + Self(SAMPLE_INTERVAL.with(|current| current.replace(sample_interval))) } } -impl Drop for IngestionIntervalGuard { +impl Drop for SampleIntervalGuard { fn drop(&mut self) { - INGESTION_INTERVAL.with(|current| *current.borrow_mut() = self.0.take()); + SAMPLE_INTERVAL.with(|current| *current.borrow_mut() = self.0.take()); } } -fn current_ingestion_interval() -> Duration { - INGESTION_INTERVAL.with(|current| { - current - .borrow() - .expect("ingestion interval is installed for workload lowering") - }) +/// The installed sample interval, which a bare instant selector needs. +fn current_sample_interval() -> Result { + SAMPLE_INTERVAL + .with(|current| *current.borrow()) + .ok_or(LoweringError::InvalidWorkload( + WorkloadError::MissingSampleInterval, + )) } /// Bounded depth check over the parser AST: errors once nesting would exceed @@ -354,7 +361,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)?; @@ -1630,10 +1637,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 +1649,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 +1678,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 +1693,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 +1733,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 +1760,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,7 +1794,7 @@ fn windowed_aggregate( inner: Inner, keys: Vec, intent: AggIntent, -) -> Unresolved { +) -> Result { let base = filtered_source(inner.metric, inner.matchers, inner.shift); let child = match inner.window { Some(w) => Unresolved::TimeRange { @@ -1795,10 +1802,10 @@ fn windowed_aggregate( kind: TimeRangeKind::Range, child: Rc::new(base), }, - None => ingestion_lookback(base), + None => sample_lookback(base)?, }; 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 +1815,7 @@ fn windowed_aggregate( filters: vec![], having: None, child: Rc::new(child), - } + }) } /// `Aggregate{reduction, [intent]}` directly over an existing Unresolved sub-DAG — the @@ -1868,17 +1875,17 @@ 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)) +/// sample interval, so the lookback is an `Instant` `TimeRange`. +fn instant_source(metric: String, matchers: Vec, shift: TimeShift) -> Result { + sample_lookback(filtered_source(metric, matchers, shift)) } -fn ingestion_lookback(child: Unresolved) -> Unresolved { - Unresolved::TimeRange { - range: current_ingestion_interval(), +fn sample_lookback(child: Unresolved) -> Result { + Ok(Unresolved::TimeRange { + range: current_sample_interval()?, kind: TimeRangeKind::Instant, child: Rc::new(child), - } + }) } /// Count vector elements regardless of their sample values. diff --git a/crates/frontend-promql/tests/observability/promql_corpus.rs b/crates/frontend-promql/tests/observability/promql_corpus.rs index d460a9257..30329ded6 100644 --- a/crates/frontend-promql/tests/observability/promql_corpus.rs +++ b/crates/frontend-promql/tests/observability/promql_corpus.rs @@ -47,10 +47,10 @@ 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(_) => t.rejected += 1, diff --git a/crates/frontend-promql/tests/promql_lowering.rs b/crates/frontend-promql/tests/promql_lowering.rs index 81fdeec23..be956bee6 100644 --- a/crates/frontend-promql/tests/promql_lowering.rs +++ b/crates/frontend-promql/tests/promql_lowering.rs @@ -11,7 +11,7 @@ use asap_types::pre_asap::{ }; use asap_types::types::AccuracyTarget; use asap_types::workload::{ - BatchEntry, DataWorkload, DurationMs, Evidence, PlanningWorkload, Predictability, Query, + BatchEntry, DataArrival, DataWorkload, DurationMs, PlanningWorkload, Predictability, Query, QueryLanguage, QueryRequirements, QueryWorkload, }; @@ -1189,14 +1189,14 @@ 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() + arrival: DataArrival::Ingesting { + rows_per_second: 1_000.0, + sample_interval: Some(DurationMs(1_000)), }, ..Default::default() }), }; - 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 +1218,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..543ad2470 100644 --- a/crates/frontend-promql/tests/support.rs +++ b/crates/frontend-promql/tests/support.rs @@ -7,7 +7,7 @@ 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, + BatchEntry, DataArrival, DataWorkload, DurationMs, PlanningWorkload, Predictability, Query, QueryLanguage, QueryRequirements, QueryWorkload, }; @@ -28,9 +28,9 @@ 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() + arrival: DataArrival::Ingesting { + rows_per_second: 1_000.0, + sample_interval: Some(DurationMs(1_000)), }, ..Default::default() }), @@ -42,7 +42,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 +53,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 +86,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..551b687cc 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, DataArrival, DataWorkload, DurationMs, PlanningWorkload, Predictability, Query, QueryLanguage, QueryRequirements, QueryWorkload, }; use std::rc::Rc; @@ -56,14 +56,14 @@ pub mod fixtures { repeating_queries: None, }, data_workload: Some(DataWorkload { - data_ingestion_interval: Evidence { - value: Some(DurationMs(1_000)), - ..Default::default() + arrival: DataArrival::Ingesting { + rows_per_second: 1_000.0, + sample_interval: Some(DurationMs(1_000)), }, ..Default::default() }), }; - 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/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..8b5bb4b4f 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::*; @@ -46,6 +46,8 @@ const SQL_Q3: &str = "SELECT SQRT(SUM(CAST(c AS DOUBLE) * CAST(c AS DOUBLE))) FR WHERE ts >= now() - INTERVAL '1 minute' GROUP BY src_ip)"; /// PromQL stand-in for Q1–Q3 (see the module docs). +/// The metric the PromQL stand-in reads. +const STANDIN_METRIC: &str = "flows_src_ip"; const PROMQL_Q1: &str = "distinct_over_time(flows_src_ip[1m])"; const PROMQL_Q2: &str = "entropy_over_time(flows_src_ip[1m])"; const PROMQL_Q3: &str = "l2_over_time(flows_src_ip[1m])"; @@ -68,20 +70,23 @@ 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. +fn data_workload(sample_interval: bool) -> DataWorkload { + let ten_million = || SourceFacts { + series: Some(10_000_000), + ..SourceFacts::default() + }; 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, + sample_interval: sample_interval.then_some(DurationMs(15_000)), }, - 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(), ten_million()), + ] + .into(), } } @@ -171,8 +176,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..88c426a61 100644 --- a/crates/integration-tests/tests/planner_layering_example3.rs +++ b/crates/integration-tests/tests/planner_layering_example3.rs @@ -14,13 +14,13 @@ 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, + SharedArrival::Mixed, )) } @@ -56,7 +56,7 @@ 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, SharedArrival::Mixed); 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 +72,7 @@ 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, + SharedArrival::Mixed, )); 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..e02a79aeb 100644 --- a/crates/integration-tests/tests/planner_layering_example4.rs +++ b/crates/integration-tests/tests/planner_layering_example4.rs @@ -20,20 +20,20 @@ use asap_aware_mapping::stage3_plan_selection::{ }; use asap_types::ir::flat::NodeId; use asap_types::post_asap::SketchAlgorithm; -use asap_types::workload::{DataArrival, PlanningWorkload}; +use asap_types::workload::PlanningWorkload; 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, SharedArrival::Mixed) } fn monthly() -> PlanningWorkload { - pattern_a(PatternARecurrence::MonthlyPredictable, DataArrival::Mixed) + pattern_a(PatternARecurrence::MonthlyPredictable, SharedArrival::Mixed) } fn at_rest() -> PlanningWorkload { - pattern_a(PatternARecurrence::OnceAdHoc, DataArrival::AtRest) + pattern_a(PatternARecurrence::OnceAdHoc, SharedArrival::AtRest) } /// The window-summary build of `c` with form `want` (`Some(form)` matches 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..92ac4185b 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, SharedArrival}; use asap_integration_tests::fixtures::lower_promql; use asap_physical_operators::physical_planner::{compile, promql_rows, InputContract}; use asap_physical_operators::runtime::Scope; @@ -27,8 +27,7 @@ use asap_types::ir::{ use asap_types::post_asap::Schema; use asap_types::types::AccuracyTarget; use asap_types::workload::{ - DataArrival, DataWorkload, QueryRecurrence, QueryWorkloadEntry, RepeatedDemand, - RepetitionInterval, + DataWorkload, QueryRecurrence, QueryWorkloadEntry, RepeatedDemand, RepetitionInterval, }; const EVALUATION_MS: i64 = 300_000; @@ -63,7 +62,7 @@ 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(SharedArrival::Ingesting, "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..a22f28c94 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, DataArrival, DataWorkload, DurationMs, PlanningWorkload, Query, QueryLanguage, QueryRequirements, QueryWorkload, }; use futures::StreamExt; @@ -68,14 +68,14 @@ fn promql(query: &str) -> QueryRoot { repeating_queries: None, }, data_workload: Some(DataWorkload { - data_ingestion_interval: Evidence { - value: Some(DurationMs(1000)), - ..Default::default() + arrival: DataArrival::Ingesting { + rows_per_second: 1_000.0, + sample_interval: Some(DurationMs(1000)), }, ..Default::default() }), }; - 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..e222ab537 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 { @@ -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..2d8a81432 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(), ); @@ -318,20 +313,16 @@ async fn each_plan_carries_its_selected_root() { }]), }, 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, + sample_interval: Some(DurationMs(15_000)), }, ..Default::default() }), }; let input = UserInput::new( &workload, - FrontendInput::Promql { - now_ms: NOW_MS, - histograms: None, - }, + FrontendInput::Promql { histograms: None }, PlanningModels::builtin(), ); @@ -356,19 +347,16 @@ async fn scalar_roots_survive_planning_in_workload_order() { repeating_queries: None, }, data_workload: Some(DataWorkload { - data_ingestion_interval: Evidence { - value: Some(DurationMs(1000)), - ..Default::default() + arrival: DataArrival::Ingesting { + rows_per_second: 1_000.0, + sample_interval: Some(DurationMs(1000)), }, ..Default::default() }), }; 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..2b24efc9c 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, DataArrival, 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 { @@ -29,14 +27,14 @@ 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() + arrival: DataArrival::Ingesting { + rows_per_second: 1_000.0, + sample_interval: Some(DurationMs(15_000)), }, ..Default::default() }), }; - 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..a4c51a368 100644 --- a/crates/planner/tests/summary_sharing.rs +++ b/crates/planner/tests/summary_sharing.rs @@ -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,14 +65,9 @@ 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, + sample_interval: Some(DurationMs(15_000)), }, ..Default::default() }), @@ -85,10 +78,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 +87,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..b3e4f7e7d 100644 --- a/crates/types/src/workload.rs +++ b/crates/types/src/workload.rs @@ -349,18 +349,73 @@ 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, +/// with the facts about its arrival. This is independent of whether queries +/// repeat. +/// +/// JSON: `"at_rest"`, `{"ingesting": {"rows_per_second": 1000.0, +/// "sample_interval": 15000}}`, `{"mixed": {...}}` or `"unknown"` (the +/// default). `sample_interval` may be omitted. +#[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, + /// 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, + /// PromQL and MetricsQL only: how often each series receives a + /// sample. A bare instant selector reads the latest sample within + /// it; `None` when no query needs it. + #[serde(default)] + sample_interval: Option, + }, + /// Older rows are stored and new rows keep arriving; the fields are + /// those of [`DataArrival::Ingesting`]. + Mixed { + /// As for [`DataArrival::Ingesting`]. + rows_per_second: f64, + /// As for [`DataArrival::Ingesting`]. + #[serde(default)] + sample_interval: Option, + }, + /// Not declared: no summary is built at ingestion time. #[default] Unknown, } +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), + Self::AtRest | Self::Unknown => None, + } + } + + /// The PromQL sample interval, when the data is ingesting or mixed and + /// declares one. + pub fn sample_interval(&self) -> Option { + match self { + Self::Ingesting { + sample_interval, .. + } + | Self::Mixed { + sample_interval, .. + } => *sample_interval, + Self::AtRest | Self::Unknown => None, + } + } +} + /// Where an empirical workload value came from. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -372,40 +427,30 @@ pub enum EvidenceSource { 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)] +/// When one source's facts were observed and how long they stay valid, in +/// milliseconds. The default (neither set) never expires: a declared fact. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub struct Evidence { - pub value: Option, - pub source: EvidenceSource, +pub struct Evidence { + /// When the facts were observed (Unix ms); `None` for declared facts. + #[serde(default)] pub observed_at_ms: Option, + /// How long after `observed_at_ms` they stay valid; `None` for no + /// expiry. + #[serde(default)] 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()?; +impl Evidence { + /// Whether facts with this evidence may be used at `now_ms`: observed + /// no later than `now_ms` and not expired (expiry is inclusive). A + /// validity duration without an observation time is never usable. + pub fn is_fresh_at(&self, now_ms: u64) -> bool { 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 - } - (None, Some(_)) => None, - _ => Some(value), + (Some(observed), _) if observed > now_ms => false, + (Some(observed), Some(valid_for)) => now_ms <= observed.saturating_add(valid_for), + (None, Some(_)) => false, + _ => true, } } } @@ -430,42 +475,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. +/// +/// JSON: `{"arrival": {"ingesting": {"rows_per_second": 66666.7, +/// "sample_interval": 15000}}, "sources": {"http_requests_total": +/// {"series": 1000000, "metric_type": "counter"}}}`. #[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 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 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. + /// Plan selection splits a whole-result failure probability over the + /// values a query returns (`G`), at most the series of the sources it + /// reads, and prices scans and selectors with it (default 1,000 when + /// unknown). + #[serde(default)] + pub series: 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 metric_type: 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: 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 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: 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 max_distinct_per_evaluation: Option, + /// When `series`, `stream_norms` and `max_distinct_per_evaluation` were + /// observed and how long they hold (default: declared, never expires). + /// Plan selection binds them into accuracy guarantees only while they + /// hold at any planning time. `metric_type` is a declaration and does + /// not expire. + #[serde(default)] + pub evidence: Evidence, } /// Upper bounds on the norms of the frequency (weight) vector any one @@ -542,55 +610,48 @@ impl QueryWorkload { } impl DataWorkload { - /// Validate source facts independently of query demand or language. + /// Validate the arrival and 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); + validate_optional_rate(self.arrival.rows_per_second().map(Rate))?; + if self.arrival.sample_interval() == Some(DurationMs(0)) { + return Err(WorkloadError::ZeroSampleInterval); } - 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)); + for facts in self.sources.values() { + 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 PromQL sample interval the data workload declares, if any. + pub fn sample_interval(&self) -> Option { + self.data_workload + .as_ref() + .and_then(|data| data.arrival.sample_interval()) + } } fn validate_entry(entry: &QueryWorkloadEntry) -> Result<(), WorkloadError> { @@ -641,14 +702,18 @@ fn validate_rate(rate: Rate) -> Result { #[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 the sample interval, but the + /// data workload declares none (data at rest, unknown arrival, or + /// ingesting without `sample_interval`). + #[error( + "a PromQL or MetricsQL instant selector needs data_workload.arrival to be \ + ingesting or mixed with a sample_interval" + )] + MissingSampleInterval, + /// Bad input: a declared sample interval of zero. + #[error("sample_interval must be greater than zero")] + ZeroSampleInterval, #[error("a one-time query must have at least one invocation")] ZeroInvocations, #[error("a fixed repetition interval must be greater than zero")] @@ -663,8 +728,6 @@ pub enum WorkloadError { InvalidRate(f64), #[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, #[error("a stream norm must be finite and non-negative, got {0}")] InvalidStreamNorm(f64), #[error("a source's max_distinct_per_evaluation must be greater than zero")] @@ -707,16 +770,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 +798,42 @@ 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)); } + /// The arrival's rate is finite and non-negative, and a 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, + sample_interval: sample_interval.map(DurationMs), + }, + ..DataWorkload::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,27 +873,31 @@ mod tests { } #[test] - fn stale_evidence_is_unknown_at_planning_time() { + fn stale_evidence_is_not_fresh_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); + assert!(evidence.is_fresh_at(1_500)); + assert!(!evidence.is_fresh_at(1_501)); + // Declared facts never expire; a validity without an observation + // time is never usable. + assert!(Evidence::default().is_fresh_at(u64::MAX)); + let unanchored = Evidence { + observed_at_ms: None, + valid_for_ms: Some(1), + }; + assert!(!unanchored.is_fresh_at(0)); } #[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))); + assert!(!evidence.is_fresh_at(1_999)); + assert!(evidence.is_fresh_at(2_000)); let estimate = DemandEstimate { observation_window: ObservationWindow { @@ -823,25 +916,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 +933,88 @@ 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, + sample_interval: Some(DurationMs(15_000)), }, - 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: Some(10_000), + metric_type: Some(MetricType::Counter), + stream_norms: Some(StreamNorms { + l1: Some(1e9), + l2: None, + }), + max_distinct_per_evaluation: Some(100), + evidence: Evidence { + observed_at_ms: Some(1_000), + valid_for_ms: Some(60_000), + }, + }, + )]), }), }; - 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, "sample_interval": 15000}}, + "sources": {"requests_total": { + "series": 10000, + "metric_type": "counter", + "stream_norms": {"l1": 1e9, "l2": null}, + "max_distinct_per_evaluation": 100, + "evidence": {"observed_at_ms": 1000, "valid_for_ms": 60000} + }} + }) + ); + 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; the sample interval is optional. + let minimal: DataWorkload = serde_json::from_value(serde_json::json!({ + "arrival": {"mixed": {"rows_per_second": 5.0}}, + "sources": {"flows": {"series": 7}} + })) + .unwrap(); + assert_eq!(minimal.arrival.sample_interval(), None); + assert_eq!(minimal.sources["flows"].evidence, Evidence::default()); + } + + /// The former flat shape (per-field evidence, one global cardinality, + /// per-fact source maps, `continuously_ingesting`) is rejected. + #[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": 1, "evidence": {"source": "declared"}}}}), + serde_json::json!({"arrival": {"at_rest": {"rows_per_second": 1.0}}}), + ] { + assert!( + serde_json::from_value::(old.clone()).is_err(), + "{old}" + ); + } } #[test] diff --git a/crates/types/tests/workload_compatibility.rs b/crates/types/tests/workload_compatibility.rs index e5c31a5b4..b0ac97dd9 100644 --- a/crates/types/tests/workload_compatibility.rs +++ b/crates/types/tests/workload_compatibility.rs @@ -1,35 +1,22 @@ -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 it only for a query with a bare instant selector. #[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}}} + }); + 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(), None); } } From b8b76af729b386a73d7d0126b43a89f750603f58 Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Fri, 16 Oct 2026 01:46:19 +0000 Subject: [PATCH 02/12] docs: DataWorkload arrival and per-source facts in the library and input docs --- .../architecture/input-output-workflow.md | 60 +++++++------- ...d_interface_with_pluggable_optimization.md | 2 +- docs/develop_docs/library-api.md | 83 +++++++++++-------- docs/develop_docs/pre-asap-ir.md | 2 +- 4 files changed, 79 insertions(+), 68 deletions(-) diff --git a/docs/design_docs/architecture/input-output-workflow.md b/docs/design_docs/architecture/input-output-workflow.md index 586580186..be229c795 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 an ingesting or mixed arrival with a nonzero `sample_interval`. | +| 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; sample interval 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 an ingesting or mixed arrival with a nonzero `sample_interval`. | #### `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, sample_interval: Option }, + Mixed { rows_per_second: f64, sample_interval: Option }, + Unknown, +} +struct SourceFacts { + series: Option, + metric_type: Option, + stream_norms: Option, + max_distinct_per_evaluation: Option, + evidence: Evidence, } ``` | 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. | +| `sample_interval` | PromQL only; nonzero | How often each series is sampled. Bare instant selectors use it as their selection horizon; without it, such a query fails to lower. | +| `series` | Optional | Series in the source. Plan selection bounds a query's number of values `G`, and its input row count, by the summed series of the sources it reads; unknown, they stay unbound. | +| `metric_type` | Optional | Declared Prometheus type; a counter proves samples non-negative. Never inferred from a name. | +| `stream_norms`, `max_distinct_per_evaluation` | Optional | Bounds that Count-Min/Count Sketch and HLL/UnivMon guarantees are bound with. | +| `evidence` | Defaults to declared | When `series`, `stream_norms` and `max_distinct_per_evaluation` were observed (`observed_at_ms`) and how long they hold (`valid_for_ms`). Plan selection uses them only while they never expire; a duration without an observation timestamp is unusable. | -Unavailable or stale data evidence stays unknown. Planner does not reinterpret -it as zero ingestion, zero cardinality, or a favorable distribution. +Unavailable or stale 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..c00b5a593 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`; one with a bare instant selector needs a nonzero `sample_interval` in its `DataArrival` | 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 +that `DataWorkload.arrival` declares (`DataArrival::Ingesting` or `Mixed` +with `sample_interval: Some(DurationMs(..))`, nonzero). A query with one +fails with `WorkloadError::MissingSampleInterval` when the data is at rest, +its arrival is unknown, or no interval is declared. 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, DataArrival, DataWorkload, DurationMs, Query, PlanningWorkload, QueryLanguage, QueryRequirements, QueryWorkload, }; use asap_types::types::AccuracyTarget; @@ -108,14 +106,14 @@ fn main() -> Result<(), Box> { repeating_queries: None, }, data_workload: Some(DataWorkload { - data_ingestion_interval: Evidence { - value: Some(DurationMs(1_000)), - ..Default::default() + arrival: DataArrival::Ingesting { + rows_per_second: 1_000.0, + sample_interval: Some(DurationMs(1_000)), }, ..Default::default() }), }; - let pre_asap = lower_promql_workload(&workload, 0)?; + let pre_asap = lower_promql_workload(&workload)?; println!("{pre_asap:#?}"); Ok(()) } @@ -199,7 +197,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, DataArrival, DataWorkload, DurationMs, Query, PlanningWorkload, QueryLanguage, QueryRequirements, QueryWorkload, }; use asap_aware_mapping::{ @@ -226,14 +224,14 @@ fn main() -> Result<(), Box> { repeating_queries: None, }, data_workload: Some(DataWorkload { - data_ingestion_interval: Evidence { - value: Some(DurationMs(1_000)), - ..Default::default() + arrival: DataArrival::Ingesting { + rows_per_second: 1_000.0, + sample_interval: Some(DurationMs(1_000)), }, ..Default::default() }), }; - 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 +344,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, DataArrival, DataWorkload, DurationMs, Query, PlanningWorkload, QueryLanguage, QueryRequirements, QueryWorkload, }; use asap_aware_mapping::{ @@ -373,14 +371,14 @@ fn main() -> Result<(), Box> { repeating_queries: None, }, data_workload: Some(DataWorkload { - data_ingestion_interval: Evidence { - value: Some(DurationMs(1_000)), - ..Default::default() + arrival: DataArrival::Ingesting { + rows_per_second: 1_000.0, + sample_interval: Some(DurationMs(1_000)), }, ..Default::default() }), }; - 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 +409,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 +491,29 @@ 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` and the PromQL-only `sample_interval`, or +`Unknown`) and `sources`, a map from PromQL metric or SQL table name to +`SourceFacts`: `series`, `metric_type`, `stream_norms`, +`max_distinct_per_evaluation`, and one `evidence` (when the facts were +observed and how long they hold). Plan selection binds a query's number of +values `G` and input row count to the summed `series` of the sources it +reads, and binds measured facts only while their evidence never expires. +`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, "sample_interval": 15000}}, + "sources": {"http_requests_total": {"series": 1000000, "metric_type": "counter"}}} +``` | 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; declared evidence that never expires | Unknown facts are not zero; give observed facts their observation time and validity | | `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 +586,7 @@ planning. ```rust use asap_frontend_promql::lower_promql_workload; use asap_types::workload::{ - BatchEntry, DataWorkload, DurationMs, Evidence, Query, + BatchEntry, DataArrival, DataWorkload, DurationMs, Query, PlanningWorkload, QueryLanguage, QueryRequirements, QueryWorkload, }; use asap_aware_mapping::{search_workload, DefaultCostModel}; @@ -598,14 +609,14 @@ fn main() -> Result<(), Box> { repeating_queries: None, }, data_workload: Some(DataWorkload { - data_ingestion_interval: Evidence { - value: Some(DurationMs(1_000)), - ..Default::default() + arrival: DataArrival::Ingesting { + rows_per_second: 1_000.0, + sample_interval: Some(DurationMs(1_000)), }, ..Default::default() }), }; - 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..5dea9df1e 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 data arrival around a bare selector). - `TimeRangeKind::Range` — a range selector (`m[5m]`): every sample in the window. ```promql From 60054a4fcae69e209c4bf68c29f74c4fe64c7324 Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Fri, 16 Oct 2026 03:42:54 +0000 Subject: [PATCH 03/12] refactor(types)!: sample interval per source, no source-fact evidence; G adds or branches - SourceFacts gains sample_interval (was DataArrival's) and renames series to series_count; DataArrival is AtRest | Ingesting { rows_per_second } | Mixed { rows_per_second } | Unknown. - The PromQL and MetricsQL frontends give each bare instant selector its own metric's interval; MissingSampleInterval fires only when that metric declares none, whatever the arrival. - Evidence, is_fresh_at on source facts, EvidenceSource and DemandEstimate.source are deleted: every fact is declared. - Stage 3 bounds G and the input row count with a RowBound per node: a union (PromQL or, SQL UNION, Concat) adds its branches and a constant vector one row, while a join or fold of one source counts it once. - price() builds each node's producers once; accuracy_check computes the bound once per root. - devtools declare --interval-ms on each metric read (arrival stays unknown); sketch_coverage takes --sample-interval-ms. --- .../src/accuracy/composition.rs | 6 +- .../asap-aware-mapping/src/analytical_cost.rs | 2 - .../src/maintained_population.rs | 2 +- .../src/per_computation_candidates.rs | 4 +- .../src/query_physical_lowering.rs | 15 +- crates/asap-aware-mapping/src/recurrence.rs | 2 - .../src/stage2_physical_asap_optimization.rs | 2 - .../src/stage3_plan_selection.rs | 377 +++++++++++------- crates/asap-aware-mapping/src/test_support.rs | 15 +- .../tests/per_computation_candidates.rs | 11 +- .../src/physical_planner/candidates.rs | 11 +- .../tests/common/mod.rs | 1 - .../tests/current_series_heap.rs | 11 +- .../tests/deployment_computation.rs | 11 +- .../tests/planspace_series_identity_heap.rs | 13 +- .../tests/precompute_candidates.rs | 22 +- .../tests/weighted_topk_binding.rs | 15 +- crates/devtools/src/bin/sketch_coverage.rs | 16 +- crates/devtools/src/bin/stage_pipeline.rs | 48 ++- crates/devtools/src/lib.rs | 20 +- .../devtools/src/planner_layering_examples.rs | 24 +- crates/frontend-metricsql/src/lib.rs | 74 ++-- crates/frontend-metricsql/tests/lowering.rs | 2 +- .../tests/lowering_semantics.rs | 43 +- crates/frontend-promql/src/lib.rs | 129 ++++-- crates/frontend-promql/src/promql.rs | 74 ++-- .../frontend-promql/tests/promql_lowering.rs | 13 +- crates/frontend-promql/tests/support.rs | 15 +- crates/integration-tests/src/lib.rs | 13 +- .../tests/per_computation_candidates_sql.rs | 2 +- .../tests/planner_layering_example2.rs | 25 +- .../tests/stage1_logical_asap_optimization.rs | 13 +- crates/planner/tests/e2e_plan.rs | 15 +- crates/planner/tests/plan_stages.rs | 13 +- crates/planner/tests/summary_sharing.rs | 10 +- crates/types/src/workload.rs | 268 +++++-------- crates/types/tests/workload_compatibility.rs | 7 +- 37 files changed, 713 insertions(+), 631 deletions(-) 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/analytical_cost.rs b/crates/asap-aware-mapping/src/analytical_cost.rs index b874d6fd3..c62ab78a4 100644 --- a/crates/asap-aware-mapping/src/analytical_cost.rs +++ b/crates/asap-aware-mapping/src/analytical_cost.rs @@ -3816,7 +3816,6 @@ mod tests { 6, DataArrival::Ingesting { rows_per_second: 1_000.0, - sample_interval: None } ), Err(AnalyticalCostError::InvalidCacheEvidence(_)) @@ -4018,7 +4017,6 @@ mod tests { let mut scope = comparison_scope(); scope.data_arrival = DataArrival::Ingesting { rows_per_second: 1_000.0, - sample_interval: None, }; let mut profile = cache_profile(100, 50); let CacheProfile::Evidence(inputs) = &mut profile else { 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/query_physical_lowering.rs b/crates/asap-aware-mapping/src/query_physical_lowering.rs index 947671937..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, DataArrival, DataWorkload, PlanningWorkload, Predictability, Query, - QueryLanguage, QueryRequirements, QueryWorkload, + BatchEntry, DataWorkload, PlanningWorkload, Predictability, Query, QueryLanguage, + QueryRequirements, QueryWorkload, }; use std::cell::RefCell; @@ -2887,13 +2887,10 @@ mod tests { }]), repeating_queries: None, }, - data_workload: Some(DataWorkload { - arrival: DataArrival::Ingesting { - rows_per_second: 1_000.0, - sample_interval: Some(DurationMs(1_000)), - }, - ..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) .unwrap() diff --git a/crates/asap-aware-mapping/src/recurrence.rs b/crates/asap-aware-mapping/src/recurrence.rs index acba01fd9..0cd36cd35 100644 --- a/crates/asap-aware-mapping/src/recurrence.rs +++ b/crates/asap-aware-mapping/src/recurrence.rs @@ -678,7 +678,6 @@ mod tests { let workload = DataWorkload { arrival: asap_types::workload::DataArrival::Ingesting { rows_per_second: 100.0, - sample_interval: None, }, ..Default::default() }; @@ -691,7 +690,6 @@ mod tests { let workload = DataWorkload { arrival: asap_types::workload::DataArrival::Mixed { rows_per_second: -0.1, - sample_interval: None, }, ..Default::default() }; 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 5110a8e6c..b186e37d6 100644 --- a/crates/asap-aware-mapping/src/stage2_physical_asap_optimization.rs +++ b/crates/asap-aware-mapping/src/stage2_physical_asap_optimization.rs @@ -983,7 +983,6 @@ mod tests { DataWorkload { arrival: DataArrival::Ingesting { rows_per_second: 1_000.0, - sample_interval: None, }, ..Default::default() } @@ -1114,7 +1113,6 @@ mod tests { let mixed = DataWorkload { arrival: DataArrival::Mixed { rows_per_second: 1_000.0, - sample_interval: None, }, ..Default::default() }; diff --git a/crates/asap-aware-mapping/src/stage3_plan_selection.rs b/crates/asap-aware-mapping/src/stage3_plan_selection.rs index 08fe77ee2..84fea93ab 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, @@ -145,7 +147,7 @@ use asap_types::post_asap::{ 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, SourceFacts, + DataWorkload, MetricType, QueryRecurrence, QueryWorkloadEntry, RepeatedDemand, }; use thiserror::Error; @@ -896,30 +898,6 @@ fn describe_usage(usage: &ResourceUsage) -> String { ) } -/// Without a planning time, only evidence that cannot expire is fresh. -const ANY_TIME: u64 = u64::MAX; - -/// `source`'s facts in `data` while their evidence holds at any planning -/// time (no validity window): the facts accuracy guarantees may bind. -fn fresh_source_facts<'a>(data: &'a DataWorkload, source: &str) -> Option<&'a SourceFacts> { - data.sources - .get(source) - .filter(|facts| facts.evidence.is_fresh_at(ANY_TIME)) -} - -/// The sources (PromQL metric or SQL table names) the scans under `nodes` -/// read. -fn sources_read<'a>(nodes: impl IntoIterator>) -> BTreeSet { - nodes - .into_iter() - .flat_map(OperatorNode::reachable) - .filter_map(|node| match node.non_asap() { - Some(NonASAPOp::Scan { source, .. }) => Some(source_name(source).to_string()), - _ => None, - }) - .collect() -} - /// A scan source's name: its PromQL metric or SQL table. fn source_name(source: &Source) -> &str { match source { @@ -928,34 +906,131 @@ fn source_name(source: &Source) -> &str { } } -/// The series the sources `root` reads hold together, from fresh source -/// facts: an upper bound on the series (and rows per evaluation) any of -/// its folds sees. `None` when it reads no source or any source's series -/// is unknown or not fresh. +/// An upper bound on the rows one node returns per evaluation, in terms of +/// the sources it reads: `copies_per_source[s]` times the series of `s`, +/// plus `constant_rows`. A source with zero copies is read (under a scalar) +/// but adds no 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) + }, + Some(NonASAPOp::PromqlVectorFromScalar(_)) => { + let mut bound = RowBound::largest(&inputs); + bound.copies_per_source.values_mut().for_each(|c| *c = 0); + bound.constant_rows = 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 rows of declares no series count. fn series_read(root: &QueryRoot, data: &DataWorkload) -> Option { - let sources = sources_read(root_operators(root)); - if sources.is_empty() { + 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; } - sources - .iter() - .map(|source| fresh_source_facts(data, source)?.series) - .sum() + let mut rows = bound.constant_rows; + for (source, copies) in &bound.copies_per_source { + if *copies > 0 { + 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 series of -/// the sources it reads ([`series_read`]) 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 => { - series_read(root, data).map(|series| series.max(1)) + series_read.map(|series| series.max(1)) } QueryRoot::Operator(_) => None, } @@ -1089,7 +1164,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); @@ -1110,7 +1185,7 @@ pub fn resize_for_failure_probability( relative_error, } => { let source = distinct_bound_source(state)?; - let bound = fresh_source_facts(data, &source)?.max_distinct_per_evaluation?; + 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 { @@ -1188,12 +1263,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 @@ -1219,8 +1295,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" ) @@ -1319,8 +1396,7 @@ fn root_operators(root: &QueryRoot) -> Vec<&Rc> { } /// [`root_guarantee`] with the statistics Stage 1 left symbolic bound: -/// - the input row count, from the series of the sources `root` reads -/// ([`series_read`]); +/// - 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 @@ -1329,18 +1405,16 @@ 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 series = series_read(root, data); let domains = operand_domain_bounds(root, series, data, models); Some(bind_statistics(&guarantee, &|statistic| { workload_statistic(statistic, series, data) @@ -1383,7 +1457,7 @@ fn workload_statistic(statistic: &str, series: Option, data: &DataWorkload) if let Some((precision, relative_error, source)) = hll::parse_classic_failure_statistic(statistic) { - let bound = fresh_source_facts(data, source)?.max_distinct_per_evaluation?; + let bound = data.sources.get(source)?.max_distinct_per_evaluation?; return Some(hll::classic_failure_probability( precision, relative_error, @@ -1393,13 +1467,13 @@ fn workload_statistic(statistic: &str, series: Option, data: &DataWorkload) if let Some((heap_size, layers, source)) = univmon::parse_incomplete_top_layer_statistic(statistic) { - let bound = fresh_source_facts(data, source)?.max_distinct_per_evaluation?; + let bound = data.sources.get(source)?.max_distinct_per_evaluation?; return Some(univmon::incomplete_top_layer_probability( heap_size, layers, bound, )); } let (norm, source) = parse_source_norm_statistic(statistic)?; - let norms = fresh_source_facts(data, source)?.stream_norms?; + let norms = data.sources.get(source)?.stream_norms?; match norm { STREAM_L1_NORM => norms.l1, _ => norms.l2, @@ -1617,9 +1691,8 @@ fn family_name(family: &FieldDataType) -> String { } /// The series pricing assumes a node reading `sources` sees: the sum of -/// their declared series, [`DEFAULT_SERIES`] for each source without one -/// and for a node that reads none. Pricing reads a source's series whatever -/// its evidence's freshness. +/// 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; @@ -1629,7 +1702,7 @@ fn priced_series(sources: &BTreeSet<&str>, data: &DataWorkload) -> u64 { .map(|source| { data.sources .get(*source) - .and_then(|facts| facts.series) + .and_then(|facts| facts.series_count) .unwrap_or(DEFAULT_SERIES) }) .sum::() @@ -1838,21 +1911,20 @@ 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(), - _ => dag - .edges + _ => producers .iter() - .filter(|e| e.consumer == node.id) - .flat_map(|e| sources_read_by[&e.producer].iter().copied()) + .flat_map(|p| sources_read_by[p].iter().copied()) .collect(), }; let series = priced_series(&sources, data); @@ -2092,12 +2164,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) } @@ -2639,7 +2710,21 @@ mod tests { }; use asap_types::pre_asap::expr_ir::ArithmeticOpKind; use asap_types::types::AccuracyTarget; - use asap_types::workload::{DataArrival, Evidence, MetricType}; + 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 { @@ -2667,13 +2752,12 @@ mod tests { DataWorkload { arrival: DataArrival::Ingesting { rows_per_second: 10_000.0, - sample_interval: None, }, sources: TEST_SOURCES .iter() .map(|source| { let facts = SourceFacts { - series, + series_count: series, ..SourceFacts::default() }; (source.to_string(), facts) @@ -2940,7 +3024,7 @@ mod tests { let root = QueryRoot::Operator(Rc::new((*node).clone().with_guarantee(Some(gated)))); 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 @@ -3319,17 +3403,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 = with_series(None); + 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") @@ -3349,12 +3433,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() ), @@ -3366,23 +3447,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_per_result(&root("sum by (job) (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]))"), &data()), + values_of(&root("sum by (job) (rate(x[5m]))"), &data()), Some(10_000) ); let table = || { @@ -3402,13 +3477,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))), @@ -3416,16 +3488,13 @@ mod tests { right: global, })) .unwrap(); - assert_eq!( - values_per_result(&QueryRoot::Operator(cross), &none), - Some(1) - ); + 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 unknown or expired source - /// leaves it unbound. Pricing reads the same per-source series, with + /// 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() { @@ -3436,7 +3505,7 @@ mod tests { .iter() .map(|(source, series)| { let facts = SourceFacts { - series: Some(*series), + series_count: Some(*series), ..SourceFacts::default() }; (source.to_string(), facts) @@ -3446,20 +3515,13 @@ mod tests { }; 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_per_result(&x, &series(&[("x", 10)])), Some(10)); - assert_eq!(values_per_result(&x, &series(&[("m", 10)])), None); + 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_per_result(&x, &declared), Some(10)); - assert_eq!(values_per_result(&both, &declared), Some(30)); + 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_per_result(&both, &series(&[("x", 10)])), None); - let mut expired = declared.clone(); - expired.sources.get_mut("m").unwrap().evidence = Evidence { - observed_at_ms: Some(0), - valid_for_ms: Some(1), - }; - assert_eq!(values_per_result(&x, &expired), Some(10)); - assert_eq!(values_per_result(&both, &expired), None); + 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); @@ -3470,6 +3532,29 @@ mod tests { 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}"); + } + } + /// Deployment evidence that every output was measured exact. struct EveryOutputMeasuredExact; @@ -4166,8 +4251,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) @@ -4301,13 +4387,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()); } @@ -4337,7 +4422,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:?}" @@ -4351,7 +4436,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:?}"); } } @@ -4374,13 +4459,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( @@ -4388,7 +4473,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:?}"); } @@ -4421,21 +4506,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 mut expiring = data(); - for facts in expiring.sources.values_mut() { - facts.evidence = Evidence { - observed_at_ms: Some(1_000), - valid_for_ms: Some(1_000), - }; - } - 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)) @@ -4876,7 +4956,6 @@ mod tests { DataWorkload { arrival: DataArrival::Ingesting { rows_per_second: rows_per_s, - sample_interval: None, }, ..data() } diff --git a/crates/asap-aware-mapping/src/test_support.rs b/crates/asap-aware-mapping/src/test_support.rs index 215af769e..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, DataArrival, DataWorkload, DurationMs, 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,13 +26,10 @@ pub(crate) fn lower_promql(query: &str, accuracy: AccuracyTarget) -> Rc Vec { }]), repeating_queries: None, }, - data_workload: Some(asap_types::workload::DataWorkload { - arrival: asap_types::workload::DataArrival::Ingesting { - rows_per_second: 1_000.0, - sample_interval: Some(asap_types::workload::DurationMs(1000)), - }, - ..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).unwrap() } diff --git a/crates/asap-physical-operators/src/physical_planner/candidates.rs b/crates/asap-physical-operators/src/physical_planner/candidates.rs index ff9407b36..b20c31c4b 100644 --- a/crates/asap-physical-operators/src/physical_planner/candidates.rs +++ b/crates/asap-physical-operators/src/physical_planner/candidates.rs @@ -359,13 +359,10 @@ mod tests { }]), repeating_queries: None, }, - data_workload: Some(DataWorkload { - arrival: DataArrival::Ingesting { - rows_per_second: 1_000.0, - sample_interval: Some(DurationMs(1000)), - }, - ..Default::default() - }), + data_workload: Some(DataWorkload::sampled_every( + ["m".to_string()], + DurationMs(1000), + )), }; let root = asap_frontend_promql::lower_promql_workload(&workload) .unwrap() diff --git a/crates/asap-physical-operators/tests/common/mod.rs b/crates/asap-physical-operators/tests/common/mod.rs index a837d1ae7..b1e894942 100644 --- a/crates/asap-physical-operators/tests/common/mod.rs +++ b/crates/asap-physical-operators/tests/common/mod.rs @@ -62,7 +62,6 @@ fn run_stage1_to_stage3( let data = DataWorkload { arrival: DataArrival::Ingesting { rows_per_second: 1_000.0, - sample_interval: None, }, ..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 d11c07c16..8a4767f89 100644 --- a/crates/asap-physical-operators/tests/current_series_heap.rs +++ b/crates/asap-physical-operators/tests/current_series_heap.rs @@ -314,13 +314,10 @@ fn planner_current_series_candidate_compiles_with_dynamic_identity() { }]), repeating_queries: None, }, - data_workload: Some(DataWorkload { - arrival: DataArrival::Ingesting { - rows_per_second: 1_000.0, - sample_interval: Some(DurationMs(60_000)), - }, - ..Default::default() - }), + data_workload: Some(DataWorkload::sampled_every( + ["m".to_string()], + DurationMs(60_000), + )), }; let original = asap_frontend_promql::lower_promql_workload(&workload) .unwrap() diff --git a/crates/asap-physical-operators/tests/deployment_computation.rs b/crates/asap-physical-operators/tests/deployment_computation.rs index 56a1a5cc5..d8f0686f3 100644 --- a/crates/asap-physical-operators/tests/deployment_computation.rs +++ b/crates/asap-physical-operators/tests/deployment_computation.rs @@ -34,13 +34,10 @@ fn lower_with(query: &str, accuracy: AccuracyTarget) -> Rc Rc { }]), repeating_queries: None, }, - data_workload: Some(DataWorkload { - arrival: DataArrival::Ingesting { - rows_per_second: 1_000.0, - sample_interval: Some(DurationMs(1_000)), - }, - ..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) .unwrap() diff --git a/crates/asap-physical-operators/tests/precompute_candidates.rs b/crates/asap-physical-operators/tests/precompute_candidates.rs index d0e781058..4d1805e2e 100644 --- a/crates/asap-physical-operators/tests/precompute_candidates.rs +++ b/crates/asap-physical-operators/tests/precompute_candidates.rs @@ -41,13 +41,10 @@ fn exact_promql_root(query: &str) -> std::rc::Rc [--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. @@ -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(); diff --git a/crates/devtools/src/bin/stage_pipeline.rs b/crates/devtools/src/bin/stage_pipeline.rs index 370def0cc..3a9ebee72 100644 --- a/crates/devtools/src/bin/stage_pipeline.rs +++ b/crates/devtools/src/bin/stage_pipeline.rs @@ -68,15 +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 PromQL sample interval (default 15000); the data -// is taken as ingesting at 1,000 rows per second, with no source facts. +// `--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, @@ -91,13 +91,14 @@ use asap_devtools::planning_stages::{ 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}; use asap_types::types::{default_metric_for_root, AccuracyMetric, AccuracyTarget}; use asap_types::workload::{ - BatchEntry, DataArrival, DataWorkload, DurationMs, PlanningWorkload, Query, QueryLanguage, - QueryRecurrence, QueryRequirements, QueryWorkload, RepeatedDemand, SqlDialect, + BatchEntry, DataWorkload, DurationMs, PlanningWorkload, Query, QueryLanguage, QueryRecurrence, + QueryRequirements, QueryWorkload, RepeatedDemand, SqlDialect, }; use serde_json::{json, Value}; @@ -330,6 +331,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 + // `--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), @@ -353,11 +357,16 @@ fn run(args: Vec) -> Result<(), Failure> { _ => 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 = adhoc_data_workload(interval_ms); + let data = DataWorkload::sampled_every(metrics, DurationMs(interval_ms)); let result = run_planning_stages_over( roots.clone(), &entries, @@ -656,6 +665,15 @@ fn query_batch( accuracy: AccuracyTarget, interval_ms: u64, ) -> PlanningWorkload { + // Each metric a PromQL query reads is sampled every `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, @@ -676,20 +694,10 @@ fn query_batch( ), repeating_queries: None, }, - data_workload: Some(adhoc_data_workload(interval_ms)), - } -} - -/// The data `--promql`/`--sql` queries read: ingesting, each series -/// sampled every `interval_ms` (`--interval-ms`), at Stage 3's default rate -/// of 1,000 rows per second, with no source facts. -fn adhoc_data_workload(interval_ms: u64) -> DataWorkload { - DataWorkload { - arrival: DataArrival::Ingesting { - rows_per_second: 1_000.0, - sample_interval: Some(DurationMs(interval_ms)), - }, - ..Default::default() + data_workload: Some(DataWorkload::sampled_every( + metrics, + DurationMs(interval_ms), + )), } } diff --git a/crates/devtools/src/lib.rs b/crates/devtools/src/lib.rs index 1ecaf20af..60623a7af 100644 --- a/crates/devtools/src/lib.rs +++ b/crates/devtools/src/lib.rs @@ -26,16 +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 over ingesting data whose -/// series are sampled every `interval_ms`. Tools intentionally require the -/// sample interval rather than choosing a default. +/// 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, DataArrival, DataWorkload, DurationMs, PlanningWorkload, Predictability, Query, + BatchEntry, DataWorkload, DurationMs, PlanningWorkload, Predictability, Query, QueryRequirements, QueryWorkload, }; @@ -54,13 +55,10 @@ pub fn lower_promql_with_sample_interval( }]), repeating_queries: None, }, - data_workload: Some(DataWorkload { - arrival: DataArrival::Ingesting { - rows_per_second: 1_000.0, - sample_interval: Some(DurationMs(interval_ms)), - }, - ..Default::default() - }), + data_workload: Some(DataWorkload::sampled_every( + asap_frontend_promql::promql_metric_names(query)?, + DurationMs(interval_ms), + )), }; // 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 acf097397..4f31ae0aa 100644 --- a/crates/devtools/src/planner_layering_examples.rs +++ b/crates/devtools/src/planner_layering_examples.rs @@ -29,23 +29,18 @@ pub enum SharedArrival { /// The #509 shared data: one million series of `source`, each sampled /// every 15 s, so 1,000,000 / 15 rows per second arrive while ingesting. pub fn shared_data_workload(arrival: SharedArrival, source: &str) -> DataWorkload { - let (rows_per_second, sample_interval) = (1_000_000.0 / 15.0, Some(DurationMs(15_000))); + let rows_per_second = 1_000_000.0 / 15.0; DataWorkload { arrival: match arrival { SharedArrival::AtRest => DataArrival::AtRest, - SharedArrival::Ingesting => DataArrival::Ingesting { - rows_per_second, - sample_interval, - }, - SharedArrival::Mixed => DataArrival::Mixed { - rows_per_second, - sample_interval, - }, + SharedArrival::Ingesting => DataArrival::Ingesting { rows_per_second }, + SharedArrival::Mixed => DataArrival::Mixed { rows_per_second }, }, sources: [( source.to_string(), SourceFacts { - series: Some(1_000_000), + series_count: Some(1_000_000), + sample_interval: Some(DurationMs(15_000)), ..SourceFacts::default() }, )] @@ -146,16 +141,16 @@ pub fn example2() -> (PlanningWorkload, asap_frontend_sql::SqlCatalog) { .collect(), ), }, - // SQL needs no sample interval; `flows` has 10,000,000 sources. + // SQL needs no sample interval; `flows` holds 10,000,000 groups + // (one per source IP). data_workload: Some(DataWorkload { arrival: DataArrival::Ingesting { rows_per_second: 1_000_000.0 / 15.0, - sample_interval: None, }, sources: [( "flows".to_string(), SourceFacts { - series: Some(10_000_000), + series_count: Some(10_000_000), ..SourceFacts::default() }, )] @@ -302,8 +297,7 @@ pub fn pattern_b() -> PlanningWorkload { mod tests { use super::*; - /// Every shared arrival validates, at rest included: Pattern A reads - /// only explicit ranges, so it needs no sample interval. + /// Every shared arrival validates, at rest included. #[test] fn shared_data_workloads_are_valid() { for arrival in [ diff --git a/crates/frontend-metricsql/src/lib.rs b/crates/frontend-metricsql/src/lib.rs index 9beff39bc..e983f135d 100644 --- a/crates/frontend-metricsql/src/lib.rs +++ b/crates/frontend-metricsql/src/lib.rs @@ -43,8 +43,9 @@ 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 data workload's `sample_interval`, and fails with -/// [`WorkloadError::MissingSampleInterval`] when none is declared. +/// 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, ) -> Result, MetricsqlError> { @@ -55,50 +56,57 @@ pub fn lower_metricsql_query_workload( ))); } workload.validate()?; - let sample_interval = workload - .sample_interval() - .map(|DurationMs(ms)| Duration::from_millis(ms)); + 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_with_sample_interval( + lower_with_sample_intervals( &entry.query.0, entry.requirements.accuracy.clone(), - sample_interval, + &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_interval(query, accuracy, Some(ingestion_interval)) + lower_with_sample_intervals(query, accuracy, &|_| Some(sample_interval)) } -/// [`lower_metricsql_query`] with the sample interval the data workload -/// declares, `None` when it declares none: a bare instant selector then -/// fails with [`WorkloadError::MissingSampleInterval`]. -fn lower_with_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: Option, + sample_interval_of: &dyn Fn(&str) -> Option, ) -> Result { let ast = parse_metricsql(query)?; if let Expr::NumberLiteral(number) = &ast { @@ -108,7 +116,7 @@ fn lower_with_sample_interval( } let unresolved = Lowerer { accuracy, - sample_interval, + sample_interval_of, } .lower(&ast)?; resolve_root(&unresolved) @@ -116,23 +124,29 @@ fn lower_with_sample_interval( .map_err(|e| MetricsqlError::Resolve(e.to_string())) } -struct Lowerer { +struct Lowerer<'a> { accuracy: AccuracyTarget, - /// What a bare instant selector looks back; `None` when undeclared. - sample_interval: Option, + /// 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 - .sample_interval - .ok_or(WorkloadError::MissingSampleInterval)?, - 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 ccd22a36d..1ae78e857 100644 --- a/crates/frontend-metricsql/tests/lowering_semantics.rs +++ b/crates/frontend-metricsql/tests/lowering_semantics.rs @@ -7,7 +7,7 @@ use asap_types::pre_asap::AggIntent; use asap_types::types::AccuracyTarget; use asap_types::workload::{ BatchEntry, DataArrival, DataWorkload, DurationMs, PlanningWorkload, Query, QueryLanguage, - QueryRequirements, QueryWorkload, WorkloadError, + 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 { - arrival: DataArrival::Ingesting { - rows_per_second: 1_000.0, - sample_interval: Some(DurationMs(15_000)), - }, - ..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,8 +106,9 @@ fn instant_selectors_look_back_one_ingestion_interval() { ); } -/// The workload entry takes the interval from the workload and checks its -/// language, 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_sample_interval() { let roots = @@ -122,12 +127,18 @@ fn workload_lowering_uses_the_declared_sample_interval() { )); let mut missing = workload(QueryLanguage::MetricsQL, "sum(up)"); missing.data_workload = Some(DataWorkload::default()); - assert!(matches!( - lower_metricsql_query_workload(&missing), - Err(MetricsqlError::InvalidWorkload( - WorkloadError::MissingSampleInterval - )) - )); + 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 { diff --git a/crates/frontend-promql/src/lib.rs b/crates/frontend-promql/src/lib.rs index 66886fa62..ca477a730 100644 --- a/crates/frontend-promql/src/lib.rs +++ b/crates/frontend-promql/src/lib.rs @@ -12,6 +12,7 @@ pub mod error; pub mod histogram; pub mod promql; +use std::collections::BTreeSet; use std::rc::Rc; use asap_types::ir::OperatorNode; @@ -22,12 +23,12 @@ pub use histogram::{HistogramCatalog, HistogramKind}; /// Lower every normalized PromQL workload entry to a plan-ready operator DAG. /// -/// A bare instant selector reads the latest sample within the data -/// workload's `sample_interval` (declared by an `Ingesting` or `Mixed` -/// [`DataArrival`](asap_types::workload::DataArrival)); a query with one -/// fails with `WorkloadError::MissingSampleInterval` when none is declared. -/// Explicit range selectors keep their query-specified range and need no -/// interval. +/// 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, ) -> Result>, PromqlError> { @@ -44,6 +45,13 @@ pub fn lower_promql_workload_with_histograms( 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. A selector with no +/// metric name reads the source named `""`, as its scan does. +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, @@ -83,17 +91,19 @@ fn lower_promql_query_workload_inner( ))); } workload.validate()?; - let sample_interval = workload - .sample_interval() - .map(|DurationMs(ms)| std::time::Duration::from_millis(ms)); + 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_sample_interval( + let root = promql::PromqlLowerer::lower_query_with_sample_intervals( &entry.query.0, &entry.requirements.accuracy.clone(), - sample_interval, + &sample_interval_of, )?; Ok(root) }) @@ -104,15 +114,22 @@ fn lower_promql_query_workload_inner( mod tests { 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, DataArrival, DataWorkload, PlanningWorkload, Query, QueryRequirements, - QueryWorkload, WorkloadError, + 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, @@ -126,17 +143,39 @@ mod tests { repeating_queries: None, }, data_workload: Some(DataWorkload { - arrival: DataArrival::Ingesting { - rows_per_second: 1_000.0, - sample_interval: Some(DurationMs(1_000)), - }, - ..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 - // sample 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_sample_interval() { let query = lower_promql_workload(&workload("sum by (job) (data)")).unwrap(); @@ -151,6 +190,22 @@ 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() { @@ -166,31 +221,36 @@ mod tests { ); } - // Data at rest, an unknown arrival, no data workload, or an ingesting - // arrival without `sample_interval` declare no interval: an instant - // selector (bare, under an aggregation, or inside a subquery) fails, - // with and without histograms, while range-only queries still lower. + // 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 instant_selector_needs_a_sample_interval() { + fn instant_selector_needs_its_metrics_sample_interval() { let arrivals = [ Some(DataArrival::AtRest), Some(DataArrival::Unknown), Some(DataArrival::Ingesting { rows_per_second: 1.0, - sample_interval: None, }), None, ]; for arrival in arrivals { let with = |query: &str| { let mut input = workload(query); - input.data_workload = arrival.map(|arrival| DataWorkload { - arrival, - ..Default::default() - }); + match arrival { + Some(arrival) => input.data_workload.as_mut().unwrap().arrival = arrival, + None => input.data_workload = None, + } input }; - for query in ["data", "sum(data)", "max_over_time(data[5m:1m])"] { + for query in [ + "undeclared", + "sum(undeclared)", + "max_over_time(undeclared[5m:1m])", + "sum(data) + sum(undeclared)", + ] { assert!( matches!( lower_promql_workload(&with(query)), @@ -210,7 +270,12 @@ mod tests { )) )); } - lower_promql_workload(&with("quantile_over_time(0.99, data[5y] offset 1y)")).unwrap(); + 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 3508754a1..5e5f6270b 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}; @@ -182,20 +183,25 @@ struct Inner { const MAX_DEPTH: usize = 256; impl PromqlLowerer { - /// Lower `query`; a bare instant selector reads the latest sample within - /// `sample_interval`, and fails with + /// 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_interval( + pub(crate) fn lower_query_with_sample_intervals( query: &str, accuracy: &AccuracyTarget, - sample_interval: Option, + sample_interval_of: &dyn Fn(&str) -> Option, ) -> Result { let _guard = AccuracyGuard::install(accuracy.clone()); - let _interval = SampleIntervalGuard::install(sample_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) @@ -217,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 SAMPLE_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 @@ -247,26 +263,27 @@ fn current_accuracy() -> AccuracyTarget { ACCURACY.with(|a| a.borrow().clone()) } -/// RAII guard installing the workload's sample interval (`None` when it -/// declares none) for the current thread's lowering. -struct SampleIntervalGuard(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 SampleIntervalGuard { - fn install(sample_interval: Option) -> Self { - Self(SAMPLE_INTERVAL.with(|current| current.replace(sample_interval))) + fn install(sample_intervals: BTreeMap) -> Self { + Self(SAMPLE_INTERVALS.with(|current| current.replace(sample_intervals))) } } impl Drop for SampleIntervalGuard { fn drop(&mut self) { - SAMPLE_INTERVAL.with(|current| *current.borrow_mut() = self.0.take()); + SAMPLE_INTERVALS.with(|current| *current.borrow_mut() = std::mem::take(&mut self.0)); } } -/// The installed sample interval, which a bare instant selector needs. -fn current_sample_interval() -> Result { - SAMPLE_INTERVAL - .with(|current| *current.borrow()) +/// `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, )) @@ -1370,18 +1387,15 @@ fn histogram_arg_is_sketchable(arg: &Expr) -> Result { /// name-less selectors like `{le="…"}`. fn collect_metric_names(expr: &Expr, out: &mut Vec) { match expr { + // A selector without a metric name scans the source named "". 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), @@ -1795,14 +1809,13 @@ fn windowed_aggregate( keys: Vec, intent: AggIntent, ) -> Result { - let base = filtered_source(inner.metric, inner.matchers, inner.shift); 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 => sample_lookback(base)?, + None => instant_source(inner.metric, inner.matchers, inner.shift)?, }; let reduction = reduction_for(&keys, inner.window.is_some() || intent.is_per_series()); Ok(Unresolved::Aggregate { @@ -1874,17 +1887,14 @@ fn filtered_source(metric: String, matchers: Vec, shift: TimeShift) -> U } } -/// An instant selector: the latest sample per series within the workload's +/// 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 { - sample_lookback(filtered_source(metric, matchers, shift)) -} - -fn sample_lookback(child: Unresolved) -> Result { + let interval = current_sample_interval(&metric)?; Ok(Unresolved::TimeRange { - range: current_sample_interval()?, + range: interval, kind: TimeRangeKind::Instant, - child: Rc::new(child), + child: Rc::new(filtered_source(metric, matchers, shift)), }) } diff --git a/crates/frontend-promql/tests/promql_lowering.rs b/crates/frontend-promql/tests/promql_lowering.rs index be956bee6..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, DataArrival, DataWorkload, DurationMs, PlanningWorkload, Predictability, Query, - QueryLanguage, QueryRequirements, QueryWorkload, + BatchEntry, PlanningWorkload, Predictability, Query, QueryLanguage, QueryRequirements, + QueryWorkload, }; use asap_frontend_promql::{lower_promql_workload, PromqlError as LoweringError}; @@ -1188,13 +1188,8 @@ fn batch_lowers_each_entry_and_reads_per_query_accuracy() { ]), repeating_queries: None, }, - data_workload: Some(DataWorkload { - arrival: DataArrival::Ingesting { - rows_per_second: 1_000.0, - sample_interval: Some(DurationMs(1_000)), - }, - ..Default::default() - }), + // Range selectors only: no sample interval is needed. + data_workload: None, }; let results = lower_promql_workload(&workload).expect("valid workload"); assert_eq!(results.len(), 2); diff --git a/crates/frontend-promql/tests/support.rs b/crates/frontend-promql/tests/support.rs index 543ad2470..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, DataArrival, DataWorkload, DurationMs, 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 { - arrival: DataArrival::Ingesting { - rows_per_second: 1_000.0, - sample_interval: Some(DurationMs(1_000)), - }, - ..Default::default() - }), + data_workload: Some(DataWorkload::sampled_every( + asap_frontend_promql::promql_metric_names(query).unwrap_or_default(), + DurationMs(1_000), + )), } } diff --git a/crates/integration-tests/src/lib.rs b/crates/integration-tests/src/lib.rs index 551b687cc..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, DataArrival, DataWorkload, DurationMs, PlanningWorkload, Predictability, Query, + BatchEntry, DataWorkload, DurationMs, PlanningWorkload, Predictability, Query, QueryLanguage, QueryRequirements, QueryWorkload, }; use std::rc::Rc; @@ -55,13 +55,10 @@ pub mod fixtures { }]), repeating_queries: None, }, - data_workload: Some(DataWorkload { - arrival: DataArrival::Ingesting { - rows_per_second: 1_000.0, - sample_interval: Some(DurationMs(1_000)), - }, - ..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)?; 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_example2.rs b/crates/integration-tests/tests/planner_layering_example2.rs index 8b5bb4b4f..7077122be 100644 --- a/crates/integration-tests/tests/planner_layering_example2.rs +++ b/crates/integration-tests/tests/planner_layering_example2.rs @@ -45,9 +45,9 @@ 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)"; -/// PromQL stand-in for Q1–Q3 (see the module docs). /// 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])"; const PROMQL_Q3: &str = "l2_over_time(flows_src_ip[1m])"; @@ -71,20 +71,24 @@ fn panel(query: &str, (epsilon, delta): (f64, f64), scope: AccuracyScope) -> Rep } /// 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. -fn data_workload(sample_interval: bool) -> DataWorkload { - let ten_million = || SourceFacts { - series: Some(10_000_000), +/// 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::Ingesting { rows_per_second: 1_000_000.0 / 15.0, - sample_interval: sample_interval.then_some(DurationMs(15_000)), }, sources: [ - ("flows".to_string(), ten_million()), - (STANDIN_METRIC.to_string(), ten_million()), + ("flows".to_string(), ten_million), + (STANDIN_METRIC.to_string(), sampled), ] .into(), } @@ -92,7 +96,7 @@ fn data_workload(sample_interval: bool) -> DataWorkload { /// 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 { @@ -114,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()), } } diff --git a/crates/integration-tests/tests/stage1_logical_asap_optimization.rs b/crates/integration-tests/tests/stage1_logical_asap_optimization.rs index a22f28c94..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, DataArrival, DataWorkload, DurationMs, PlanningWorkload, Query, QueryLanguage, + BatchEntry, DataWorkload, DurationMs, PlanningWorkload, Query, QueryLanguage, QueryRequirements, QueryWorkload, }; use futures::StreamExt; @@ -67,13 +67,10 @@ fn promql(query: &str) -> QueryRoot { }]), repeating_queries: None, }, - data_workload: Some(DataWorkload { - arrival: DataArrival::Ingesting { - rows_per_second: 1_000.0, - sample_interval: Some(DurationMs(1000)), - }, - ..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) .unwrap_or_else(|e| panic!("lower failed for {query:?}: {e}")) diff --git a/crates/planner/tests/e2e_plan.rs b/crates/planner/tests/e2e_plan.rs index 2d8a81432..7dff4f324 100644 --- a/crates/planner/tests/e2e_plan.rs +++ b/crates/planner/tests/e2e_plan.rs @@ -312,10 +312,10 @@ 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::Ingesting { rows_per_second: 1_000.0, - sample_interval: Some(DurationMs(15_000)), }, ..Default::default() }), @@ -346,13 +346,12 @@ 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 { - arrival: DataArrival::Ingesting { - rows_per_second: 1_000.0, - sample_interval: Some(DurationMs(1000)), - }, - ..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, diff --git a/crates/planner/tests/plan_stages.rs b/crates/planner/tests/plan_stages.rs index 2b24efc9c..3eaa119bc 100644 --- a/crates/planner/tests/plan_stages.rs +++ b/crates/planner/tests/plan_stages.rs @@ -5,7 +5,7 @@ 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, DataArrival, DataWorkload, DurationMs, PlanningWorkload, Query, QueryLanguage, + BatchEntry, DataWorkload, DurationMs, PlanningWorkload, Query, QueryLanguage, QueryRequirements, QueryWorkload, }; @@ -26,13 +26,10 @@ fn promql_stages(query: &str, epsilon: f64) -> Stage1ToStage3Run { }]), repeating_queries: None, }, - data_workload: Some(DataWorkload { - arrival: DataArrival::Ingesting { - rows_per_second: 1_000.0, - sample_interval: Some(DurationMs(15_000)), - }, - ..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).expect("lowers"); run_stage1_to_stage3( diff --git a/crates/planner/tests/summary_sharing.rs b/crates/planner/tests/summary_sharing.rs index a4c51a368..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}; @@ -67,9 +67,13 @@ fn promql_workload(queries: &[(&str, f64)]) -> PlanningWorkload { data_workload: Some(DataWorkload { arrival: DataArrival::Ingesting { rows_per_second: 1.0, - sample_interval: Some(DurationMs(15_000)), }, - ..Default::default() + ..DataWorkload::sampled_every( + queries + .iter() + .flat_map(|(query, _)| promql_metric_names(query).unwrap_or_default()), + DurationMs(15_000), + ) }), } } diff --git a/crates/types/src/workload.rs b/crates/types/src/workload.rs index b3e4f7e7d..4d4ae6773 100644 --- a/crates/types/src/workload.rs +++ b/crates/types/src/workload.rs @@ -144,8 +144,7 @@ pub struct ObservationWindow { /// /// ``` /// use asap_types::workload::{ -/// Confidence, DemandEstimate, DurationMs, EvidenceSource, ObservationWindow, -/// Rate, TimestampMs, +/// Confidence, DemandEstimate, DurationMs, ObservationWindow, Rate, TimestampMs, /// }; /// /// let estimate = DemandEstimate { @@ -157,7 +156,6 @@ pub struct ObservationWindow { /// 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)), /// }; @@ -170,7 +168,6 @@ pub struct DemandEstimate { pub peak_rate: Option, pub max_concurrency: Option, pub confidence: Confidence, - pub source: EvidenceSource, pub observed_at: Option, pub valid_for: Option, } @@ -349,13 +346,11 @@ impl From<&RepeatingEntry> for QueryWorkloadEntry { // ── Data workload ───────────────────────────────────────────────────────────── -/// How the queried data arrives: already stored, still arriving, or both, -/// with the facts about its arrival. This is independent of whether queries -/// repeat. +/// 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, -/// "sample_interval": 15000}}`, `{"mixed": {...}}` or `"unknown"` (the -/// default). `sample_interval` may be omitted. +/// 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 { @@ -367,20 +362,12 @@ pub enum DataArrival { /// Rows (PromQL samples) arriving per second over all sources. /// Plan selection prices scans and ingestion-time work with it. rows_per_second: f64, - /// PromQL and MetricsQL only: how often each series receives a - /// sample. A bare instant selector reads the latest sample within - /// it; `None` when no query needs it. - #[serde(default)] - sample_interval: Option, }, - /// Older rows are stored and new rows keep arriving; the fields are - /// those of [`DataArrival::Ingesting`]. + /// Older rows are stored and new rows keep arriving. Mixed { - /// As for [`DataArrival::Ingesting`]. + /// Rows (PromQL samples) arriving per second over all sources, as + /// for [`DataArrival::Ingesting`]. rows_per_second: f64, - /// As for [`DataArrival::Ingesting`]. - #[serde(default)] - sample_interval: Option, }, /// Not declared: no summary is built at ingestion time. #[default] @@ -391,70 +378,14 @@ 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::Ingesting { rows_per_second } | Self::Mixed { rows_per_second } => { + Some(*rows_per_second) } - | Self::Mixed { - rows_per_second, .. - } => Some(*rows_per_second), - Self::AtRest | Self::Unknown => None, - } - } - - /// The PromQL sample interval, when the data is ingesting or mixed and - /// declares one. - pub fn sample_interval(&self) -> Option { - match self { - Self::Ingesting { - sample_interval, .. - } - | Self::Mixed { - sample_interval, .. - } => *sample_interval, Self::AtRest | Self::Unknown => None, } } } -/// 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, - #[default] - Unknown, -} - -/// When one source's facts were observed and how long they stay valid, in -/// milliseconds. The default (neither set) never expires: a declared fact. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct Evidence { - /// When the facts were observed (Unix ms); `None` for declared facts. - #[serde(default)] - pub observed_at_ms: Option, - /// How long after `observed_at_ms` they stay valid; `None` for no - /// expiry. - #[serde(default)] - pub valid_for_ms: Option, -} - -impl Evidence { - /// Whether facts with this evidence may be used at `now_ms`: observed - /// no later than `now_ms` and not expired (expiry is inclusive). A - /// validity duration without an observation time is never usable. - pub fn is_fresh_at(&self, now_ms: u64) -> bool { - match (self.observed_at_ms, self.valid_for_ms) { - (Some(observed), _) if observed > now_ms => false, - (Some(observed), Some(valid_for)) => now_ms <= observed.saturating_add(valid_for), - (None, Some(_)) => false, - _ => true, - } - } -} - impl DemandEstimate { /// Whether this estimate was already observed and has not expired at /// `now_ms`. A validity duration without an observation time is not a @@ -476,11 +407,12 @@ impl DemandEstimate { pub struct Rate(pub f64); /// Facts about the data being queried: how it arrives, and what is known -/// about each source. +/// 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, -/// "sample_interval": 15000}}, "sources": {"http_requests_total": -/// {"series": 1000000, "metric_type": "counter"}}}`. +/// 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 { @@ -497,18 +429,24 @@ pub struct DataWorkload { #[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. - /// Plan selection splits a whole-result failure probability over the - /// values a query returns (`G`), at most the series of the sources it - /// reads, and prices scans and selectors with it (default 1,000 when - /// unknown). + /// 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). #[serde(default)] - pub series: Option, + 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 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 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 @@ -527,13 +465,6 @@ pub struct SourceFacts { /// is unknown. KMV's guarantee needs no bound. #[serde(default)] pub max_distinct_per_evaluation: Option, - /// When `series`, `stream_norms` and `max_distinct_per_evaluation` were - /// observed and how long they hold (default: declared, never expires). - /// Plan selection binds them into accuracy guarantees only while they - /// hold at any planning time. `metric_type` is a declaration and does - /// not expire. - #[serde(default)] - pub evidence: Evidence, } /// Upper bounds on the norms of the frequency (weight) vector any one @@ -610,14 +541,34 @@ impl QueryWorkload { } impl DataWorkload { + /// 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 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))?; - if self.arrival.sample_interval() == Some(DurationMs(0)) { - return Err(WorkloadError::ZeroSampleInterval); - } for facts in self.sources.values() { + 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) { @@ -646,11 +597,13 @@ impl PlanningWorkload { Ok(()) } - /// The PromQL sample interval the data workload declares, if any. - pub fn sample_interval(&self) -> Option { + /// 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() - .and_then(|data| data.arrival.sample_interval()) + .as_ref()? + .sources + .get(metric)? + .sample_interval } } @@ -700,36 +653,50 @@ 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 { /// Bad input: a PromQL or MetricsQL query has a bare instant selector, - /// which reads the latest sample within the sample interval, but the - /// data workload declares none (data at rest, unknown arrival, or - /// ingesting without `sample_interval`). + /// 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 data_workload.arrival to be \ - ingesting or mixed with a sample_interval" + "a PromQL or MetricsQL instant selector needs its metric's sample_interval \ + in data_workload.sources" )] MissingSampleInterval, - /// Bad input: a declared sample interval of zero. + /// 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, + /// Bad input: a demand estimate whose observation window does not + /// start before it ends. #[error("a demand-estimate observation window must have start < end")] EmptyObservationWindow, + /// Bad input: a demand estimate's confidence outside `[0, 1]` or not + /// finite. #[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), + /// 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, } @@ -811,16 +778,19 @@ mod tests { assert_eq!(with(0).validate(), Err(WorkloadError::ZeroMaxDistinct)); } - /// The arrival's rate is finite and non-negative, and a declared sample - /// interval is positive. + /// 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, - sample_interval: sample_interval.map(DurationMs), - }, - ..DataWorkload::default() + 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()); @@ -873,32 +843,7 @@ mod tests { } #[test] - fn stale_evidence_is_not_fresh_at_planning_time() { - let evidence = Evidence { - observed_at_ms: Some(1_000), - valid_for_ms: Some(500), - }; - assert!(evidence.is_fresh_at(1_500)); - assert!(!evidence.is_fresh_at(1_501)); - // Declared facts never expire; a validity without an observation - // time is never usable. - assert!(Evidence::default().is_fresh_at(u64::MAX)); - let unanchored = Evidence { - observed_at_ms: None, - valid_for_ms: Some(1), - }; - assert!(!unanchored.is_fresh_at(0)); - } - - #[test] - fn future_evidence_and_demand_estimates_are_not_fresh() { - let evidence = Evidence { - observed_at_ms: Some(2_000), - valid_for_ms: Some(1_000), - }; - assert!(!evidence.is_fresh_at(1_999)); - assert!(evidence.is_fresh_at(2_000)); - + fn future_demand_estimates_are_not_fresh() { let estimate = DemandEstimate { observation_window: ObservationWindow { start: TimestampMs(0), @@ -908,7 +853,6 @@ mod tests { 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)), }; @@ -935,22 +879,18 @@ mod tests { data_workload: Some(DataWorkload { arrival: DataArrival::Ingesting { rows_per_second: 1_000.0, - sample_interval: Some(DurationMs(15_000)), }, sources: BTreeMap::from([( "requests_total".to_string(), SourceFacts { - series: Some(10_000), + 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), - evidence: Evidence { - observed_at_ms: Some(1_000), - valid_for_ms: Some(60_000), - }, }, )]), }), @@ -960,13 +900,13 @@ mod tests { assert_eq!( json["data_workload"], serde_json::json!({ - "arrival": {"ingesting": {"rows_per_second": 1000.0, "sample_interval": 15000}}, + "arrival": {"ingesting": {"rows_per_second": 1000.0}}, "sources": {"requests_total": { - "series": 10000, + "series_count": 10000, "metric_type": "counter", + "sample_interval": 15000, "stream_norms": {"l1": 1e9, "l2": null}, - "max_distinct_per_evaluation": 100, - "evidence": {"observed_at_ms": 1000, "valid_for_ms": 60000} + "max_distinct_per_evaluation": 100 }} }) ); @@ -977,18 +917,24 @@ mod tests { let with_unknown = text.replacen("{", "{\"unknown\":true,", 1); assert!(serde_json::from_str::(&with_unknown).is_err()); - // Omitted facts default to unknown; the sample interval is optional. + // 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": 7}} + "sources": {"flows": {"series_count": 7}} })) .unwrap(); - assert_eq!(minimal.arrival.sample_interval(), None); - assert_eq!(minimal.sources["flows"].evidence, Evidence::default()); + 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. + /// 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| { @@ -1007,7 +953,10 @@ mod tests { serde_json::json!({"arrival": "at_rest", "metric_types": {"m": "counter"}}), serde_json::json!({"arrival": "at_rest", - "sources": {"m": {"series": 1, "evidence": {"source": "declared"}}}}), + "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!( @@ -1069,7 +1018,6 @@ mod tests { peak_rate: None, max_concurrency: None, confidence: Confidence(0.9), - source: EvidenceSource::Observed, observed_at: None, valid_for: None, }), diff --git a/crates/types/tests/workload_compatibility.rs b/crates/types/tests/workload_compatibility.rs index b0ac97dd9..9deaff29a 100644 --- a/crates/types/tests/workload_compatibility.rs +++ b/crates/types/tests/workload_compatibility.rs @@ -2,7 +2,7 @@ use asap_types::workload::PlanningWorkload; // A SQL workload over ingesting data omits the PromQL-only sample interval. // Validation never requires it, for PromQL either: the PromQL frontend -// checks it only for a query with a bare instant selector. +// checks a metric's interval only for a bare instant selector over it. #[test] fn ingesting_payload_without_sample_interval_validates() { for language in [ @@ -13,10 +13,11 @@ fn ingesting_payload_without_sample_interval_validates() { let payload = serde_json::json!({ "query_workload": {"language": language, "query_batch": null, "repeating_queries": null}, - "data_workload": {"arrival": {"ingesting": {"rows_per_second": 10.0}}} + "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(); - assert_eq!(workload.sample_interval(), None); + assert_eq!(workload.sample_interval_of("m"), None); } } From 967f170b218362073d9b2db539dc46f0e41132ef Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Fri, 16 Oct 2026 03:42:54 +0000 Subject: [PATCH 04/12] docs: per-source sample interval and series_count in the library and input docs --- .../architecture/input-output-workflow.md | 22 ++--- docs/develop_docs/library-api.md | 97 +++++++++---------- docs/develop_docs/pre-asap-ir.md | 2 +- 3 files changed, 57 insertions(+), 64 deletions(-) diff --git a/docs/design_docs/architecture/input-output-workflow.md b/docs/design_docs/architecture/input-output-workflow.md index be229c795..a079b3c2e 100644 --- a/docs/design_docs/architecture/input-output-workflow.md +++ b/docs/design_docs/architecture/input-output-workflow.md @@ -15,7 +15,7 @@ Post-ASAP alternatives for the workload. | Input | Fields | Required | |---|---|---:| | `PlanningWorkload.query_workload` | Query language and one-time/repeating query workloads | Yes | -| `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 an ingesting or mixed arrival with a nonzero `sample_interval`. | +| `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,7 +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: ingesting; sample interval 15 s"] + D["data_workload: ingesting; up sampled every 15 s"] F["PromQL lowering"] R["One canonical OperatorNode root"] S["Candidate search"] @@ -124,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 | There is no automatic default: explicitly set `None` when unavailable. A PromQL or MetricsQL query with a bare instant selector needs an ingesting or mixed arrival with a nonzero `sample_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` @@ -214,16 +214,16 @@ struct DataWorkload { } enum DataArrival { AtRest, - Ingesting { rows_per_second: f64, sample_interval: Option }, - Mixed { rows_per_second: f64, sample_interval: Option }, + Ingesting { rows_per_second: f64 }, + Mixed { rows_per_second: f64 }, Unknown, } struct SourceFacts { - series: Option, + series_count: Option, metric_type: Option, + sample_interval: Option, stream_norms: Option, max_distinct_per_evaluation: Option, - evidence: Evidence, } ``` @@ -231,13 +231,13 @@ struct SourceFacts { |---|---:|---| | `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. | -| `sample_interval` | PromQL only; nonzero | How often each series is sampled. Bare instant selectors use it as their selection horizon; without it, such a query fails to lower. | -| `series` | Optional | Series in the source. Plan selection bounds a query's number of values `G`, and its input row count, by the summed series of the sources it reads; unknown, they stay unbound. | +| `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. | -| `evidence` | Defaults to declared | When `series`, `stream_norms` and `max_distinct_per_evaluation` were observed (`observed_at_ms`) and how long they hold (`valid_for_ms`). Plan selection uses them only while they never expire; a duration without an observation timestamp is unusable. | -Unavailable or stale data facts stay unknown. Planner does not reinterpret +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 diff --git a/docs/develop_docs/library-api.md b/docs/develop_docs/library-api.md index c00b5a593..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`; one with a bare instant selector needs a nonzero `sample_interval` in its `DataArrival` | 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 | @@ -63,11 +63,11 @@ lower_promql_workload(workload: &PlanningWorkload) ``` A bare instant selector reads the latest sample within the sample interval -that `DataWorkload.arrival` declares (`DataArrival::Ingesting` or `Mixed` -with `sample_interval: Some(DurationMs(..))`, nonzero). A query with one -fails with `WorkloadError::MissingSampleInterval` when the data is at rest, -its arrival is unknown, or no interval is declared. Explicit range selectors -retain their query-specified range and need no interval. Use `lower_promql_workload_with_histograms` +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: @@ -84,7 +84,7 @@ that a supported candidate exists. ```rust use asap_frontend_promql::lower_promql_workload; use asap_types::workload::{ - BatchEntry, DataArrival, DataWorkload, DurationMs, Query, + BatchEntry, DataWorkload, DurationMs, Query, PlanningWorkload, QueryLanguage, QueryRequirements, QueryWorkload, }; use asap_types::types::AccuracyTarget; @@ -105,13 +105,11 @@ fn main() -> Result<(), Box> { }]), repeating_queries: None, }, - data_workload: Some(DataWorkload { - arrival: DataArrival::Ingesting { - rows_per_second: 1_000.0, - sample_interval: Some(DurationMs(1_000)), - }, - ..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)?; println!("{pre_asap:#?}"); @@ -197,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, DataArrival, DataWorkload, DurationMs, Query, + BatchEntry, DataWorkload, DurationMs, Query, PlanningWorkload, QueryLanguage, QueryRequirements, QueryWorkload, }; use asap_aware_mapping::{ @@ -223,13 +221,11 @@ fn main() -> Result<(), Box> { }]), repeating_queries: None, }, - data_workload: Some(DataWorkload { - arrival: DataArrival::Ingesting { - rows_per_second: 1_000.0, - sample_interval: Some(DurationMs(1_000)), - }, - ..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)?.remove(0); let cost_model = DefaultCostModel; @@ -344,7 +340,7 @@ replacement::default_strategies_with_evidence<'a>( ```rust use asap_frontend_promql::lower_promql_workload; use asap_types::workload::{ - BatchEntry, DataArrival, DataWorkload, DurationMs, Query, + BatchEntry, DataWorkload, DurationMs, Query, PlanningWorkload, QueryLanguage, QueryRequirements, QueryWorkload, }; use asap_aware_mapping::{ @@ -370,13 +366,11 @@ fn main() -> Result<(), Box> { }]), repeating_queries: None, }, - data_workload: Some(DataWorkload { - arrival: DataArrival::Ingesting { - rows_per_second: 1_000.0, - sample_interval: Some(DurationMs(1_000)), - }, - ..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)?.remove(0); let model = DefaultCostModel; @@ -492,28 +486,29 @@ 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` holds `arrival` (`DataArrival`: `AtRest`, `Ingesting` or -`Mixed` with `rows_per_second` and the PromQL-only `sample_interval`, or -`Unknown`) and `sources`, a map from PromQL metric or SQL table name to -`SourceFacts`: `series`, `metric_type`, `stream_norms`, -`max_distinct_per_evaluation`, and one `evidence` (when the facts were -observed and how long they hold). Plan selection binds a query's number of -values `G` and input row count to the summed `series` of the sources it -reads, and binds measured facts only while their evidence never expires. -`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. +`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, "sample_interval": 15000}}, - "sources": {"http_requests_total": {"series": 1000000, "metric_type": "counter"}}} +{"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, no source facts | Supply facts needed for the requested comparisons | -| `SourceFacts::default()` | Every fact unknown; declared evidence that never expires | Unknown facts are not zero; give observed facts their observation time and validity | +| `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. @@ -586,7 +581,7 @@ planning. ```rust use asap_frontend_promql::lower_promql_workload; use asap_types::workload::{ - BatchEntry, DataArrival, DataWorkload, DurationMs, Query, + BatchEntry, DataWorkload, DurationMs, Query, PlanningWorkload, QueryLanguage, QueryRequirements, QueryWorkload, }; use asap_aware_mapping::{search_workload, DefaultCostModel}; @@ -608,13 +603,11 @@ fn main() -> Result<(), Box> { }]), repeating_queries: None, }, - data_workload: Some(DataWorkload { - arrival: DataArrival::Ingesting { - rows_per_second: 1_000.0, - sample_interval: Some(DurationMs(1_000)), - }, - ..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)?.remove(0); let space = search_workload(vec![("q1", root)]); diff --git a/docs/develop_docs/pre-asap-ir.md b/docs/develop_docs/pre-asap-ir.md index 5dea9df1e..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 - `sample_interval` of the data arrival 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 From cf368197aa29c3ad666144279070e37739ba603a Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Fri, 16 Oct 2026 03:55:15 +0000 Subject: [PATCH 05/12] refactor(devtools)!: --sample-interval-ms in every lowering tool and the run-a-query guide --- crates/devtools/src/bin/analyze_corpora.rs | 8 ++--- crates/devtools/src/bin/show_logical_dag.rs | 4 +-- crates/devtools/src/bin/variant_coverage.rs | 14 ++++---- .../awesome_prometheus_alerts.rs | 2 +- .../tests/promql_conformance.rs | 4 +-- crates/planner/src/lib.rs | 4 +-- docs/user_guide_docs/run-a-query.md | 34 +++++++++---------- 7 files changed, 35 insertions(+), 35 deletions(-) diff --git a/crates/devtools/src/bin/analyze_corpora.rs b/crates/devtools/src/bin/analyze_corpora.rs index 7a2365676..2d2f34e7b 100644 --- a/crates/devtools/src/bin/analyze_corpora.rs +++ b/crates/devtools/src/bin/analyze_corpora.rs @@ -546,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}"); @@ -559,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 e7919f578..9b2ab90c1 100644 --- a/crates/devtools/src/bin/show_logical_dag.rs +++ b/crates/devtools/src/bin/show_logical_dag.rs @@ -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() diff --git a/crates/devtools/src/bin/variant_coverage.rs b/crates/devtools/src/bin/variant_coverage.rs index ba5645a50..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 @@ -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 ── 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/promql_conformance.rs b/crates/frontend-promql/tests/promql_conformance.rs index 45e919f8c..52d8ab3c2 100644 --- a/crates/frontend-promql/tests/promql_conformance.rs +++ b/crates/frontend-promql/tests/promql_conformance.rs @@ -203,8 +203,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/planner/src/lib.rs b/crates/planner/src/lib.rs index e222ab537..025d2c64b 100644 --- a/crates/planner/src/lib.rs +++ b/crates/planner/src/lib.rs @@ -143,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, 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. From b93783a58ea1cd416ec252ee0ecf6436fefe6742 Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Fri, 16 Oct 2026 13:33:10 +0000 Subject: [PATCH 06/12] fix(frontend-promql)!: reject selectors with no metric name A selector such as {job="a"} reads every metric, and a Source::TimeSeries names exactly one, so lowering now rejects it with "selector without one exact metric name", as the MetricsQL frontend does, instead of scanning a source named "". The 29 Prometheus testdata corpus queries with such selectors are counted as expected unsupported. --- crates/frontend-promql/src/lib.rs | 4 +-- crates/frontend-promql/src/promql.rs | 34 ++++++++++++------- .../tests/observability/promql_corpus.rs | 29 ++++++++++++---- .../tests/promql_conformance.rs | 22 ++++++++++++ 4 files changed, 69 insertions(+), 20 deletions(-) diff --git a/crates/frontend-promql/src/lib.rs b/crates/frontend-promql/src/lib.rs index ca477a730..c5254f104 100644 --- a/crates/frontend-promql/src/lib.rs +++ b/crates/frontend-promql/src/lib.rs @@ -46,8 +46,8 @@ pub fn lower_promql_workload_with_histograms( } /// The metrics `query`'s selectors read, by name: the sources whose facts -/// (such as `sample_interval`) its lowering looks up. A selector with no -/// metric name reads the source named `""`, as its scan does. +/// (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) } diff --git a/crates/frontend-promql/src/promql.rs b/crates/frontend-promql/src/promql.rs index 5e5f6270b..e510a2e08 100644 --- a/crates/frontend-promql/src/promql.rs +++ b/crates/frontend-promql/src/promql.rs @@ -1382,12 +1382,13 @@ 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 { - // A selector without a metric name scans the source named "". Expr::VectorSelector(vs) => { if let Ok((metric, ..)) = vs_parts(vs) { out.push(metric); @@ -2056,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/promql_corpus.rs b/crates/frontend-promql/tests/observability/promql_corpus.rs index 30329ded6..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 } } @@ -53,6 +60,7 @@ fn tally(corpus: &str) -> Tally { )) { 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 52d8ab3c2..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, From 53d1167e83864fa7455bbe6a7e39daed6100208e Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Fri, 16 Oct 2026 13:33:10 +0000 Subject: [PATCH 07/12] refactor(devtools)!: shared_data_workload takes a DataArrival SharedArrival only restated DataArrival without the rate. Callers pass a DataArrival with SHARED_ROWS_PER_SECOND, and the new candidate-label test from main declares its source. --- crates/devtools/src/bin/stage_pipeline.rs | 13 +++-- crates/devtools/src/candidate_labels.rs | 20 ++++++-- .../devtools/src/planner_layering_examples.rs | 49 +++++++++---------- crates/devtools/tests/stage_pipeline.rs | 10 +++- .../tests/planner_layering_example3.rs | 16 ++++-- .../tests/planner_layering_example4.rs | 18 +++++-- crates/integration-tests/tests/reuse_rules.rs | 12 +++-- 7 files changed, 92 insertions(+), 46 deletions(-) diff --git a/crates/devtools/src/bin/stage_pipeline.rs b/crates/devtools/src/bin/stage_pipeline.rs index 3a9ebee72..2cb3df3c8 100644 --- a/crates/devtools/src/bin/stage_pipeline.rs +++ b/crates/devtools/src/bin/stage_pipeline.rs @@ -84,7 +84,7 @@ use asap_aware_mapping::stage3_plan_selection::{ }; use asap_devtools::candidate_labels::candidate_labels; use asap_devtools::planner_layering_examples::{ - example1, example2, pattern_a, pattern_b, PatternARecurrence, SharedArrival, + 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, @@ -97,8 +97,8 @@ use asap_types::ir::QueryRoot; use asap_types::pre_asap::schema::{DataType, Field, Schema}; use asap_types::types::{default_metric_for_root, AccuracyMetric, AccuracyTarget}; use asap_types::workload::{ - BatchEntry, DataWorkload, DurationMs, PlanningWorkload, Query, QueryLanguage, QueryRecurrence, - QueryRequirements, QueryWorkload, RepeatedDemand, SqlDialect, + BatchEntry, DataArrival, DataWorkload, DurationMs, PlanningWorkload, Query, QueryLanguage, + QueryRecurrence, QueryRequirements, QueryWorkload, RepeatedDemand, SqlDialect, }; use serde_json::{json, Value}; @@ -293,7 +293,12 @@ fn run(args: Vec) -> Result<(), Failure> { "planner-layering-1" => (example1(), SqlCatalog::new()), "planner-layering-2" => example2(), "planner-layering-3a" => ( - pattern_a(PatternARecurrence::OnceAdHoc, SharedArrival::Mixed), + pattern_a( + PatternARecurrence::OnceAdHoc, + DataArrival::Mixed { + rows_per_second: SHARED_ROWS_PER_SECOND, + }, + ), SqlCatalog::new(), ), "planner-layering-3b" => (pattern_b(), SqlCatalog::new()), diff --git a/crates/devtools/src/candidate_labels.rs b/crates/devtools/src/candidate_labels.rs index dfd0c9cb8..05234f822 100644 --- a/crates/devtools/src/candidate_labels.rs +++ b/crates/devtools/src/candidate_labels.rs @@ -444,10 +444,10 @@ 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, SharedArrival}; + use crate::planner_layering_examples::{shared_data_workload, SHARED_ROWS_PER_SECOND}; use asap_types::types::AccuracyTarget; use asap_types::workload::{ - PlanningWorkload, Predictability, Query, QueryLanguage, QueryRequirements, + DataArrival, PlanningWorkload, Predictability, Query, QueryLanguage, QueryRequirements, QueryWorkload, RepeatedDemand, RepeatingEntry, RepetitionInterval, }; let panel = |query: &str| RepeatingEntry { @@ -469,7 +469,12 @@ mod tests { panel("max_over_time(a[1m])"), ]), }, - data_workload: Some(shared_data_workload(SharedArrival::Ingesting, "a")), + 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/planner_layering_examples.rs b/crates/devtools/src/planner_layering_examples.rs index 4f31ae0aa..7642004df 100644 --- a/crates/devtools/src/planner_layering_examples.rs +++ b/crates/devtools/src/planner_layering_examples.rs @@ -15,27 +15,15 @@ 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; -/// How the #509 shared data workload arrives. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SharedArrival { - /// Stored, nothing arriving. - AtRest, - /// Arriving while the queries run. - Ingesting, - /// Stored history with new rows arriving. - Mixed, -} +/// 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: one million series of `source`, each sampled -/// every 15 s, so 1,000,000 / 15 rows per second arrive while ingesting. -pub fn shared_data_workload(arrival: SharedArrival, source: &str) -> DataWorkload { - let rows_per_second = 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 { - arrival: match arrival { - SharedArrival::AtRest => DataArrival::AtRest, - SharedArrival::Ingesting => DataArrival::Ingesting { rows_per_second }, - SharedArrival::Mixed => DataArrival::Mixed { rows_per_second }, - }, + arrival, sources: [( source.to_string(), SourceFacts { @@ -80,7 +68,12 @@ pub fn example1() -> PlanningWorkload { ]), }, data_workload: Some({ - let mut data = shared_data_workload(SharedArrival::Ingesting, EXAMPLE1_METRIC); + 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") @@ -216,7 +209,7 @@ fn pattern_a_requirements() -> QueryRequirements { } /// Example 3, Pattern A: p99 over five historical intervals. -pub fn pattern_a(recurrence: PatternARecurrence, arrival: SharedArrival) -> PlanningWorkload { +pub fn pattern_a(recurrence: PatternARecurrence, arrival: DataArrival) -> PlanningWorkload { let (batch, repeating) = match recurrence { PatternARecurrence::OnceAdHoc => ( Some( @@ -287,7 +280,9 @@ pub fn pattern_b() -> PlanningWorkload { }]), }, data_workload: Some(shared_data_workload( - SharedArrival::Ingesting, + DataArrival::Ingesting { + rows_per_second: SHARED_ROWS_PER_SECOND, + }, PATTERN_METRIC, )), } @@ -301,9 +296,13 @@ mod tests { #[test] fn shared_data_workloads_are_valid() { for arrival in [ - SharedArrival::AtRest, - SharedArrival::Ingesting, - SharedArrival::Mixed, + 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() diff --git a/crates/devtools/tests/stage_pipeline.rs b/crates/devtools/tests/stage_pipeline.rs index 4d0a83089..473e575a4 100644 --- a/crates/devtools/tests/stage_pipeline.rs +++ b/crates/devtools/tests/stage_pipeline.rs @@ -4,7 +4,7 @@ use std::path::PathBuf; use std::process::Command; use asap_devtools::planner_layering_examples::{ - example1, pattern_a, PatternARecurrence, SharedArrival, + example1, pattern_a, PatternARecurrence, SHARED_ROWS_PER_SECOND, }; use asap_devtools::planning_stages::{ lower_promql_roots, run_planning_stages, PlanningStagesError, @@ -12,6 +12,7 @@ use asap_devtools::planning_stages::{ 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; @@ -205,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, SharedArrival::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; diff --git a/crates/integration-tests/tests/planner_layering_example3.rs b/crates/integration-tests/tests/planner_layering_example3.rs index 88c426a61..814fb122e 100644 --- a/crates/integration-tests/tests/planner_layering_example3.rs +++ b/crates/integration-tests/tests/planner_layering_example3.rs @@ -14,13 +14,16 @@ 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, - SharedArrival::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, SharedArrival::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, - SharedArrival::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 e02a79aeb..2bb9cde7d 100644 --- a/crates/integration-tests/tests/planner_layering_example4.rs +++ b/crates/integration-tests/tests/planner_layering_example4.rs @@ -20,20 +20,30 @@ use asap_aware_mapping::stage3_plan_selection::{ }; use asap_types::ir::flat::NodeId; use asap_types::post_asap::SketchAlgorithm; -use asap_types::workload::PlanningWorkload; +use asap_types::workload::{DataArrival, PlanningWorkload}; use planner_layering_common::*; /// Pattern A as given: one ad hoc batch at T over mixed data. fn once() -> PlanningWorkload { - pattern_a(PatternARecurrence::OnceAdHoc, SharedArrival::Mixed) + pattern_a( + PatternARecurrence::OnceAdHoc, + DataArrival::Mixed { + rows_per_second: SHARED_ROWS_PER_SECOND, + }, + ) } fn monthly() -> PlanningWorkload { - pattern_a(PatternARecurrence::MonthlyPredictable, SharedArrival::Mixed) + pattern_a( + PatternARecurrence::MonthlyPredictable, + DataArrival::Mixed { + rows_per_second: SHARED_ROWS_PER_SECOND, + }, + ) } fn at_rest() -> PlanningWorkload { - pattern_a(PatternARecurrence::OnceAdHoc, SharedArrival::AtRest) + pattern_a(PatternARecurrence::OnceAdHoc, DataArrival::AtRest) } /// The window-summary build of `c` with form `want` (`Some(form)` matches diff --git a/crates/integration-tests/tests/reuse_rules.rs b/crates/integration-tests/tests/reuse_rules.rs index 92ac4185b..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, SharedArrival}; +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; @@ -27,7 +27,8 @@ use asap_types::ir::{ use asap_types::post_asap::Schema; use asap_types::types::AccuracyTarget; use asap_types::workload::{ - DataWorkload, QueryRecurrence, QueryWorkloadEntry, RepeatedDemand, RepetitionInterval, + DataArrival, DataWorkload, QueryRecurrence, QueryWorkloadEntry, RepeatedDemand, + RepetitionInterval, }; const EVALUATION_MS: i64 = 300_000; @@ -62,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(SharedArrival::Ingesting, "m") + shared_data_workload( + DataArrival::Ingesting { + rows_per_second: SHARED_ROWS_PER_SECOND, + }, + "m", + ) } /// Every minute. From d28f37475a754b9849c5cbcc9a20600773524078 Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Fri, 16 Oct 2026 13:35:04 +0000 Subject: [PATCH 08/12] fix(asap-aware-mapping): vector(s) keeps the rows read below it The row bound of PromqlVectorFromScalar zeroed the copies of the sources below it, so series_read of vector(scalar(max(rate(x[5m])))) was 1 and the input row count shrank going up the tree. The bound now keeps them, and at_most_one_row gives a vector() root G = 1. --- .../src/stage3_plan_selection.rs | 45 +++++++++++++------ 1 file changed, 32 insertions(+), 13 deletions(-) diff --git a/crates/asap-aware-mapping/src/stage3_plan_selection.rs b/crates/asap-aware-mapping/src/stage3_plan_selection.rs index 84fea93ab..2e4163371 100644 --- a/crates/asap-aware-mapping/src/stage3_plan_selection.rs +++ b/crates/asap-aware-mapping/src/stage3_plan_selection.rs @@ -906,10 +906,9 @@ fn source_name(source: &Source) -> &str { } } -/// An upper bound on the rows one node returns per evaluation, in terms of -/// the sources it reads: `copies_per_source[s]` times the series of `s`, -/// plus `constant_rows`. A source with zero copies is read (under a scalar) -/// but adds no rows. +/// 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, @@ -969,10 +968,12 @@ fn row_bound( 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.copies_per_source.values_mut().for_each(|c| *c = 0); - bound.constant_rows = 1; + bound.constant_rows = bound.constant_rows.max(1); bound } Some( @@ -998,7 +999,7 @@ fn row_bound( /// 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 rows of declares no series count. +/// 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) @@ -1011,9 +1012,7 @@ fn series_read(root: &QueryRoot, data: &DataWorkload) -> Option { } let mut rows = bound.constant_rows; for (source, copies) in &bound.copies_per_source { - if *copies > 0 { - rows += copies * data.sources.get(source)?.series_count?; - } + rows += copies * data.sources.get(source)?.series_count?; } Some(rows) } @@ -1037,9 +1036,9 @@ fn values_per_result(root: &QueryRoot, series_read: Option) -> Option } /// 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 @@ -1071,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, .. } @@ -3555,6 +3556,24 @@ mod tests { } } + /// `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. struct EveryOutputMeasuredExact; From 0b655e6c567c1aaf833dab615a019eb752e18a26 Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Fri, 16 Oct 2026 13:36:21 +0000 Subject: [PATCH 09/12] fix(types): reject a series_count of zero A source with zero series would bound the input row count at zero, while absent(m) over it still returns one row. DataWorkload::validate now rejects it with WorkloadError::ZeroSeriesCount. --- crates/types/src/workload.rs | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/crates/types/src/workload.rs b/crates/types/src/workload.rs index 4d4ae6773..e286a762a 100644 --- a/crates/types/src/workload.rs +++ b/crates/types/src/workload.rs @@ -432,7 +432,7 @@ 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). + /// scans and selectors with it (1,000 when unknown). Must be positive. #[serde(default)] pub series_count: Option, /// Declared Prometheus type of the metric (`# TYPE` metadata). The @@ -566,6 +566,9 @@ impl DataWorkload { 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); } @@ -699,6 +702,11 @@ pub enum WorkloadError { /// 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)] @@ -778,6 +786,24 @@ mod tests { 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] From 9b5afd6d7954892ea3428b71808275b5fb8ab1e1 Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Fri, 16 Oct 2026 13:37:23 +0000 Subject: [PATCH 10/12] refactor(types)!: drop DemandEstimate.peak_rate and max_concurrency No non-test code reads them: max_concurrency had no reader and peak_rate was only validated. --- crates/types/src/workload.rs | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/crates/types/src/workload.rs b/crates/types/src/workload.rs index e286a762a..842823607 100644 --- a/crates/types/src/workload.rs +++ b/crates/types/src/workload.rs @@ -138,8 +138,8 @@ pub struct ObservationWindow { /// /// # 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 +/// This estimate predicts ten executions per second, based on one minute of +/// observed data. It is usable for five minutes /// after `observed_at`. /// /// ``` @@ -153,8 +153,6 @@ pub struct ObservationWindow { /// end: TimestampMs(60_000), /// }, /// expected_rate: Rate(10.0), -/// peak_rate: Some(Rate(20.0)), -/// max_concurrency: Some(4), /// confidence: Confidence(0.95), /// observed_at: Some(TimestampMs(60_000)), /// valid_for: Some(DurationMs(5 * 60 * 1_000)), @@ -165,8 +163,6 @@ pub struct ObservationWindow { pub struct DemandEstimate { pub observation_window: ObservationWindow, pub expected_rate: Rate, - pub peak_rate: Option, - pub max_concurrency: Option, pub confidence: Confidence, pub observed_at: Option, pub valid_for: Option, @@ -637,7 +633,6 @@ fn validate_entry(entry: &QueryWorkloadEntry) -> Result<(), WorkloadError> { return Err(WorkloadError::InvalidConfidence(estimate.confidence.0)); } validate_rate(estimate.expected_rate)?; - validate_optional_rate(estimate.peak_rate)?; } _ => {} } @@ -876,8 +871,6 @@ mod tests { end: TimestampMs(1_000), }, expected_rate: Rate(1.0), - peak_rate: None, - max_concurrency: None, confidence: Confidence(1.0), observed_at: Some(TimestampMs(2_000)), valid_for: Some(DurationMs(1_000)), @@ -1041,8 +1034,6 @@ mod tests { end: TimestampMs(10), }, expected_rate: Rate(1.0), - peak_rate: None, - max_concurrency: None, confidence: Confidence(0.9), observed_at: None, valid_for: None, From a233d323431990ec34b2c12635f511ce5582f73a Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Fri, 16 Oct 2026 13:41:25 +0000 Subject: [PATCH 11/12] refactor(devtools)!: stage_pipeline --sample-interval-ms stage_pipeline's --interval-ms becomes --sample-interval-ms, the name the other lowering tools use; the stage viewer's server and the stage-viewer guide pass and document the new flag. --- crates/devtools/src/bin/stage_pipeline.rs | 27 ++++++++++++++--------- crates/devtools/tests/stage_pipeline.rs | 22 ++++++++++++++++++ docs/user_guide_docs/stage-viewer.md | 2 +- tools/dag-viewer/server.py | 2 +- tools/dag-viewer/test_server.py | 2 +- 5 files changed, 42 insertions(+), 13 deletions(-) diff --git a/crates/devtools/src/bin/stage_pipeline.rs b/crates/devtools/src/bin/stage_pipeline.rs index 2cb3df3c8..30d8c86de 100644 --- a/crates/devtools/src/bin/stage_pipeline.rs +++ b/crates/devtools/src/bin/stage_pipeline.rs @@ -68,7 +68,7 @@ // `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 sample interval declared for every metric the +// `--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| @@ -104,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 "; @@ -186,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; @@ -223,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}"))? @@ -337,7 +339,7 @@ fn run(args: Vec) -> Result<(), Failure> { 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 - // `--interval-ms`. + // `--sample-interval-ms`. let mut metrics = BTreeSet::new(); for (index, (language, text, name)) in queries.into_iter().enumerate() { let (name, defaulted) = match name { @@ -356,7 +358,12 @@ 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)?, @@ -371,7 +378,7 @@ fn run(args: Vec) -> Result<(), Failure> { query["id"] = json!(name); described.push(query); } - let data = DataWorkload::sampled_every(metrics, DurationMs(interval_ms)); + let data = DataWorkload::sampled_every(metrics, DurationMs(sample_interval_ms)); let result = run_planning_stages_over( roots.clone(), &entries, @@ -668,9 +675,9 @@ 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 `interval_ms`; a + // 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 @@ -701,7 +708,7 @@ fn query_batch( }, data_workload: Some(DataWorkload::sampled_every( metrics, - DurationMs(interval_ms), + DurationMs(sample_interval_ms), )), } } diff --git a/crates/devtools/tests/stage_pipeline.rs b/crates/devtools/tests/stage_pipeline.rs index 473e575a4..8a9484a89 100644 --- a/crates/devtools/tests/stage_pipeline.rs +++ b/crates/devtools/tests/stage_pipeline.rs @@ -437,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/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): From 0c248d36d93622813b7fa480f5114a514d287ecf Mon Sep 17 00:00:00 2001 From: zzylol <50204836+zzylol@users.noreply.github.com> Date: Fri, 16 Oct 2026 14:01:55 +0000 Subject: [PATCH 12/12] refactor(types)!: drop DemandEstimate.confidence and observation_window No planner code reads them; validation only checked them. Their types Confidence and ObservationWindow, and the errors EmptyObservationWindow and InvalidConfidence, go with them. --- crates/types/src/workload.rs | 70 ++++-------------------------------- 1 file changed, 6 insertions(+), 64 deletions(-) diff --git a/crates/types/src/workload.rs b/crates/types/src/workload.rs index 842823607..2f101dba5 100644 --- a/crates/types/src/workload.rs +++ b/crates/types/src/workload.rs @@ -112,48 +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, 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, 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), -/// confidence: Confidence(0.95), /// observed_at: Some(TimestampMs(60_000)), /// valid_for: Some(DurationMs(5 * 60 * 1_000)), /// }; @@ -161,9 +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 confidence: Confidence, pub observed_at: Option, pub valid_for: Option, } @@ -625,13 +595,6 @@ 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)?; } _ => {} @@ -674,14 +637,6 @@ pub enum WorkloadError { /// Bad input: a scheduled repeating query with no timestamps. #[error("a repeated-query schedule must not be empty")] EmptySchedule, - /// Bad input: a demand estimate whose observation window does not - /// start before it ends. - #[error("a demand-estimate observation window must have start < end")] - EmptyObservationWindow, - /// Bad input: a demand estimate's confidence outside `[0, 1]` or not - /// finite. - #[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}")] @@ -866,12 +821,7 @@ mod tests { #[test] 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), - confidence: Confidence(1.0), observed_at: Some(TimestampMs(2_000)), valid_for: Some(DurationMs(1_000)), }; @@ -1024,26 +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), - confidence: Confidence(0.9), + 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))); } }