Split src/engine/mod.rs into handle/message/snapshot submodules

Pure code-motion refactor, no logic changes. Follows the repo convention
of one struct/enum + its impl per module file:

- engine/handle.rs: the left-right write/read plumbing (EngineOp,
  IndexWriter, IndexReadHandle, IndexReader, EngineHandle) and the actor
  loop (spawn/run) that owns EngineSnapshot.
- engine/message.rs: EngineQuery/EngineMessage/EngineResponse.
- engine/snapshot/: EngineSnapshot itself, split by concern into
  params.rs (declaration + auto-detect), metrics.rs (declare/apply/
  backfill), filter.rs (search-bar filter compilation/eval), sort.rs
  (sort-helper indexes), and query.rs (handle_query_inner + PromQL
  evaluation), with mod.rs holding the struct and the accessors shared
  across all of them.
- engine/tests.rs: the inline test module, kept as one file (per the
  task's own escape hatch) since most tests exercise ingest -> declare
  -> query across several of the new submodules at once rather than one
  in isolation.

Visibility was narrowed per call site: pub(super) where only sibling
snapshot submodules need an item, pub(crate) where engine::handle or
engine::tests (outside the snapshot subtree) also need it.

cargo build --all-targets and cargo test are green (291 passed, 1
ignored, same as before the split).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1ZJ3SrayzLzVndhVq9BmS
This commit is contained in:
Hugh Kaznowski
2026-09-06 18:36:53 +01:00
co-authored by Claude Sonnet 5
parent 3b3ace146a
commit f19fa50a25
10 changed files with 4517 additions and 4383 deletions
+549
View File
@@ -0,0 +1,549 @@
//! Answering `EngineQuery`s: log/timeline listing (including the sort-helper-index path),
//! stats, declaration listing, bucket-value listing, and PromQL evaluation.
use crate::api::dto::{
BucketValueDto, LogEntryDto, MetricDeclarationDto, ParamDeclarationDto, PromqlSeriesDto,
StatsDto, TimelineBucketDto,
};
use crate::consts::{DEFAULT_TIMELINE_BUCKET_MS, MERGE_WINDOW_MS};
use crate::engine::data_value::{DataValue, MetricPrimitiveKind};
use crate::engine::index_key::IndexKey;
use crate::engine::message::{EngineQuery, EngineResponse};
use crate::engine::metric_primitive::{MetricPrimitive, Number};
use crate::engine::promql::{AggOp, LabelMatcher, PromqlQuery};
use crate::engine::rate;
#[cfg(test)]
use crate::engine::snapshot::sort::SortHelperIndexEntry;
use crate::engine::snapshot::sort::{log_entry_params, SortHelperAccess, SortHelperKey};
use crate::engine::snapshot::EngineSnapshot;
use crate::engine::timestamp::Timestamp;
#[cfg(test)]
use std::collections::HashMap;
use std::collections::BTreeMap;
#[cfg(test)]
pub(crate) fn handle_query(
snapshot: &EngineSnapshot,
query: EngineQuery,
_approx_bytes_total: u64,
_ingest_lag_high_watermark_ms: i64,
sort_helper_indexes: &mut HashMap<String, SortHelperIndexEntry>,
) -> EngineResponse {
handle_query_inner(snapshot, query, SortHelperAccess::Mutable(sort_helper_indexes))
}
pub(super) fn handle_query_inner(
snapshot: &EngineSnapshot,
query: EngineQuery,
sort_helper_access: SortHelperAccess<'_>,
) -> EngineResponse {
let index = &snapshot.index;
let approx_bytes_total = snapshot.approx_bytes_total;
let ingest_lag_high_watermark_ms = snapshot.ingest_lag_high_watermark_ms;
match query {
EngineQuery::Logs(params) => {
let compiled_filter = match params.filter.as_deref() {
Some(filter) => match snapshot.compile_filter_query(filter, Timestamp::now()) {
Ok(compiled) => Some(compiled),
Err(err) => return EngineResponse::Error(err),
},
None => None,
};
if let Some(sort_by) = params.sort_by.as_deref() {
if !snapshot.param_declarations_range().any(|(name, ..)| name == sort_by) {
return EngineResponse::Error(format!("unknown param '{sort_by}' for sort_by"));
}
}
let limit = params.limit.unwrap_or(500).min(10_000);
let compiled_params = snapshot.compiled_param_declarations();
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)
&& params.end_ms.is_none_or(|end| timestamp.as_millis() <= end)
})
.filter(|(sequence, timestamp, text)| {
compiled_filter
.as_ref()
.is_none_or(|filter| filter.eval(*sequence, *timestamp, text))
});
let entries: Vec<LogEntryDto> = match params.sort_by.as_deref() {
None => filtered
.take(limit)
.map(|(sequence, timestamp, text)| LogEntryDto {
sequence,
timestamp_ms: timestamp.as_millis(),
text: text.to_string(),
params: log_entry_params(text, &compiled_params),
})
.collect(),
Some(sort_by) => {
static EMPTY_HELPER: std::sync::LazyLock<BTreeMap<SortHelperKey, Timestamp>> =
std::sync::LazyLock::new(BTreeMap::new);
let helper_index =
sort_helper_access.resolve(index, sort_by).unwrap_or(&EMPTY_HELPER);
let sort_missing_fallback = params.sort_missing_fallback.unwrap_or(true);
let mut entries = Vec::new();
for (helper_key, timestamp) in helper_index.iter() {
let sequence = match helper_key {
SortHelperKey::WithParam(_, sequence) => *sequence,
SortHelperKey::WithoutParam(sequence) => {
if !sort_missing_fallback {
continue;
}
*sequence
}
};
if !(params.since_sequence.is_none_or(|since| sequence > since)
&& params.start_ms.is_none_or(|start| timestamp.as_millis() >= start)
&& params.end_ms.is_none_or(|end| timestamp.as_millis() <= end))
{
continue;
}
let Some(DataValue::LogLiteral { text }) =
index.get(&IndexKey::IngestedLog { sequence, timestamp: *timestamp })
else {
continue;
};
if !compiled_filter
.as_ref()
.is_none_or(|filter| filter.eval(sequence, *timestamp, text))
{
continue;
}
entries.push(LogEntryDto {
sequence,
timestamp_ms: timestamp.as_millis(),
text: text.clone(),
params: log_entry_params(text, &compiled_params),
});
if entries.len() >= limit {
break;
}
}
entries
}
};
let entries: Vec<LogEntryDto> = entries.into_iter().take(limit).collect();
EngineResponse::Logs(entries)
}
EngineQuery::Timeline(params) => {
let compiled_filter = match params.filter.as_deref() {
Some(filter) => match snapshot.compile_filter_query(filter, Timestamp::now()) {
Ok(compiled) => Some(compiled),
Err(err) => return EngineResponse::Error(err),
},
None => None,
};
let bucket_ms = params.bucket_ms.unwrap_or(DEFAULT_TIMELINE_BUCKET_MS).max(1);
let mut buckets: BTreeMap<i64, u64> = BTreeMap::new();
for (sequence, timestamp, text) in snapshot.logs_range().filter(|(_, timestamp, _)| {
params.start_ms.is_none_or(|start| timestamp.as_millis() >= start)
&& params.end_ms.is_none_or(|end| timestamp.as_millis() <= end)
}) {
if compiled_filter
.as_ref()
.is_some_and(|filter| !filter.eval(sequence, timestamp, text))
{
continue;
}
let bucket_start = (timestamp.as_millis() / bucket_ms) * bucket_ms;
*buckets.entry(bucket_start).or_insert(0) += 1;
}
let out = buckets
.into_iter()
.map(|(start_ms, count)| TimelineBucketDto { start_ms, count })
.collect();
EngineResponse::Timeline(out)
}
EngineQuery::Stats => {
let mut log_count = 0u64;
let mut first_sequence = None;
let mut last_sequence = None;
let mut first_timestamp_ms = None;
let mut last_timestamp_ms = None;
for (sequence, timestamp, _) in snapshot.logs_range() {
log_count += 1;
first_sequence.get_or_insert(sequence);
last_sequence = Some(sequence);
first_timestamp_ms.get_or_insert(timestamp.as_millis());
last_timestamp_ms = Some(timestamp.as_millis());
}
EngineResponse::Stats(StatsDto {
log_count,
first_sequence,
last_sequence,
first_timestamp_ms,
last_timestamp_ms,
approx_bytes_total,
ingest_lag_high_watermark_ms,
})
}
EngineQuery::ParamDeclarations => {
let declarations = snapshot.param_declarations_range()
.map(|(name, message_match_regex, capture_group, json_field_path, kind, elapsed_since_last, auto_derived, _)| {
ParamDeclarationDto {
name: name.to_string(),
message_match_regex: message_match_regex.to_string(),
capture_group: capture_group.to_string(),
json_field_path: json_field_path.map(|path| path.to_string()),
kind,
elapsed_since_last,
auto_derived,
reserved: false,
}
})
.collect();
EngineResponse::ParamDeclarations(declarations)
}
EngineQuery::MetricDeclarations => {
let declarations = snapshot.metric_declarations_range()
.map(|(name, message_match_regex, label_params, value_param, matched_labels, unmatched_labels)| {
MetricDeclarationDto {
name: name.to_string(),
message_match_regex: message_match_regex.to_string(),
label_params: label_params.to_vec(),
value_param: value_param.map(|s| s.to_string()),
matched_labels: matched_labels.cloned(),
unmatched_labels: unmatched_labels.cloned(),
}
})
.collect();
EngineResponse::MetricDeclarations(declarations)
}
EngineQuery::BucketValues {
metric_name,
labels_filter,
start_ms,
end_ms,
since_sequence,
limit,
} => {
let limit = limit.unwrap_or(500).min(10_000);
let values: Vec<BucketValueDto> = snapshot.bucket_values(&metric_name, None)
.filter(|(labels, _)| {
labels_filter.as_ref().is_none_or(|filter| {
filter.iter().all(|(name, expected)| {
labels
.get(name)
.is_some_and(|value| &value.to_string() == expected)
})
})
})
.filter(|(_, (sequence, timestamp, _))| {
since_sequence.is_none_or(|since| *sequence > since)
&& start_ms.is_none_or(|start| timestamp.as_millis() >= start)
&& end_ms.is_none_or(|end| timestamp.as_millis() <= end)
})
.take(limit)
.map(|(labels, (sequence, timestamp, value))| BucketValueDto {
labels: labels.clone(),
sequence: *sequence,
timestamp_ms: timestamp.as_millis(),
value: value.clone(),
})
.collect();
EngineResponse::BucketValues(values)
}
EngineQuery::Promql { query, start_ms, end_ms } => {
let response = PromqlQuery::parse(&query)
.and_then(|parsed| snapshot.evaluate_promql(&parsed, start_ms, end_ms));
EngineResponse::Promql(response)
}
}
}
fn coerce_to_f64(value: &MetricPrimitive) -> f64 {
match value {
MetricPrimitive::Number(n) => match n {
Number::Int(v) => *v as f64,
Number::Float(v) => *v,
},
MetricPrimitive::Timestamp(ts) => ts.as_millis() as f64,
MetricPrimitive::Duration(ms) => *ms as f64,
MetricPrimitive::String(_) => 1.0,
MetricPrimitive::Boolean(b) => {
if *b {
1.0
} else {
0.0
}
}
}
}
fn matches_label_matchers(
labels: &BTreeMap<String, MetricPrimitive>,
matchers: &[LabelMatcher],
) -> bool {
matchers.iter().all(|matcher| {
labels.get(&matcher.name).is_some_and(|value| value.to_string() == matcher.value)
})
}
fn project_labels(
labels: &BTreeMap<String, String>,
by_labels: &[String],
) -> BTreeMap<String, String> {
by_labels
.iter()
.filter_map(|name| labels.get(name).map(|value| (name.clone(), value.clone())))
.collect()
}
fn windowed_rate_series(
points: &[(i64, f64)],
range_ms: i64,
start_ms: Option<i64>,
end_ms: Option<i64>,
) -> Vec<(i64, f64)> {
let ts_points: Vec<(Timestamp, f64)> =
points.iter().map(|(ms, value)| (Timestamp::from_millis(*ms), *value)).collect();
let mut series: Vec<(i64, f64)> = rate::windowed_rate_series(&ts_points, range_ms)
.into_iter()
.map(|(ts, value)| (ts.as_millis(), value))
.collect();
if let Some(end) = end_ms {
if series.last().is_none_or(|(ts, _)| *ts < end) {
let value = rate::rate_at(&ts_points, Timestamp::from_millis(end), range_ms);
series.push((end, value));
}
}
if let Some(start) = start_ms {
if series.first().is_none_or(|(ts, _)| *ts > start) {
let value = rate::rate_at(&ts_points, Timestamp::from_millis(start), range_ms);
series.insert(0, (start, value));
}
}
series
}
fn merge_series_windowed(sub_series: &[Vec<(i64, f64)>], is_avg: bool) -> Vec<(i64, f64)> {
if sub_series.len() <= 1 {
return sub_series.first().cloned().unwrap_or_default();
}
let mut grouped: BTreeMap<i64, Vec<f64>> = BTreeMap::new();
for points in sub_series {
for (timestamp_ms, value) in points {
let window_start = timestamp_ms.div_euclid(MERGE_WINDOW_MS) * MERGE_WINDOW_MS;
grouped.entry(window_start).or_default().push(*value);
}
}
grouped
.into_iter()
.map(|(timestamp_ms, values)| {
let sum: f64 = values.iter().sum();
let value = if is_avg { sum / values.len() as f64 } else { sum };
(timestamp_ms, value)
})
.collect()
}
fn merge_series_locf(sub_series: &[Vec<(i64, f64)>], is_avg: bool) -> Vec<(i64, f64)> {
let mut all_timestamps: std::collections::BTreeSet<i64> = std::collections::BTreeSet::new();
for points in sub_series {
all_timestamps.extend(points.iter().map(|(ts, _)| *ts));
}
let mut cursors = vec![0usize; sub_series.len()];
let mut last_values = vec![0.0f64; sub_series.len()];
all_timestamps
.into_iter()
.map(|ts| {
for (i, points) in sub_series.iter().enumerate() {
while cursors[i] < points.len() && points[cursors[i]].0 <= ts {
last_values[i] = points[cursors[i]].1;
cursors[i] += 1;
}
}
let sum: f64 = last_values.iter().sum();
let value = if is_avg { sum / last_values.len() as f64 } else { sum };
(ts, value)
})
.collect()
}
fn ottyl_reserved_numeric_value_fn(metric_name: &str) -> Option<fn(u64, i64) -> f64> {
match metric_name {
"ottyl_seq" => Some(|sequence, _timestamp_ms| sequence as f64),
"ottyl_ts" => Some(|_sequence, timestamp_ms| timestamp_ms as f64),
_ => None,
}
}
impl EngineSnapshot {
pub(crate) fn evaluate_promql(
&self,
parsed: &PromqlQuery,
start_ms: Option<i64>,
end_ms: Option<i64>,
) -> Result<Vec<PromqlSeriesDto>, String> {
if parsed.metric_name == "ottyl_fmt" {
return Err(
"'ottyl_fmt' is a categorical value (json/logfmt/none), not a number, so it can't be \
used in a dashboard PromQL query; filter on it in the search bar instead, e.g. \
ottyl_fmt==json"
.to_string(),
);
}
if ottyl_reserved_numeric_value_fn(&parsed.metric_name).is_none()
&& !self.index.contains_key(&IndexKey::MetricDeclaration { name: parsed.metric_name.clone() })
&& self.bucket_values(&parsed.metric_name, None).next().is_none()
{
return Err(format!(
"unknown metric \"{}\": no declaration or recorded data for it (was it deleted?)",
parsed.metric_name
));
}
if matches!(parsed.agg, Some(AggOp::Sum) | Some(AggOp::Avg))
&& !parsed.inner_rate
&& self.declared_value_kind(&parsed.metric_name) == Some(MetricPrimitiveKind::String)
{
return Err(format!(
"cannot sum/avg a string-typed metric \"{}\"; use count() or a numeric-typed metric",
parsed.metric_name
));
}
let mut series_points: BTreeMap<BTreeMap<String, String>, Vec<(i64, f64)>> = BTreeMap::new();
if let Some(reserved_value) = ottyl_reserved_numeric_value_fn(&parsed.metric_name) {
let no_labels: BTreeMap<String, MetricPrimitive> = BTreeMap::new();
if matches_label_matchers(&no_labels, &parsed.matchers) {
for (sequence, timestamp, _text) in self.logs_range() {
let timestamp_ms = timestamp.as_millis();
if start_ms.is_none_or(|start| timestamp_ms >= start)
&& end_ms.is_none_or(|end| timestamp_ms <= end)
{
series_points
.entry(BTreeMap::new())
.or_default()
.push((timestamp_ms, reserved_value(sequence, timestamp_ms)));
}
}
}
} else {
for (labels, (_, timestamp, value)) in self.bucket_values(&parsed.metric_name, None)
.filter(|(labels, _)| matches_label_matchers(labels, &parsed.matchers))
.filter(|(_, (_, timestamp, _))| {
start_ms.is_none_or(|start| timestamp.as_millis() >= start)
&& end_ms.is_none_or(|end| timestamp.as_millis() <= end)
})
{
let display_labels: BTreeMap<String, String> =
labels.iter().map(|(name, value)| (name.clone(), value.to_string())).collect();
series_points
.entry(display_labels)
.or_default()
.push((timestamp.as_millis(), coerce_to_f64(value)));
}
}
for points in series_points.values_mut() {
points.sort_by_key(|(timestamp_ms, _)| *timestamp_ms);
}
Ok(match parsed.agg {
None => series_points
.into_iter()
.map(|(labels, points)| PromqlSeriesDto { labels, points })
.collect(),
Some(AggOp::Count) => {
let mut groups: BTreeMap<BTreeMap<String, String>, Vec<Vec<(i64, f64)>>> = BTreeMap::new();
for (labels, points) in series_points {
let count_points: Vec<(i64, f64)> =
points.iter().map(|(timestamp_ms, _)| (*timestamp_ms, 1.0)).collect();
let group_key = project_labels(&labels, &parsed.by_labels);
groups.entry(group_key).or_default().push(count_points);
}
groups
.into_iter()
.map(|(group_labels, sub_series)| {
let points = merge_series_windowed(&sub_series, false);
PromqlSeriesDto { labels: group_labels, points }
})
.collect()
}
Some(AggOp::Sum) | Some(AggOp::Avg) => {
let is_avg = parsed.agg == Some(AggOp::Avg);
let rate_range_ms = parsed
.inner_rate
.then(|| parsed.range.expect("inner rate() always carries a range").as_millis() as i64);
let mut groups: BTreeMap<BTreeMap<String, String>, Vec<Vec<(i64, f64)>>> = BTreeMap::new();
for (labels, points) in series_points {
let points = match rate_range_ms {
Some(range_ms) => windowed_rate_series(&points, range_ms, start_ms, end_ms),
None => points,
};
let group_key = project_labels(&labels, &parsed.by_labels);
groups.entry(group_key).or_default().push(points);
}
groups
.into_iter()
.map(|(group_labels, sub_series)| {
let points = if rate_range_ms.is_some() {
merge_series_locf(&sub_series, is_avg)
} else {
merge_series_windowed(&sub_series, is_avg)
};
PromqlSeriesDto { labels: group_labels, points }
})
.collect()
}
Some(AggOp::Rate) => {
let range_ms = parsed.range.expect("rate() always carries a range").as_millis() as i64;
series_points
.into_iter()
.map(|(labels, points)| PromqlSeriesDto {
labels,
points: windowed_rate_series(&points, range_ms, start_ms, end_ms),
})
.collect()
}
Some(AggOp::Gauge) => {
series_points
.into_iter()
.filter_map(|(labels, points)| {
let last = *points.last()?;
Some(PromqlSeriesDto { labels, points: vec![last] })
})
.collect()
}
Some(AggOp::Histogram) => {
let bucket_width =
parsed.bucket_width.expect("histogram() always carries a bucket width");
let last_ts = series_points
.values()
.flat_map(|points| points.iter().map(|(ts, _)| *ts))
.max()
.unwrap_or(0);
let mut groups: BTreeMap<BTreeMap<String, String>, Vec<&(i64, f64)>> = BTreeMap::new();
for (labels, points) in &series_points {
let group_key = project_labels(labels, &parsed.by_labels);
groups.entry(group_key).or_default().extend(points.iter());
}
groups
.into_iter()
.flat_map(|(group_labels, points)| {
let mut counts: BTreeMap<i64, u64> = BTreeMap::new();
for (_, value) in points {
let bucket_index = (value / bucket_width).floor() as i64;
*counts.entry(bucket_index).or_insert(0) += 1;
}
counts.into_iter().map(move |(bucket_index, count)| {
let upper_bound = (bucket_index + 1) as f64 * bucket_width;
let mut labels = group_labels.clone();
labels.insert("le".to_string(), upper_bound.to_string());
PromqlSeriesDto { labels, points: vec![(last_ts, count as f64)] }
})
})
.collect()
}
})
}
}