Seek log/metric-bucket queries by key range instead of scanning

Log reads (logs_since, the unsorted Logs query path) now seek the
IngestedLog range directly from since_sequence/before_sequence instead
of scanning from IndexKey::Start and filtering every prior entry out
in a closure. Metric-comparison filters and bucket_values now seek to
a single metric's Bucket key range instead of scanning every declared
metric's buckets. evaluate_promql sits on top of bucket_values, so its
per-poll dashboard evaluation is narrowed by the same fix without
further changes; caching prior evaluations across polls is left for a
follow-up if profiling ever shows it's needed (per ticket 081's own
scope, not built speculatively).

Resolves tickets 077, 078, 081.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TeyhRfMYjMditek6geDRoQ
This commit is contained in:
Hugh Kaznowski
2026-09-06 20:51:24 +01:00
co-authored by Claude Sonnet 5
parent 65330d37e0
commit 946fd50d53
8 changed files with 128 additions and 195 deletions
+11 -1
View File
@@ -298,8 +298,18 @@ impl EngineSnapshot {
labels_filter: Option<&'a BTreeMap<String, MetricPrimitive>>,
) -> impl Iterator<Item = (&'a BTreeMap<String, MetricPrimitive>, &'a (u64, Timestamp, MetricPrimitive))> + 'a
{
// IndexKey::Bucket sorts by (metric_name, labels), so bound the range to just this
// metric's buckets instead of ranging MetricBucketSplit..End over every declared
// metric. '\u{10FFFF}' is the maximum Unicode scalar value, so `metric_name` followed
// by it sorts after every real key for this metric (any labels) and before any key
// for a lexicographically greater metric name.
let lower = IndexKey::Bucket { metric_name: metric_name.to_string(), labels: BTreeMap::new() };
let upper = IndexKey::Bucket {
metric_name: format!("{metric_name}\u{10FFFF}"),
labels: BTreeMap::new(),
};
self.index
.range(IndexKey::MetricBucketSplit..IndexKey::End)
.range(lower..upper)
.filter_map(move |(key, value)| match (key, value) {
(IndexKey::Bucket { metric_name: name, labels }, DataValue::BucketValue { values })
if name == metric_name && labels_filter.is_none_or(|filter| filter == labels) =>
+36 -10
View File
@@ -157,13 +157,41 @@ impl EngineSnapshot {
}
pub(crate) fn logs_range(&self) -> impl Iterator<Item = (u64, Timestamp, &str)> {
self.index.range(IndexKey::Start..IndexKey::LogParamSplit).filter_map(|(key, value)| {
match (key, value) {
(IndexKey::IngestedLog { sequence, timestamp }, DataValue::LogLiteral { text }) => {
Some((*sequence, *timestamp, text.as_str()))
}
_ => None,
self.logs_in_sequence_range(..)
}
/// Seeks directly to `sequence_range` within the `IngestedLog` key range instead of
/// scanning from `IndexKey::Start` and filtering after the fact. `IngestedLog` sorts by
/// `(sequence, timestamp)`, and `sequence` is assigned monotonically at ingest
/// (`handle.rs`), so a bound on `sequence` alone is an exact seek — `Timestamp::MIN` is
/// used as the synthetic sub-key since it sorts below any real timestamp for that sequence.
pub(crate) fn logs_in_sequence_range(
&self,
sequence_range: impl std::ops::RangeBounds<u64>,
) -> impl Iterator<Item = (u64, Timestamp, &str)> {
let lower = match sequence_range.start_bound() {
std::ops::Bound::Included(&sequence) => {
IndexKey::IngestedLog { sequence, timestamp: Timestamp::MIN }
}
std::ops::Bound::Excluded(&sequence) => {
IndexKey::IngestedLog { sequence: sequence.saturating_add(1), timestamp: Timestamp::MIN }
}
std::ops::Bound::Unbounded => IndexKey::Start,
};
let upper = match sequence_range.end_bound() {
std::ops::Bound::Included(&sequence) => {
IndexKey::IngestedLog { sequence: sequence.saturating_add(1), timestamp: Timestamp::MIN }
}
std::ops::Bound::Excluded(&sequence) => {
IndexKey::IngestedLog { sequence, timestamp: Timestamp::MIN }
}
std::ops::Bound::Unbounded => IndexKey::LogParamSplit,
};
self.index.range(lower..upper).filter_map(|(key, value)| match (key, value) {
(IndexKey::IngestedLog { sequence, timestamp }, DataValue::LogLiteral { text }) => {
Some((*sequence, *timestamp, text.as_str()))
}
_ => None,
})
}
@@ -173,10 +201,8 @@ impl EngineSnapshot {
since_ms: i64,
now_ms: i64,
) -> impl Iterator<Item = (u64, Timestamp, &str)> {
self.logs_range().filter(move |(sequence, timestamp, _)| {
*sequence < before_sequence
&& timestamp.as_millis() > since_ms
&& timestamp.as_millis() <= now_ms
self.logs_in_sequence_range(..before_sequence).filter(move |(_, timestamp, _)| {
timestamp.as_millis() > since_ms && timestamp.as_millis() <= now_ms
})
}
}
+11 -4
View File
@@ -56,11 +56,14 @@ pub(super) fn handle_query_inner(
}
let limit = params.limit.unwrap_or(500).min(10_000);
let compiled_params = snapshot.compiled_param_declarations();
let since_bound = match params.since_sequence {
Some(since) => std::ops::Bound::Excluded(since),
None => std::ops::Bound::Unbounded,
};
let filtered = snapshot
.logs_range()
.filter(|(sequence, timestamp, _)| {
params.since_sequence.is_none_or(|since| *sequence > since)
&& params.start_ms.is_none_or(|start| timestamp.as_millis() >= start)
.logs_in_sequence_range((since_bound, std::ops::Bound::Unbounded))
.filter(|(_, timestamp, _)| {
params.start_ms.is_none_or(|start| timestamp.as_millis() >= start)
&& params.end_ms.is_none_or(|end| timestamp.as_millis() <= end)
})
.filter(|(sequence, timestamp, text)| {
@@ -80,6 +83,10 @@ pub(super) fn handle_query_inner(
})
.collect(),
Some(sort_by) => {
// Not range-bounded by since_sequence/start_ms: SortHelperKey::WithParam
// sorts by the param's value first (that's the point — it's what gives
// sort-by-param order), so sequence/time bounds can't narrow a BTreeMap
// range seek here the way they do for the unsorted path above.
static EMPTY_HELPER: std::sync::LazyLock<BTreeMap<SortHelperKey, Timestamp>> =
std::sync::LazyLock::new(BTreeMap::new);
let helper_index =
+65
View File
@@ -381,6 +381,34 @@ use std::collections::{BTreeMap, HashMap, HashSet};
assert_eq!(index.approx_bytes_total, expected_bytes);
}
#[test]
fn logs_in_sequence_range_seeks_instead_of_scanning_from_start() {
let mut index = EngineSnapshot::default();
for sequence in 0..1000u64 {
index.index.insert(
IndexKey::IngestedLog { sequence, timestamp: Timestamp::from_millis(sequence as i64) },
DataValue::LogLiteral { text: format!("line {sequence}") },
);
}
// An excluded lower bound (as `since_sequence` polling uses) must start exactly past
// the bound, not include it or anything before it.
let since_990: Vec<_> =
index.logs_in_sequence_range(990..).map(|(seq, _, _)| seq).collect();
assert_eq!(since_990, vec![990, 991, 992, 993, 994, 995, 996, 997, 998, 999]);
let after_990: Vec<_> = index
.logs_in_sequence_range((std::ops::Bound::Excluded(990u64), std::ops::Bound::Unbounded))
.map(|(seq, _, _)| seq)
.collect();
assert_eq!(after_990, vec![991, 992, 993, 994, 995, 996, 997, 998, 999]);
// An exclusive upper bound (as `logs_since`'s `before_sequence` uses) must stop
// strictly before it.
let before_5: Vec<_> = index.logs_in_sequence_range(..5u64).map(|(seq, _, _)| seq).collect();
assert_eq!(before_5, vec![0, 1, 2, 3, 4]);
}
#[test]
fn evict_excess_logs_is_noop_when_unlimited_or_under_cap() {
let mut index = EngineSnapshot::default();
@@ -1381,6 +1409,43 @@ use std::collections::{BTreeMap, HashMap, HashSet};
assert_eq!(*value, MetricPrimitive::Number(Number::Int(5)));
}
#[test]
fn bucket_values_seeks_by_metric_name_and_ignores_lexicographic_neighbors() {
let mut index = EngineSnapshot::default();
declare_test_param(
&mut index,
"value",
r"value=(?P<value>\d+)",
"value",
MetricPrimitiveKind::Number,
);
// "aaa" and "aab" are adjacent lexicographically, so this exercises the exact
// boundary the metric-name seek relies on: each metric's own message_match_regex
// gates it to only its own line, so if the seek pulled in the wrong metric's bucket
// range, the value asserted below would be wrong.
index
.declare_metric(metric_request_with_value("aaa", r"^aaa value=\d+$", vec![], Some("value")))
.unwrap();
index
.declare_metric(metric_request_with_value("aab", r"^aab value=\d+$", vec![], Some("value")))
.unwrap();
ingest_log(&mut index, 0, "aaa value=1");
ingest_log(&mut index, 1, "aab value=2");
let aaa_values: Vec<_> = index
.bucket_values("aaa", None)
.map(|(_, (_, _, value))| value.clone())
.collect();
assert_eq!(aaa_values, vec![MetricPrimitive::Number(Number::Int(1))]);
let aab_values: Vec<_> = index
.bucket_values("aab", None)
.map(|(_, (_, _, value))| value.clone())
.collect();
assert_eq!(aab_values, vec![MetricPrimitive::Number(Number::Int(2))]);
}
fn declare_durations_metric(index: &mut EngineSnapshot) {
let pattern = r"request path=(?P<path>\S+) duration=(?P<duration>\d+)";
declare_test_param(index, "path", pattern, "path", MetricPrimitiveKind::String);
+5
View File
@@ -5,6 +5,11 @@ use std::time::{SystemTime, UNIX_EPOCH};
pub struct Timestamp(i64);
impl Timestamp {
/// The smallest representable timestamp — used as a synthetic sub-key when seeking
/// `IndexKey::IngestedLog { sequence, timestamp }` by `sequence` alone, since it sorts
/// below any real timestamp recorded for that sequence.
pub const MIN: Timestamp = Timestamp(i64::MIN);
pub fn now() -> Self {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)