Skip to content

Commit 85c112e

Browse files
fix(optimizer): enforce aggregation_id assignment via OptimizerSolution::register_config (#585)
candidate_gen.rs builds candidates with aggregation_id=0 as a placeholder for cost evaluation, since the id isn't known until a solver deploys the config. greedy.rs correctly overwrote it before insertion, but nothing stopped a future solver (or a change to greedy.rs) from forgetting to. Make deployed_configs private on OptimizerSolution and route all insertion through register_config(), which always assigns a fresh id. No caller can add a deployed config without going through id assignment. Fixes #564
1 parent b350dba commit 85c112e

4 files changed

Lines changed: 104 additions & 42 deletions

File tree

asap-planner-rs/src/optimizer/candidate_gen.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,9 @@ fn determine_query_method(
172172
}
173173
}
174174

175-
/// Build an AggregationConfig from candidate parameters. aggregation_id = 0 (placeholder).
175+
/// Build an AggregationConfig from candidate parameters. aggregation_id = 0 is a
176+
/// placeholder never used past cost evaluation — OptimizerSolution::register_config
177+
/// overwrites it with a real id when (if) a solver deploys this candidate.
176178
#[allow(clippy::too_many_arguments)]
177179
fn build_config(
178180
aqe: &AQE,
@@ -185,7 +187,7 @@ fn build_config(
185187
n_windows: u64,
186188
) -> AggregationConfig {
187189
AggregationConfig::new(
188-
0, // placeholder; replaced by greedy/MIP solver when deploying
190+
0, // placeholder; overwritten by OptimizerSolution::register_config when deployed
189191
agg_type,
190192
sub_type.to_string(),
191193
params.clone(),

asap-planner-rs/src/optimizer/greedy.rs

Lines changed: 8 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,3 @@
1-
use std::collections::HashMap;
2-
3-
use asap_types::aggregation_config::AggregationConfig;
41
use tracing::debug;
52

63
use super::atomic_costs::{resolve_atomic_costs, AtomicCostTable};
@@ -29,11 +26,7 @@ pub fn greedy_assign(
2926
atomic_cost_table: &AtomicCostTable,
3027
weights: &CostWeights,
3128
) -> OptimizerSolution {
32-
let mut deployed_configs: HashMap<u64, AggregationConfig> = HashMap::new();
33-
let mut assignments = Vec::new();
34-
let mut estimated_ingest_cost_per_sec = 0.0;
35-
let mut estimated_total_cost_per_sec = 0.0;
36-
let mut next_id: u64 = 1;
29+
let mut solution = OptimizerSolution::empty();
3730

3831
for aqe in aqes {
3932
let candidates = enumerate_candidates(&aqe, scrape_interval_ms);
@@ -66,16 +59,7 @@ pub fn greedy_assign(
6659
let query_rate = aqe.query_frequency_hz * query_cost(&aqe, &best, &costs, weights);
6760
let query_method = best.query_method.clone();
6861

69-
let aggregation_id = match best.config {
70-
None => None,
71-
Some(mut config) => {
72-
let id = next_id;
73-
next_id += 1;
74-
config.aggregation_id = id;
75-
deployed_configs.insert(id, config);
76-
Some(id)
77-
}
78-
};
62+
let aggregation_id = best.config.map(|config| solution.register_config(config));
7963

8064
debug!(
8165
metric = %aqe.requirements.metric,
@@ -86,23 +70,18 @@ pub fn greedy_assign(
8670
"greedy: assigned AQE"
8771
);
8872

89-
estimated_ingest_cost_per_sec += ingest;
90-
estimated_total_cost_per_sec += ingest + query_rate;
73+
solution.estimated_ingest_cost_per_sec += ingest;
74+
solution.estimated_total_cost_per_sec += ingest + query_rate;
9175

92-
assignments.push(AQEAssignment {
76+
solution.assignments.push(AQEAssignment {
9377
aqe,
9478
aggregation_id,
9579
query_method,
9680
estimated_query_cost_per_sec: query_rate,
9781
});
9882
}
9983

100-
OptimizerSolution {
101-
deployed_configs,
102-
assignments,
103-
estimated_ingest_cost_per_sec,
104-
estimated_total_cost_per_sec,
105-
}
84+
solution
10685
}
10786

10887
#[cfg(test)]
@@ -145,7 +124,7 @@ mod tests {
145124
);
146125

147126
let mut seen_ids: StdHashMap<u64, ()> = StdHashMap::new();
148-
for id in solution.deployed_configs.keys() {
127+
for id in solution.deployed_configs().keys() {
149128
assert!(
150129
seen_ids.insert(*id, ()).is_none(),
151130
"duplicate aggregation_id"
@@ -178,6 +157,6 @@ mod tests {
178157
&CostWeights::default(),
179158
);
180159
assert_eq!(solution.num_exact_fallback(), 1);
181-
assert!(solution.deployed_configs.is_empty());
160+
assert!(solution.deployed_configs().is_empty());
182161
}
183162
}

asap-planner-rs/src/optimizer/solution.rs

Lines changed: 90 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,14 @@ pub struct AQEAssignment {
8686
pub struct OptimizerSolution {
8787
/// Deployed streaming configs (y_g = 1 in the MIP). Keyed by aggregation_id.
8888
/// Empty for all-EXACT solutions (Phase 1 scaffolding).
89-
pub deployed_configs: HashMap<u64, AggregationConfig>,
89+
///
90+
/// Private: the only way to add an entry is `register_config`, which
91+
/// assigns the id. This keeps candidate_gen.rs's placeholder id (0) from
92+
/// ever reaching a deployed config — see ASAPQuery#564.
93+
deployed_configs: HashMap<u64, AggregationConfig>,
94+
95+
/// Next id `register_config` will hand out.
96+
next_id: u64,
9097

9198
/// One entry per deduplicated AQE across the full RQE workload.
9299
pub assignments: Vec<AQEAssignment>,
@@ -100,10 +107,38 @@ pub struct OptimizerSolution {
100107
}
101108

102109
impl OptimizerSolution {
110+
/// An empty solution with no assignments or deployed configs yet, ready
111+
/// to be built up incrementally (e.g. by a solver's assignment loop).
112+
pub fn empty() -> Self {
113+
Self {
114+
deployed_configs: HashMap::new(),
115+
next_id: 1,
116+
assignments: Vec::new(),
117+
estimated_ingest_cost_per_sec: 0.0,
118+
estimated_total_cost_per_sec: 0.0,
119+
}
120+
}
121+
122+
/// Register a candidate config as deployed: assigns it a fresh unique id
123+
/// (overwriting whatever placeholder candidate_gen.rs set), stores it,
124+
/// and returns the id. The only way to populate `deployed_configs`.
125+
pub fn register_config(&mut self, mut config: AggregationConfig) -> u64 {
126+
let id = self.next_id;
127+
self.next_id += 1;
128+
config.aggregation_id = id;
129+
self.deployed_configs.insert(id, config);
130+
id
131+
}
132+
133+
pub fn deployed_configs(&self) -> &HashMap<u64, AggregationConfig> {
134+
&self.deployed_configs
135+
}
136+
103137
/// Construct an all-EXACT solution: every AQE falls back to raw data,
104138
/// no streaming configs are deployed. Used as the Phase 1 scaffolding baseline.
105139
pub fn all_exact(aqes: Vec<AQE>) -> Self {
106-
let assignments = aqes
140+
let mut solution = Self::empty();
141+
solution.assignments = aqes
107142
.into_iter()
108143
.map(|aqe| AQEAssignment {
109144
aqe,
@@ -112,13 +147,7 @@ impl OptimizerSolution {
112147
estimated_query_cost_per_sec: 0.0,
113148
})
114149
.collect();
115-
116-
Self {
117-
deployed_configs: HashMap::new(),
118-
assignments,
119-
estimated_ingest_cost_per_sec: 0.0,
120-
estimated_total_cost_per_sec: 0.0,
121-
}
150+
solution
122151
}
123152

124153
/// Number of AQEs served by an approximate sketch (not EXACT fallback).
@@ -137,3 +166,55 @@ impl OptimizerSolution {
137166
.count()
138167
}
139168
}
169+
170+
#[cfg(test)]
171+
mod tests {
172+
use super::*;
173+
use asap_types::enums::WindowType;
174+
use promql_utilities::data_model::KeyByLabelNames;
175+
use promql_utilities::query_logics::enums::AggregationType;
176+
177+
fn candidate_config() -> AggregationConfig {
178+
// aggregation_id: 0, matching candidate_gen.rs's placeholder — the
179+
// thing register_config must always overwrite (ASAPQuery#564).
180+
AggregationConfig::new(
181+
0,
182+
AggregationType::CountMinSketch,
183+
"sum".into(),
184+
HashMap::new(),
185+
KeyByLabelNames::empty(),
186+
KeyByLabelNames::empty(),
187+
KeyByLabelNames::empty(),
188+
String::new(),
189+
60_000,
190+
60_000,
191+
WindowType::Tumbling,
192+
String::new(),
193+
"test_metric".into(),
194+
Some(1),
195+
None,
196+
None,
197+
None,
198+
)
199+
}
200+
201+
#[test]
202+
fn register_config_never_leaves_the_placeholder_id() {
203+
let mut solution = OptimizerSolution::empty();
204+
let id = solution.register_config(candidate_config());
205+
assert_ne!(
206+
id, 0,
207+
"register_config must not hand out the placeholder id"
208+
);
209+
assert_eq!(solution.deployed_configs()[&id].aggregation_id, id);
210+
}
211+
212+
#[test]
213+
fn register_config_assigns_distinct_ids() {
214+
let mut solution = OptimizerSolution::empty();
215+
let id1 = solution.register_config(candidate_config());
216+
let id2 = solution.register_config(candidate_config());
217+
assert_ne!(id1, id2);
218+
assert_eq!(solution.deployed_configs().len(), 2);
219+
}
220+
}

asap-planner-rs/src/optimizer/translator.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ pub fn translate(solution: &OptimizerSolution) -> (StreamingConfig, InferenceCon
1919

2020
fn build_streaming_config(solution: &OptimizerSolution) -> StreamingConfig {
2121
// Deployed configs map directly to AggregationConfigs — the types are the same.
22-
StreamingConfig::new(solution.deployed_configs.clone())
22+
StreamingConfig::new(solution.deployed_configs().clone())
2323
}
2424

2525
fn build_inference_config(solution: &OptimizerSolution) -> InferenceConfig {
@@ -74,7 +74,7 @@ pub struct TranslationSummary {
7474
impl TranslationSummary {
7575
pub fn from_solution(solution: &OptimizerSolution) -> Self {
7676
Self {
77-
num_deployed_configs: solution.deployed_configs.len(),
77+
num_deployed_configs: solution.deployed_configs().len(),
7878
num_sketch_assignments: solution.num_sketch_served(),
7979
num_exact_fallbacks: solution.num_exact_fallback(),
8080
}

0 commit comments

Comments
 (0)