Skip to content

Commit 1fc18be

Browse files
feat(query-engine): computed top-k in range queries + stage-E equivalence tests (#581 stage E prep) (#629)
* feat(query-engine): computed top-k in range queries + stage-E equivalence tests (#581 stage E prep) Adds step-major topk ranking/truncation to execute_range_query_pipeline (previously range had no top-k support at all), wires it through PromQL's range call sites, and adds an instant/range equivalence test matrix across Tumbling/Sliding window shapes and SetAgg/DeltaSetAgg keys configs, ahead of stage E's full pipeline collapse. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(query-engine): drop dead-code retain in apply_range_topk (roborev #23) kept_timestamps_by_key's entries are only ever inserted alongside a timestamp drawn from that same element's own samples, so the per-sample filter can never leave a surviving key's samples empty -- the trailing retain was unreachable dead code. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * test(query-engine): pin topk binary-expr label bugs found via PR #629 Finding 1 topk(...) as one arm of a binary expression is broken two different ways, neither of which is what Finding 1's review comment described nor fixable as part of #629 -- both are pre-existing/orthogonal and tracked in #631 instead. One test pins the current (surprising) None-return behavior; the other reproduces the join-corruption bug Finding 1 actually describes, and is #[ignore]d since it isn't fixed here. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(query-engine): deterministic range topk tie-break, fewer label clones Addresses PR #629 review findings 2-4 (mod.rs::apply_range_topk): - Finding 2: candidates.sort_by was value-only with no tiebreak, so groups tied at the k-th value boundary kept a different survivor run to run (HashMap iteration order is randomized per-process). Confirmed via a flaky RED test (4/5 pass rate) before adding a label-values tiebreak; stable across 20+ runs after. - Finding 3: folded into the same restructure -- index each group once instead of cloning its label vector per (group, timestamp) sample (G clones instead of G*T). - Finding 4: documented why range has no observable (false, true) case for enable_topk_limiting/enable_topk_formatting, unlike instant's always-sort-when-Topk behavior. Finding 1 is not addressed here -- tracked separately in #631. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 09ff227 commit 1fc18be

5 files changed

Lines changed: 1122 additions & 8 deletions

File tree

asap-query-engine/src/engines/simple_engine/mod.rs

Lines changed: 149 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1500,10 +1500,20 @@ impl SimpleEngine {
15001500
}
15011501
}
15021502

1503-
/// Execute the range query pipeline
1503+
/// Execute the range query pipeline.
1504+
///
1505+
/// `enable_topk_limiting`/`enable_topk_formatting` mirror
1506+
/// `execute_query_pipeline`'s flags of the same name (see that method's
1507+
/// doc comment) -- both no-ops unless
1508+
/// `context.base.metadata.statistic_to_compute == Statistic::Topk`. The
1509+
/// actual ranking/truncation is delegated to `apply_range_topk` below;
1510+
/// see its doc comment for why range's version can't just reuse
1511+
/// instant's `format_final_results` truncate-once shape.
15041512
fn execute_range_query_pipeline(
15051513
&self,
15061514
context: &RangeQueryExecutionContext,
1515+
enable_topk_limiting: bool,
1516+
enable_topk_formatting: bool,
15071517
) -> Result<Vec<crate::engines::query_result::RangeVectorElement>, String> {
15081518
use crate::engines::query_result::RangeVectorElement;
15091519
use crate::engines::window_merger::create_window_merger;
@@ -1829,8 +1839,144 @@ impl SimpleEngine {
18291839
}
18301840
}
18311841

1832-
// Convert to Vec
1833-
Ok(results.into_values().collect())
1842+
Ok(self.apply_range_topk(
1843+
results,
1844+
&context.base.metadata.statistic_to_compute,
1845+
&context.base.metadata.query_kwargs,
1846+
&context.base.metric,
1847+
enable_topk_formatting,
1848+
enable_topk_limiting,
1849+
))
1850+
}
1851+
1852+
/// Applies PromQL top-k semantics to a range query's raw per-group
1853+
/// results. No-op unless `statistic == Statistic::Topk` (mirrors
1854+
/// `format_final_results`).
1855+
///
1856+
/// This is deliberately NOT a straight port of `format_final_results`
1857+
/// (sort all groups once by value, then truncate to k): that shape only
1858+
/// works because instant queries have exactly one value per group. A
1859+
/// range query's `RangeVectorElement` carries many per-timestamp
1860+
/// samples, and real PromQL `topk(k, range_vector)` semantics rank
1861+
/// independently AT EACH timestamp -- the surviving key set can differ
1862+
/// from step to step. So this ranks/truncates per-timestamp
1863+
/// ("step-major"), across all groups, as its own pass over the
1864+
/// already-assembled results -- rather than restructuring the group-major
1865+
/// fetch/merge loop above into a step-major shape. Issue #581's own
1866+
/// scoping decided the fetch/merge loop itself becomes step-major only
1867+
/// as part of stage E, the full instant/range pipeline collapse (not
1868+
/// done here, deliberately -- this is stage-E prep). Doing the ranking
1869+
/// as a separate pass gets the same correctness (each timestamp's kept
1870+
/// set is decided across all groups, never one group at a time) without
1871+
/// front-running that larger, separately-staged restructure.
1872+
/// `enable_topk_limiting` and `enable_topk_formatting` are independent
1873+
/// flags, mirroring instant's `execute_query_pipeline` contract -- but
1874+
/// unlike instant, range has no `(false, true)`-observable case. Instant
1875+
/// always sorts Topk results when formatting regardless of limiting,
1876+
/// because it returns a flat `Vec` where sort order is part of the
1877+
/// output. Range returns a `HashMap` (this function's `results`) whose
1878+
/// iteration order was never meaningful, and each surviving group carries
1879+
/// many per-timestamp samples rather than one value to sort the outer
1880+
/// collection by -- so skipping the ranking block when
1881+
/// `enable_topk_limiting` is false has no observable effect here beyond
1882+
/// formatting, even though every current call site passes both flags
1883+
/// together and never actually exercises `(false, true)`.
1884+
fn apply_range_topk(
1885+
&self,
1886+
mut results: HashMap<KeyByLabelValues, crate::engines::query_result::RangeVectorElement>,
1887+
statistic: &Statistic,
1888+
query_kwargs: &HashMap<String, String>,
1889+
metric: &str,
1890+
enable_topk_formatting: bool,
1891+
enable_topk_limiting: bool,
1892+
) -> Vec<crate::engines::query_result::RangeVectorElement> {
1893+
if *statistic != Statistic::Topk {
1894+
return results.into_values().collect();
1895+
}
1896+
1897+
// Limiting MUST run before formatting: it matches
1898+
// `kept_timestamps_by_key`'s keys (read from each element's
1899+
// `labels` field) against `results`' own HashMap keys via
1900+
// `retain`. Formatting rewrites `elem.labels` (the field) without
1901+
// touching the HashMap's outer key, so if formatting ran first the
1902+
// two would no longer agree and `retain` would drop every group.
1903+
if enable_topk_limiting {
1904+
if let Some(k) = query_kwargs.get("k").and_then(|s| s.parse::<usize>().ok()) {
1905+
use std::collections::HashSet;
1906+
1907+
// Index each group once (G clones total) instead of cloning
1908+
// its label vector per (group, timestamp) sample -- G*T
1909+
// clones otherwise, for G groups over T steps.
1910+
let index_keys: Vec<KeyByLabelValues> = results.keys().cloned().collect();
1911+
let key_to_idx: HashMap<KeyByLabelValues, usize> = index_keys
1912+
.iter()
1913+
.cloned()
1914+
.enumerate()
1915+
.map(|(i, key)| (key, i))
1916+
.collect();
1917+
1918+
// Step-major ranking: group every group's samples by
1919+
// timestamp first, so each timestamp's top-k decision sees
1920+
// every group's value at that timestamp.
1921+
let mut by_timestamp: HashMap<u64, Vec<(usize, f64)>> = HashMap::new();
1922+
for elem in results.values() {
1923+
let idx = key_to_idx[&elem.labels];
1924+
for sample in &elem.samples {
1925+
by_timestamp
1926+
.entry(sample.timestamp)
1927+
.or_default()
1928+
.push((idx, sample.value));
1929+
}
1930+
}
1931+
1932+
let mut kept_timestamps_by_idx: HashMap<usize, HashSet<u64>> = HashMap::new();
1933+
for (timestamp, mut candidates) in by_timestamp {
1934+
// Tiebreak on label values: `candidates`'s order comes
1935+
// from iterating `results`, a HashMap, whose iteration
1936+
// order is randomized per-process -- without this,
1937+
// groups tied at the k-th value boundary would keep
1938+
// different survivors run to run.
1939+
candidates.sort_by(|a, b| {
1940+
b.1.partial_cmp(&a.1)
1941+
.unwrap_or(std::cmp::Ordering::Equal)
1942+
.then_with(|| index_keys[a.0].labels.cmp(&index_keys[b.0].labels))
1943+
});
1944+
candidates.truncate(k);
1945+
for (idx, _) in candidates {
1946+
kept_timestamps_by_idx
1947+
.entry(idx)
1948+
.or_default()
1949+
.insert(timestamp);
1950+
}
1951+
}
1952+
1953+
results.retain(|key, _| kept_timestamps_by_idx.contains_key(&key_to_idx[key]));
1954+
for elem in results.values_mut() {
1955+
let idx = key_to_idx[&elem.labels];
1956+
// `keep` is built only from timestamps that already
1957+
// appear in this same element's `samples` (see the
1958+
// `by_timestamp` loop above), and is non-empty for every
1959+
// key that survives the `retain` just above -- so this
1960+
// filter can never leave `elem.samples` empty.
1961+
let keep = &kept_timestamps_by_idx[&idx];
1962+
elem.samples.retain(|s| keep.contains(&s.timestamp));
1963+
}
1964+
}
1965+
}
1966+
1967+
if enable_topk_formatting {
1968+
// Prepend metric name to each key's label values (PromQL shape),
1969+
// same rewrite as format_final_results does for instant. Safe to
1970+
// mutate `elem.labels` now -- nothing below matches it back
1971+
// against the HashMap's outer key.
1972+
for elem in results.values_mut() {
1973+
let mut new_labels = vec![metric.to_string()];
1974+
new_labels.extend(elem.labels.labels.clone());
1975+
elem.labels.labels = new_labels;
1976+
}
1977+
}
1978+
1979+
results.into_values().collect()
18341980
}
18351981
}
18361982

asap-query-engine/src/engines/simple_engine/promql.rs

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -717,7 +717,10 @@ impl SimpleEngine {
717717

718718
if let Some((scalar, vector_arm, scalar_on_left)) = detect_scalar_arm(lhs, rhs) {
719719
let (ctx, labels) = self.build_arm_range_context(vector_arm, start, end, step)?;
720-
let results = self.execute_range_query_pipeline(&ctx).ok()?;
720+
// (true, true): self-gated, same as instant's binary-arm call
721+
// (evaluate_binary_arm) -- both flags are no-ops unless the arm's
722+
// statistic is Topk.
723+
let results = self.execute_range_query_pipeline(&ctx, true, true).ok()?;
721724
let combined: Vec<RangeVectorElement> = results
722725
.into_iter()
723726
.map(|mut elem| {
@@ -745,8 +748,13 @@ impl SimpleEngine {
745748
if lhs_labels != rhs_labels {
746749
return None;
747750
}
748-
let lhs_results = self.execute_range_query_pipeline(&lhs_ctx).ok()?;
749-
let rhs_results = self.execute_range_query_pipeline(&rhs_ctx).ok()?;
751+
// (true, true): self-gated, same rationale as the scalar-arm call above.
752+
let lhs_results = self
753+
.execute_range_query_pipeline(&lhs_ctx, true, true)
754+
.ok()?;
755+
let rhs_results = self
756+
.execute_range_query_pipeline(&rhs_ctx, true, true)
757+
.ok()?;
750758

751759
// Build lookup: label_key -> {timestamp -> value} for rhs
752760
let mut rhs_map: HashMap<KeyByLabelValues, HashMap<u64, f64>> = HashMap::new();
@@ -1307,9 +1315,11 @@ impl SimpleEngine {
13071315
let context =
13081316
self.build_range_query_execution_context_from_parsed(&ast, &query, start, end, step)?;
13091317

1310-
// Execute range query pipeline
1318+
// Execute range query pipeline. (true, true): self-gated, same as
1319+
// instant's handle_query_promql -- both flags are no-ops unless this
1320+
// query's statistic is Topk.
13111321
let results: Vec<RangeVectorElement> = self
1312-
.execute_range_query_pipeline(&context)
1322+
.execute_range_query_pipeline(&context, true, true)
13131323
.map_err(|e| {
13141324
warn!("Range query execution failed: {}", e);
13151325
e

asap-query-engine/src/tests/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ pub mod prometheus_forwarding_tests;
1212
pub mod query_equivalence_tests;
1313
pub mod range_query_arithmetic_tests;
1414
pub mod sql_pattern_matching_tests;
15+
pub mod stage_e_instant_range_equivalence_tests;
1516
pub mod store_correctness_tests;
1617
pub mod structural_matching_tests;
1718
pub mod trait_design_tests;

0 commit comments

Comments
 (0)