Skip to content

Commit 6a79e94

Browse files
zzylolclaude
andcommitted
test(integration): same answers with and without materialization
Ports the four execution tests removed with the summary-maintenance lifecycle to MaterializationAssignment: NotMaterialized and MatAtIngestion placements of the KLL quantile, the current-series population, and sum_over_time arithmetic over one and over different selectors execute to the same results. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW
1 parent e5155b3 commit 6a79e94

1 file changed

Lines changed: 376 additions & 0 deletions

File tree

Lines changed: 376 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,376 @@
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(&not_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

Comments
 (0)