|
| 1 | +//! Materialization decides placement, not answers: the same selected DAG |
| 2 | +//! timed with its states `NotMaterialized` (everything at query time) and |
| 3 | +//! `MatAtIngestion` (states in precompute) executes to the same result. |
| 4 | +//! Ported from the removed summary-maintenance-lifecycle end-to-end tests. |
| 5 | +
|
| 6 | +use std::collections::BTreeMap; |
| 7 | +use std::rc::Rc; |
| 8 | +use std::sync::Arc; |
| 9 | + |
| 10 | +use asap_aware_mapping::maintained_population::MaintainedPopulationStrategy; |
| 11 | +use asap_aware_mapping::{search_workload, DefaultCostModel}; |
| 12 | +use asap_integration_tests::fixtures::lower_promql; |
| 13 | +use asap_physical_operators::physical_planner::{ |
| 14 | + compile_candidate, frontier_from_timing, promql_rows, CompiledPhysicalDAG, InputContract, |
| 15 | +}; |
| 16 | +use asap_physical_operators::runtime::Scope; |
| 17 | +use asap_physical_operators::values::{Batch, SchemaRef, Value}; |
| 18 | +use asap_types::ir::physical_export::{ |
| 19 | + compile_physical_asap_dag, PhysicalASAPDAG, PhysicalASAPOperatorPayload, |
| 20 | +}; |
| 21 | +use asap_types::ir::{ |
| 22 | + apply_materialization_timings, ASAPOp, Materialization, MaterializationAssignment, Medium, |
| 23 | + NonASAPOp, Operator, OperatorNode, TimingMemo, |
| 24 | +}; |
| 25 | +use asap_types::post_asap::maintained_population::PopulationInput; |
| 26 | +use asap_types::post_asap::ExecutionTiming; |
| 27 | +use asap_types::pre_asap::Source; |
| 28 | +use asap_types::types::AccuracyTarget; |
| 29 | + |
| 30 | +mod physical_common; |
| 31 | + |
| 32 | +const AT_INGESTION: Materialization = Materialization::MatAtIngestion(Medium::Memory); |
| 33 | + |
| 34 | +/// The selected logical DAG for `query`, optionally with a PromQL series |
| 35 | +/// identity (per-entity state needs one). |
| 36 | +fn selected(query: &str, series_identity: bool) -> Rc<OperatorNode> { |
| 37 | + let mut root = lower_promql(query, AccuracyTarget::Epsilon(0.01)).unwrap(); |
| 38 | + if series_identity { |
| 39 | + root = promql_rows::with_series_identity(&root).unwrap(); |
| 40 | + } |
| 41 | + let space = search_workload(vec![("query", root)]).unwrap(); |
| 42 | + space |
| 43 | + .global_selection(&DefaultCostModel) |
| 44 | + .assemble_selected_dag(&space.roots[0].1) |
| 45 | + .unwrap() |
| 46 | + .unwrap() |
| 47 | +} |
| 48 | + |
| 49 | +/// The two placements under test: nothing stored, and every state stored at |
| 50 | +/// ingestion time where the DAG allows it. |
| 51 | +fn placements() -> [MaterializationAssignment; 2] { |
| 52 | + [ |
| 53 | + MaterializationAssignment::default(), |
| 54 | + MaterializationAssignment::with_default(AT_INGESTION), |
| 55 | + ] |
| 56 | +} |
| 57 | + |
| 58 | +fn timed_dag(root: &Rc<OperatorNode>, assignment: &MaterializationAssignment) -> PhysicalASAPDAG { |
| 59 | + let timed = apply_materialization_timings(root, assignment, &mut TimingMemo::new()).unwrap(); |
| 60 | + compile_physical_asap_dag(&timed).unwrap() |
| 61 | +} |
| 62 | + |
| 63 | +fn states(dag: &PhysicalASAPDAG) -> Vec<u64> { |
| 64 | + dag.nodes |
| 65 | + .iter() |
| 66 | + .filter(|node| node.materialization == AT_INGESTION) |
| 67 | + .map(|node| node.id as u64) |
| 68 | + .collect() |
| 69 | +} |
| 70 | + |
| 71 | +fn floats(result: &[Vec<Batch>]) -> Vec<f64> { |
| 72 | + result[0] |
| 73 | + .iter() |
| 74 | + .flat_map(|batch| batch.rows()) |
| 75 | + .flat_map(|row| row.iter()) |
| 76 | + .filter_map(|value| match value { |
| 77 | + Value::Float64(value) => Some(*value), |
| 78 | + _ => None, |
| 79 | + }) |
| 80 | + .collect() |
| 81 | +} |
| 82 | + |
| 83 | +/// Execute a timed DAG over one raw-source batch per `TimeRange`/`Scan` |
| 84 | +/// input (`batch(schema, metric)`): precompute (when there is one) over |
| 85 | +/// `ingestion`, then the query at `evaluation_time_ms`. Returns the root's |
| 86 | +/// values and the frontier. |
| 87 | +fn execute( |
| 88 | + dag: &PhysicalASAPDAG, |
| 89 | + batch: &dyn Fn(&SchemaRef, &str) -> Batch, |
| 90 | + ingestion: Scope, |
| 91 | + evaluation_time_ms: i64, |
| 92 | +) -> (Vec<f64>, Vec<u64>) { |
| 93 | + let mut raw = BTreeMap::new(); |
| 94 | + for node in &dag.nodes { |
| 95 | + if !matches!( |
| 96 | + node.payload, |
| 97 | + PhysicalASAPOperatorPayload::NonASAP( |
| 98 | + NonASAPOp::TimeRange { .. } | NonASAPOp::Scan { .. } |
| 99 | + ) |
| 100 | + ) { |
| 101 | + continue; |
| 102 | + } |
| 103 | + let mut id = node.id; |
| 104 | + loop { |
| 105 | + let n = dag.nodes.iter().find(|n| n.id == id).unwrap(); |
| 106 | + if let PhysicalASAPOperatorPayload::NonASAP(NonASAPOp::Scan { |
| 107 | + source: Source::TimeSeries { metric }, |
| 108 | + .. |
| 109 | + }) = &n.payload |
| 110 | + { |
| 111 | + raw.insert( |
| 112 | + node.id as u64, |
| 113 | + (Arc::new(node.output_schema.clone()), metric.clone()), |
| 114 | + ); |
| 115 | + break; |
| 116 | + } |
| 117 | + id = dag |
| 118 | + .edges |
| 119 | + .iter() |
| 120 | + .find(|e| e.consumer == id) |
| 121 | + .unwrap() |
| 122 | + .producer; |
| 123 | + } |
| 124 | + } |
| 125 | + let frontier = frontier_from_timing(dag).unwrap(); |
| 126 | + let candidate = compile_candidate( |
| 127 | + dag, |
| 128 | + raw.iter() |
| 129 | + .map(|(id, (schema, _))| (*id, InputContract::bounded(schema.clone()))) |
| 130 | + .collect(), |
| 131 | + &[dag.roots[0] as u64], |
| 132 | + &frontier, |
| 133 | + ) |
| 134 | + .unwrap(); |
| 135 | + let raw_sources = |plan: &CompiledPhysicalDAG| { |
| 136 | + plan.input_contracts() |
| 137 | + .filter_map(|(id, _)| raw.get(&id).map(|(schema, name)| (id, batch(schema, name)))) |
| 138 | + .collect::<BTreeMap<_, _>>() |
| 139 | + }; |
| 140 | + let mut query_sources = raw_sources(&candidate.query); |
| 141 | + if frontier.is_empty() { |
| 142 | + assert!(candidate.precompute.is_none()); |
| 143 | + } |
| 144 | + if let Some(precompute) = &candidate.precompute { |
| 145 | + let stored = physical_common::execute(precompute, raw_sources(precompute), ingestion); |
| 146 | + for (root, batches) in precompute.roots().iter().zip(stored) { |
| 147 | + query_sources.insert(*root, batches[0].clone()); |
| 148 | + } |
| 149 | + } |
| 150 | + let result = physical_common::execute( |
| 151 | + &candidate.query, |
| 152 | + query_sources, |
| 153 | + Scope::Query { |
| 154 | + evaluation_time_ms, |
| 155 | + revision: 1, |
| 156 | + }, |
| 157 | + ); |
| 158 | + (floats(&result), frontier) |
| 159 | +} |
| 160 | + |
| 161 | +/// `(metric, job, seconds, value)` samples as one batch per metric. |
| 162 | +fn job_samples<'a>( |
| 163 | + samples: &'a [(&'a str, &'a str, i64, f64)], |
| 164 | +) -> impl Fn(&SchemaRef, &str) -> Batch + 'a { |
| 165 | + move |schema, name| { |
| 166 | + let rows = samples |
| 167 | + .iter() |
| 168 | + .filter(|sample| sample.0 == name) |
| 169 | + .map(|(metric, job, seconds, value)| { |
| 170 | + let labels = BTreeMap::from([ |
| 171 | + ("__name__".to_string(), metric.to_string()), |
| 172 | + ("job".to_string(), job.to_string()), |
| 173 | + ]); |
| 174 | + promql_rows::series_row(schema, &labels, seconds * 1000, *value).unwrap() |
| 175 | + }) |
| 176 | + .collect(); |
| 177 | + Batch::try_new(schema.clone(), rows).unwrap() |
| 178 | + } |
| 179 | +} |
| 180 | + |
| 181 | +/// `MatAtIngestion` puts the quantile's state in precompute; |
| 182 | +/// `NotMaterialized` leaves precompute empty and reads the raw source at |
| 183 | +/// query time; both answer alike. |
| 184 | +#[test] |
| 185 | +fn materialization_decides_precompute_contents() { |
| 186 | + let root = selected("quantile(0.99, latency)", false); |
| 187 | + let mut answers = Vec::new(); |
| 188 | + for assignment in placements() { |
| 189 | + let dag = timed_dag(&root, &assignment); |
| 190 | + let batch = |schema: &SchemaRef, _: &str| { |
| 191 | + let rows = (1..=100) |
| 192 | + .map(|value| { |
| 193 | + schema |
| 194 | + .fields |
| 195 | + .iter() |
| 196 | + .map(|field| match field.dtype { |
| 197 | + asap_types::post_asap::FieldDataType::Plain( |
| 198 | + asap_types::pre_asap::DataType::Float64, |
| 199 | + ) => Value::Float64(f64::from(value)), |
| 200 | + asap_types::post_asap::FieldDataType::Plain( |
| 201 | + asap_types::pre_asap::DataType::Timestamp, |
| 202 | + ) => Value::Timestamp(300_000), |
| 203 | + _ => panic!("unexpected field {field:?}"), |
| 204 | + }) |
| 205 | + .collect() |
| 206 | + }) |
| 207 | + .collect(); |
| 208 | + Batch::try_new(schema.clone(), rows).unwrap() |
| 209 | + }; |
| 210 | + let ingestion = Scope::Ingestion { |
| 211 | + window_start_ms: 0, |
| 212 | + window_end_ms: 300_000, |
| 213 | + revision: 1, |
| 214 | + }; |
| 215 | + let (values, frontier) = execute(&dag, &batch, ingestion, 300_000); |
| 216 | + assert_eq!(frontier, states(&dag)); |
| 217 | + answers.push(values); |
| 218 | + } |
| 219 | + assert_eq!(answers[1].len(), 1); |
| 220 | + assert!(!states(&timed_dag(&root, &placements()[1])).is_empty()); |
| 221 | + assert_eq!(answers[0], answers[1]); |
| 222 | +} |
| 223 | + |
| 224 | +/// A maintained current-series population: `MatAtIngestion` stores it in |
| 225 | +/// precompute, `NotMaterialized` rebuilds it from the raw source at query |
| 226 | +/// time; both rank alike. |
| 227 | +#[test] |
| 228 | +fn population_materialization_decides_precompute_contents() { |
| 229 | + let root = promql_rows::with_series_identity( |
| 230 | + &lower_promql("topk by(job)(1, m)", AccuracyTarget::Epsilon(0.01)).unwrap(), |
| 231 | + ) |
| 232 | + .unwrap(); |
| 233 | + let root = MaintainedPopulationStrategy::new(std::slice::from_ref(&root)) |
| 234 | + .candidate(&root) |
| 235 | + .unwrap(); |
| 236 | + let population = OperatorNode::reachable(&root) |
| 237 | + .into_iter() |
| 238 | + .find(|node| matches!(node.asap(), Some(ASAPOp::MaintainPopulation { .. }))) |
| 239 | + .expect("a maintained population"); |
| 240 | + let mut stored = MaterializationAssignment::default(); |
| 241 | + stored.set(&population, AT_INGESTION); |
| 242 | + let mut answers = Vec::new(); |
| 243 | + for assignment in [MaterializationAssignment::default(), stored] { |
| 244 | + let dag = timed_dag(&root, &assignment); |
| 245 | + let lookback = dag |
| 246 | + .nodes |
| 247 | + .iter() |
| 248 | + .find_map(|node| match &node.payload { |
| 249 | + PhysicalASAPOperatorPayload::ASAP(ASAPOp::MaintainPopulation { |
| 250 | + population, |
| 251 | + .. |
| 252 | + }) => match &population.input { |
| 253 | + PopulationInput::CurrentSeries(spec) => { |
| 254 | + Some(i64::try_from(spec.lookback_ms).unwrap()) |
| 255 | + } |
| 256 | + _ => None, |
| 257 | + }, |
| 258 | + _ => None, |
| 259 | + }) |
| 260 | + .expect("current-series population"); |
| 261 | + let end = 60_000; |
| 262 | + let batch = |schema: &SchemaRef, _: &str| { |
| 263 | + let rows = [("a", end - 1, 100.), ("a", end, 1.), ("b", end, 20.)] |
| 264 | + .into_iter() |
| 265 | + .map(|(instance, at, value)| { |
| 266 | + promql_rows::series_row( |
| 267 | + schema, |
| 268 | + &BTreeMap::from([ |
| 269 | + ("job".into(), "api".into()), |
| 270 | + ("instance".into(), instance.into()), |
| 271 | + ]), |
| 272 | + at, |
| 273 | + value, |
| 274 | + ) |
| 275 | + .unwrap() |
| 276 | + }) |
| 277 | + .collect(); |
| 278 | + Batch::try_new(schema.clone(), rows).unwrap() |
| 279 | + }; |
| 280 | + let ingestion = Scope::Ingestion { |
| 281 | + window_start_ms: end - lookback, |
| 282 | + window_end_ms: end, |
| 283 | + revision: 1, |
| 284 | + }; |
| 285 | + let (values, frontier) = execute(&dag, &batch, ingestion, end); |
| 286 | + assert_eq!(frontier, states(&dag)); |
| 287 | + answers.push(values); |
| 288 | + } |
| 289 | + assert_eq!(answers[0], answers[1]); |
| 290 | + assert_eq!(answers[0], [20.]); |
| 291 | +} |
| 292 | + |
| 293 | +/// Whether the DAG has an ingestion-time `BinaryOp`. |
| 294 | +fn ingestion_binary(dag: &PhysicalASAPDAG) -> bool { |
| 295 | + dag.nodes.iter().any(|node| { |
| 296 | + matches!( |
| 297 | + node.payload, |
| 298 | + PhysicalASAPOperatorPayload::NonASAP(NonASAPOp::BinaryOp { .. }) |
| 299 | + ) && node.output_state.timing == ExecutionTiming::IngestionTime |
| 300 | + }) |
| 301 | +} |
| 302 | + |
| 303 | +const INGESTION_WINDOW: Scope = Scope::Ingestion { |
| 304 | + window_start_ms: 240_000, |
| 305 | + window_end_ms: 300_000, |
| 306 | + revision: 1, |
| 307 | +}; |
| 308 | + |
| 309 | +/// Prometheus drops series without a match: arithmetic over different |
| 310 | +/// selectors keeps only label sets present on both sides (none when |
| 311 | +/// disjoint), at either placement, and never becomes aligned maintenance. |
| 312 | +#[test] |
| 313 | +fn arithmetic_over_different_selectors_matches_prometheus() { |
| 314 | + // The selected root is the outer `sum`'s exact state; finalize it to read |
| 315 | + // its value. |
| 316 | + let root = selected("sum(sum_over_time(m[1m]) + sum_over_time(n[1m]))", true); |
| 317 | + assert!(matches!(root.asap(), Some(ASAPOp::SummaryAgg { .. }))); |
| 318 | + let root = OperatorNode::new_shared(Operator::ASAP(ASAPOp::FinalizeExactAccumulator { |
| 319 | + child: root, |
| 320 | + })) |
| 321 | + .unwrap(); |
| 322 | + let disjoint = [ |
| 323 | + ("m", "a", 250, 1.0), |
| 324 | + ("m", "a", 290, 2.0), |
| 325 | + ("n", "b", 250, 5.0), |
| 326 | + ]; |
| 327 | + // Only job a is on both sides: m_a + n_a = (1 + 2) + 7; m{job="b"} is dropped. |
| 328 | + let overlapping = [ |
| 329 | + ("m", "a", 250, 1.0), |
| 330 | + ("m", "a", 290, 2.0), |
| 331 | + ("m", "b", 250, 5.0), |
| 332 | + ("n", "a", 250, 7.0), |
| 333 | + ]; |
| 334 | + for assignment in placements() { |
| 335 | + let dag = timed_dag(&root, &assignment); |
| 336 | + assert!(!ingestion_binary(&dag)); |
| 337 | + let (values, _) = execute(&dag, &job_samples(&disjoint), INGESTION_WINDOW, 300_000); |
| 338 | + assert!(values.is_empty(), "{values:?}"); |
| 339 | + let (values, _) = execute(&dag, &job_samples(&overlapping), INGESTION_WINDOW, 300_000); |
| 340 | + assert_eq!(values, [10.0]); |
| 341 | + } |
| 342 | + let root = selected( |
| 343 | + "quantile(0.9, sum_over_time(m[1m]) + sum_over_time(n[1m]))", |
| 344 | + true, |
| 345 | + ); |
| 346 | + assert!(!ingestion_binary(&timed_dag(&root, &placements()[1]))); |
| 347 | +} |
| 348 | + |
| 349 | +/// Arithmetic over one selector is aligned maintenance when stored at |
| 350 | +/// ingestion time, and adds each series' two evaluations before the |
| 351 | +/// quantile at either placement. |
| 352 | +#[test] |
| 353 | +fn arithmetic_over_one_selector_executes() { |
| 354 | + let root = selected( |
| 355 | + "quantile(0.9, sum_over_time(m[1m]) + sum_over_time(m[1m]))", |
| 356 | + true, |
| 357 | + ); |
| 358 | + let samples = [ |
| 359 | + ("m", "a", 250, 1.0), |
| 360 | + ("m", "a", 290, 2.0), |
| 361 | + ("m", "b", 250, 5.0), |
| 362 | + ]; |
| 363 | + let [not_materialized, at_ingestion] = placements().map(|a| timed_dag(&root, &a)); |
| 364 | + assert!(!ingestion_binary(¬_materialized)); |
| 365 | + assert!( |
| 366 | + ingestion_binary(&at_ingestion), |
| 367 | + "one selector shares its key set" |
| 368 | + ); |
| 369 | + for dag in [not_materialized, at_ingestion] { |
| 370 | + let (values, _) = execute(&dag, &job_samples(&samples), INGESTION_WINDOW, 300_000); |
| 371 | + // job a: 3 + 3 = 6; job b: 5 + 5 = 10 (mispairing a with b gives 8 and 8). |
| 372 | + // KLL at epsilon 0.01 returns an input value within 0.01 of rank 0.9; |
| 373 | + // of two values only the larger is. |
| 374 | + assert_eq!(values, [10.0]); |
| 375 | + } |
| 376 | +} |
0 commit comments