Skip to content

Commit 6ba0023

Browse files
fix(query-engine): consistent hard-fail semantics across #586's invariant checks
Second round of review findings on #586: - The chronological-fold precondition (a bucket can't remove a key the fold doesn't yet believe present) was a debug_assert! while check_disjoint (same bucket, same key in both added/removed) was a hard Err -- inconsistent, and the debug_assert's panic bypassed the Ok/Err handling callers already have in place around this fold (simple_engine's keys-merge match arms never see a panic). Converted to an Err, matching check_disjoint. - check_disjoint now also warn!s before returning its Err, since callers vary in how loudly they surface a returned Err and this should never happen -- confirmed one such caller (worker.rs's merge_panes_for_window) was silently discarding it via .unwrap_or(existing) with zero logging; added a warn! there too. - get_keys()'s disjointness check downgraded to debug_assert! (previously warn! + None, before #586 rewrote this function) -- restored warn! + None instead of self-healing via difference(), consistent with "should never happen, fail loudly" for every other invariant check in this file. - simple_engine's keys-merge Err branch logged at debug! -- bumped to warn! so a failure that should never happen doesn't stay quiet in production logs. - Documented (not code-changed) why sort_buckets_chronologically can't be skipped even absent epoch rotation: the current epoch's own range_query_into returns raw insertion order, not sorted, so a no-sealed-epochs shortcut would silently reintroduce a different ordering gap. Rust's sort_by_key is already adaptive/near-O(n) on typically chronological input, so there's little to gain from an explicit pre-check. Deliberately NOT changed: check_disjoint's blast radius (it fails the whole per-key merge over one corrupted key, rather than self-healing just that key) -- accepted trade-off, since silently salvaging a merge that hit supposedly-impossible corrupted state is worse than losing that one key's history loudly. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 4f6751c commit 6ba0023

4 files changed

Lines changed: 78 additions & 27 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1758,7 +1758,7 @@ impl SimpleEngine {
17581758
}
17591759
},
17601760
Err(e) => {
1761-
debug!("Failed to merge keys at t={}: {}", current_time, e);
1761+
warn!("Failed to merge keys at t={}: {}", current_time, e);
17621762
Vec::new()
17631763
}
17641764
}

asap-query-engine/src/precompute_engine/worker.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1019,7 +1019,16 @@ fn merge_panes_for_window(
10191019
if let Some(acc) = pane_acc {
10201020
merged = Some(match merged {
10211021
None => acc,
1022-
Some(existing) => existing.merge_with(acc.as_ref()).unwrap_or(existing),
1022+
Some(existing) => match existing.merge_with(acc.as_ref()) {
1023+
Ok(merged) => merged,
1024+
Err(e) => {
1025+
warn!(
1026+
"Failed to merge pane at start={ps}: {e} -- keeping prior state, \
1027+
discarding this pane's contribution"
1028+
);
1029+
existing
1030+
}
1031+
},
10231032
});
10241033
}
10251034
}

asap-query-engine/src/precompute_operators/delta_set_aggregator_accumulator.rs

Lines changed: 57 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use crate::data_model::{
55
use asap_sketchlib::{message_pack_format::MessagePackCodec, DeltaResult};
66
use serde_json::Value;
77
use std::collections::{HashMap, HashSet};
8+
use tracing::warn;
89

910
use promql_utilities::query_logics::enums::Statistic;
1011

@@ -249,14 +250,18 @@ impl AggregateCore for DeltaSetAggregatorAccumulator {
249250

250251
fn get_keys(&self) -> Option<Vec<KeyByLabelValues>> {
251252
// A well-formed accumulator (raw or merged) never has the same key in
252-
// both sets — see merge_accumulators, which enforces this at every
253-
// fold step. `difference` is a defensive no-op under that invariant;
254-
// debug_assert catches it loudly if the invariant is ever violated.
255-
debug_assert!(
256-
self.added.is_disjoint(&self.removed),
257-
"DeltaSetAggregatorAccumulator invariant violated: {} key(s) present in both added and removed",
258-
self.added.intersection(&self.removed).count()
259-
);
253+
// both sets -- see merge_accumulators, which enforces this at every
254+
// fold step. This should never happen; if it does, fail loudly
255+
// (warn + None) rather than silently computing a possibly-wrong key
256+
// set via `difference`.
257+
if !self.added.is_disjoint(&self.removed) {
258+
warn!(
259+
"DeltaSetAggregatorAccumulator::get_keys invariant violated: {} key(s) \
260+
present in both added and removed -- returning None",
261+
self.added.intersection(&self.removed).count()
262+
);
263+
return None;
264+
}
260265
Some(self.added.difference(&self.removed).cloned().collect())
261266
}
262267

@@ -313,15 +318,19 @@ impl MergeableAccumulator<DeltaSetAggregatorAccumulator> for DeltaSetAggregatorA
313318
// added/removed -- a single window can't both gain and lose the
314319
// same key. This is a hard error, not a self-heal: it means the
315320
// input itself is corrupt, not just an artifact of folding order.
321+
// Warn (in addition to the Err) since callers vary in how loudly
322+
// they surface a returned Err -- this should never happen, so it
323+
// must not go unnoticed even if a caller's Err-handling is quiet.
316324
fn check_disjoint(
317325
acc: &DeltaSetAggregatorAccumulator,
318326
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
319327
if !acc.added.is_disjoint(&acc.removed) {
320-
return Err(format!(
328+
let msg = format!(
321329
"DeltaSetAggregatorAccumulator bucket has {} key(s) in both added and removed",
322330
acc.added.intersection(&acc.removed).count()
323-
)
324-
.into());
331+
);
332+
warn!("{msg}");
333+
return Err(msg.into());
325334
}
326335
Ok(())
327336
}
@@ -340,13 +349,18 @@ impl MergeableAccumulator<DeltaSetAggregatorAccumulator> for DeltaSetAggregatorA
340349
// Holds because real callers always grow this fold forward from
341350
// a true starting point (e.g. NaiveMerger only ever appends
342351
// later buckets, never merges an arbitrary mid-range fragment).
343-
debug_assert!(
344-
accumulator.removed.is_subset(&added),
345-
"DeltaSetAggregatorAccumulator merge received a bucket removing {} key(s) \
346-
not currently known present -- buckets must be chronologically ordered \
347-
and the fold must start from a valid prior state",
348-
accumulator.removed.difference(&added).count()
349-
);
352+
// Hard error (not debug_assert!): a panic here would bypass the
353+
// Ok/Err handling callers already have in place for this fold.
354+
if !accumulator.removed.is_subset(&added) {
355+
let msg = format!(
356+
"DeltaSetAggregatorAccumulator merge received a bucket removing {} key(s) \
357+
not currently known present -- buckets must be chronologically ordered \
358+
and the fold must start from a valid prior state",
359+
accumulator.removed.difference(&added).count()
360+
);
361+
warn!("{msg}");
362+
return Err(msg.into());
363+
}
350364
for key in &accumulator.removed {
351365
added.remove(key);
352366
}
@@ -494,6 +508,23 @@ mod tests {
494508
assert_eq!(keys, vec![present_key]);
495509
}
496510

511+
/// A key present in both `added` and `removed` violates the
512+
/// disjointness invariant merge_accumulators is supposed to maintain.
513+
/// This should never happen -- if it does, get_keys() must fail loudly
514+
/// (None) rather than silently computing a possibly-wrong key set.
515+
#[test]
516+
fn test_get_keys_returns_none_when_disjointness_invariant_violated() {
517+
let mut acc = DeltaSetAggregatorAccumulator::new();
518+
let key = create_test_key("corrupt");
519+
acc.add_key(key.clone());
520+
acc.remove_key(key);
521+
522+
assert!(
523+
acc.get_keys().is_none(),
524+
"get_keys must return None when a key is in both added and removed"
525+
);
526+
}
527+
497528
/// Bug #586 (#1): `merge_accumulators` must fold buckets in chronological
498529
/// order, not union all added/removed sets and strip same-key
499530
/// "conflicts". A key toggled more than twice across the merged buckets
@@ -533,12 +564,11 @@ mod tests {
533564
}
534565

535566
/// The chronological-fold invariant (a bucket can't remove a key the
536-
/// fold doesn't yet believe is present) is a `debug_assert!`, not a hard
537-
/// `Err` -- only checked in debug builds, so this test is too.
567+
/// fold doesn't yet believe is present) is a hard `Err`, not a
568+
/// `debug_assert!` -- a panic would bypass the Ok/Err handling callers
569+
/// already have in place around this fold.
538570
#[test]
539-
#[cfg(debug_assertions)]
540-
#[should_panic(expected = "not currently known present")]
541-
fn test_merge_accumulators_debug_asserts_on_removal_without_prior_add() {
571+
fn test_merge_accumulators_errors_on_removal_without_prior_add() {
542572
let key = create_test_key("phantom");
543573

544574
// First bucket is a valid, empty starting state -- it never saw `key`.
@@ -548,10 +578,12 @@ mod tests {
548578
let mut removes_unseen_key = DeltaSetAggregatorAccumulator::new();
549579
removes_unseen_key.remove_key(key);
550580

551-
let _ = DeltaSetAggregatorAccumulator::merge_accumulators(vec![
581+
let err = DeltaSetAggregatorAccumulator::merge_accumulators(vec![
552582
starting_state,
553583
removes_unseen_key,
554-
]);
584+
])
585+
.expect_err("removing a never-added key must be rejected");
586+
assert!(err.to_string().contains("not currently known present"));
555587
}
556588

557589
/// A bucket with the same key in both its own `added` and `removed` is

asap-query-engine/src/stores/simple_map_store/common.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,16 @@ pub type MetricBucketMap = HashMap<MetricID, Vec<(TimestampRange, Arc<dyn Aggreg
1313
/// sealed epochs oldest-to-newest, so the concatenated result isn't
1414
/// chronological once rotation has occurred. Callers building the final
1515
/// per-key bucket list must run this before returning it.
16+
///
17+
/// Runs unconditionally rather than only after detected rotation: the
18+
/// current epoch's own `range_query_into` (`MutableEpoch`) returns buckets
19+
/// in raw insertion order, not sorted, so an out-of-order insert can violate
20+
/// chronological order even with a single epoch that's never rotated.
21+
/// Skipping this based on "no sealed epochs" would silently reintroduce
22+
/// that gap. `sort_by_key`'s adaptive (Timsort-derived) algorithm is
23+
/// already close to O(n) on the common case of already- or
24+
/// mostly-chronological input, so there's little to gain from an explicit
25+
/// pre-check.
1626
pub fn sort_buckets_chronologically(buckets: &mut [(TimestampRange, Arc<dyn AggregateCore>)]) {
1727
buckets.sort_by_key(|(range, _)| *range);
1828
}

0 commit comments

Comments
 (0)