Extract the trailing-window rate/count implementation into a new src/rate.rs, shared by PromQL's rate() (engine::evaluate_promql, via a thin i64<->Timestamp adapter) and alert Threshold/Ratio conditions (alerts::evaluate_condition, via the new incremental RateWindow), replacing alerts' bespoke VecDeque+prune loop. Both now agree on what "windowed rate" means for the same data, verified by a new regression test (engine::tests::dashboard_rate_and_alert_threshold_agree_on_same_windowed_data). Behavior change: alert Threshold/Ratio windows are now backfilled once, on first evaluation, from already-ingested logs within their own trailing window (bounded cost, not a full history rescan) - a rule created mid-session, or a process that replays existing log content (e.g. --file tailing, which rereads the whole file from the start on every process start), no longer starts with a silently empty window. A bare restart with no replayed history (e.g. stdin ingestion) still starts empty, since there is genuinely no log history to backfill from in that case - alert state itself remains in-memory only. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
144 lines
6.1 KiB
Rust
144 lines
6.1 KiB
Rust
//! Ticket 040: the single windowed-rate/count implementation in the codebase, shared by
|
|
//! PromQL's `rate()` (`crate::engine::evaluate_promql`, via the `windowed_rate_series` adapter
|
|
//! there) and alert `Threshold`/`Ratio` conditions (`crate::alerts`). Previously each computed
|
|
//! its own trailing window independently — PromQL scanning indexed bucket data, alerts scanning
|
|
//! raw log-line matches with a hand-rolled `VecDeque` — and could disagree for equivalent-looking
|
|
//! rules simply because the window boundary was implemented twice.
|
|
//!
|
|
//! `count_in_window` is the one place that decides what "falls in the trailing window" means;
|
|
//! everything else in this module is built on top of it, either by batch-rescanning a full point
|
|
//! series (`rate_at`/`windowed_rate_series`, fine for PromQL's historical/one-shot evaluation) or
|
|
//! incrementally (`RateWindow`, for alerts, which see one point at a time and need cost
|
|
//! proportional to the window rather than a full rescan per ingested line).
|
|
|
|
use std::collections::VecDeque;
|
|
|
|
use crate::timestamp::Timestamp;
|
|
|
|
/// How many `points` fall in the trailing `(now - window_ms, now]` window. Points are occurrence
|
|
/// markers rather than a continuous reading, so only their timestamps matter — the boundary is
|
|
/// exclusive on the old end and inclusive of `now` itself, matching the original
|
|
/// `windowed_rate_series` behavior this replaces.
|
|
fn count_in_window(points: &[(Timestamp, f64)], now: Timestamp, window_ms: i64) -> usize {
|
|
let now_ms = now.as_millis();
|
|
let window_start = now_ms - window_ms;
|
|
points
|
|
.iter()
|
|
.filter(|(ts, _)| {
|
|
let ms = ts.as_millis();
|
|
ms > window_start && ms <= now_ms
|
|
})
|
|
.count()
|
|
}
|
|
|
|
/// Trailing-window occurrence rate (points per second) ending at `now`: how many `points` fall in
|
|
/// `(now - window_ms, now]`, divided by the window width in seconds.
|
|
pub fn rate_at(points: &[(Timestamp, f64)], now: Timestamp, window_ms: i64) -> f64 {
|
|
let window_seconds = window_ms as f64 / 1000.0;
|
|
count_in_window(points, now, window_ms) as f64 / window_seconds
|
|
}
|
|
|
|
/// Evaluates `rate_at` at every one of `points`' own timestamps, turning an occurrence series
|
|
/// into a windowed rate series — used by PromQL's `rate()` so it plots as a real line rather than
|
|
/// one trailing point.
|
|
pub fn windowed_rate_series(points: &[(Timestamp, f64)], window_ms: i64) -> Vec<(Timestamp, f64)> {
|
|
points.iter().map(|(ts, _)| (*ts, rate_at(points, *ts, window_ms))).collect()
|
|
}
|
|
|
|
/// Incrementally maintained trailing window: for a caller that sees points arrive one at a time
|
|
/// (alert `Threshold`/`Ratio` conditions, evaluated per ingested line) and needs cost proportional
|
|
/// to the window rather than `count_in_window`'s full rescan per call. Assumes points are pushed
|
|
/// in non-decreasing timestamp order, true for ingested log lines.
|
|
#[derive(Debug, Default, Clone)]
|
|
pub struct RateWindow {
|
|
timestamps: VecDeque<Timestamp>,
|
|
}
|
|
|
|
impl RateWindow {
|
|
/// Builds a window pre-populated from already-seen history (e.g. currently-ingested logs
|
|
/// matching an alert's query), so a freshly-created window starts with whatever of that
|
|
/// history already falls in the trailing window instead of empty.
|
|
pub fn backfill(timestamps: impl IntoIterator<Item = Timestamp>, now: Timestamp, window_ms: i64) -> Self {
|
|
let mut window = Self::default();
|
|
for ts in timestamps {
|
|
window.push(ts, window_ms);
|
|
}
|
|
window.prune(now, window_ms);
|
|
window
|
|
}
|
|
|
|
/// Records one occurrence at `timestamp` and drops anything that has aged out of the
|
|
/// resulting trailing window.
|
|
pub fn push(&mut self, timestamp: Timestamp, window_ms: i64) {
|
|
self.timestamps.push_back(timestamp);
|
|
self.prune(timestamp, window_ms);
|
|
}
|
|
|
|
/// Same boundary as `count_in_window`: drops entries at or before `now - window_ms`.
|
|
fn prune(&mut self, now: Timestamp, window_ms: i64) {
|
|
let window_start = now.as_millis() - window_ms;
|
|
while let Some(&front) = self.timestamps.front() {
|
|
if front.as_millis() <= window_start {
|
|
self.timestamps.pop_front();
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn count(&self) -> usize {
|
|
self.timestamps.len()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn rate_at_matches_hand_computed_window() {
|
|
let points = vec![
|
|
(Timestamp::from_millis(0), 1.0),
|
|
(Timestamp::from_millis(1000), 1.0),
|
|
(Timestamp::from_millis(2000), 1.0),
|
|
];
|
|
// (1000, 2000]: only the point at 2000.
|
|
assert_eq!(rate_at(&points, Timestamp::from_millis(2000), 1000), 1.0);
|
|
// (0, 2000]: points at 1000 and 2000 -> 2 points / 2s.
|
|
assert_eq!(rate_at(&points, Timestamp::from_millis(2000), 2000), 1.0);
|
|
}
|
|
|
|
#[test]
|
|
fn windowed_rate_series_matches_rate_at_at_each_point() {
|
|
let points = vec![
|
|
(Timestamp::from_millis(0), 1.0),
|
|
(Timestamp::from_millis(500), 1.0),
|
|
(Timestamp::from_millis(1000), 1.0),
|
|
];
|
|
let series = windowed_rate_series(&points, 1000);
|
|
for (ts, rate) in &series {
|
|
assert_eq!(*rate, rate_at(&points, *ts, 1000));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn rate_window_incremental_count_matches_batch_count_in_window() {
|
|
let mut window = RateWindow::default();
|
|
let millis = [0, 200, 400, 1200];
|
|
for ms in millis {
|
|
window.push(Timestamp::from_millis(ms), 1000);
|
|
}
|
|
let points: Vec<(Timestamp, f64)> =
|
|
millis.iter().map(|ms| (Timestamp::from_millis(*ms), 1.0)).collect();
|
|
assert_eq!(window.count(), count_in_window(&points, Timestamp::from_millis(1200), 1000));
|
|
}
|
|
|
|
#[test]
|
|
fn rate_window_backfill_seeds_from_history_within_the_window() {
|
|
let history = [0, 500, 2000, 2500].map(Timestamp::from_millis);
|
|
// now=2500ms, window=1000ms -> window (1500, 2500]: only 2000 and 2500 qualify.
|
|
let window = RateWindow::backfill(history, Timestamp::from_millis(2500), 1000);
|
|
assert_eq!(window.count(), 2);
|
|
}
|
|
}
|