Skip to main content

query_runtime/observability/recorder/
state.rs

1//! Recorder retention state, metrics reduction, and budget windows.
2
3use std::collections::BTreeMap;
4
5use super::{
6    CleanupFailureSummary, ObservationSequence, PlanExecutionId, QueryMetrics, QueryMetricsCheckpoint,
7    QueryMetricsSummary, RetainedTopologyRecord, RuntimeObservationLimits, RuntimeObservationLossReason,
8    RuntimeObservationRecordClass, RuntimeObservationSealObserver, RuntimeTimelineEvent,
9    SealedRuntimeObservationSnapshot, SourceReadRuntimeMetrics,
10};
11use crate::observability::LakehouseCacheFillEventSnapshot;
12
13/// Exact cleanup-failure accounting for an attempt report. It sits outside the
14/// record limits, so bounded timeline loss never changes the count or the
15/// first summary.
16#[derive(Debug, Clone)]
17pub(crate) struct CleanupFailureTally {
18    count: u64,
19    complete: bool,
20    first: Option<CleanupFailureSummary>,
21}
22
23impl Default for CleanupFailureTally {
24    fn default() -> Self {
25        Self { count: 0, complete: true, first: None }
26    }
27}
28
29impl CleanupFailureTally {
30    /// Retains only the first cleanup failure while accounting every observed
31    /// one. Overflow marks the tally incomplete, and the count is then not
32    /// reported as exact.
33    pub(super) fn record(&mut self, summary: CleanupFailureSummary) {
34        if let Some(first) = &mut self.first {
35            // The report retains one representative summary. Once another
36            // cleanup failure is observed that representative is necessarily
37            // incomplete, even though the count remains exact below.
38            first.mark_incomplete();
39        } else {
40            self.first = Some(summary);
41        }
42        match self.count.checked_add(1) {
43            Some(next) => self.count = next,
44            None => self.complete = false,
45        }
46    }
47
48    pub(crate) fn count(&self) -> u64 {
49        self.count
50    }
51
52    pub(crate) fn complete(&self) -> bool {
53        self.complete
54    }
55
56    pub(crate) fn first(&self) -> Option<&CleanupFailureSummary> {
57        self.first.as_ref()
58    }
59
60    #[cfg(test)]
61    pub(super) fn with_count_for_test(count: u64) -> Self {
62        Self { count, ..Self::default() }
63    }
64}
65
66#[derive(Debug, Default)]
67pub(super) struct RuntimeObservationRecorderState {
68    pub(super) next_sequence: u64,
69    pub(super) last_monotonic_ns: u64,
70    pub(super) retained_records: u64,
71    pub(super) retained_bytes: u64,
72    pub(super) topology: Vec<RetainedTopologyRecord>,
73    pub(super) events: Vec<RuntimeTimelineEvent>,
74    pub(super) first_dropped_sequence: Option<ObservationSequence>,
75    pub(super) last_attempted_sequence: Option<ObservationSequence>,
76    pub(super) dropped_by_class: BTreeMap<RuntimeObservationRecordClass, u64>,
77    pub(super) dropped_by_reason: BTreeMap<RuntimeObservationLossReason, u64>,
78    pub(super) degraded: bool,
79    pub(super) poison_recoveries: u64,
80    pub(super) counter_overflow: bool,
81    pub(super) sealed_snapshot: Option<SealedRuntimeObservationSnapshot>,
82    pub(super) seal_observers: Vec<RuntimeObservationSealObserver>,
83    pub(super) cleanup_failures: CleanupFailureTally,
84    // Metrics reduction shares this lock with retention and loss accounting,
85    // so one revision always describes totals, retained rows, and completeness
86    // together. It is partitioned by plan execution because one query can run
87    // several, and each execution's projection must carry only its own facts;
88    // a consumer that sums projections must not count a fact twice.
89    pub(super) metrics: BTreeMap<PlanExecutionId, PlanExecutionMetrics>,
90    pub(super) metrics_revision: u64,
91    // Query-wide share of the limits already taken by budget detail rows.
92    pub(super) budget_detail_records: u64,
93    pub(super) budget_detail_bytes: u64,
94    // Background cache-fill completions no plan execution carries yet. A fill
95    // finishes on its own schedule, so the next plan execution to freeze its
96    // metrics takes them and offers them for retention. At most the record
97    // limit wait; later ones only count as overflow, since no more than the
98    // limit can be retained.
99    pub(super) cache_fill_events: Vec<LakehouseCacheFillEventSnapshot>,
100    pub(super) cache_fill_events_overflow: u64,
101}
102
103// Bounded by the number of plan executions in the query, and data volume does
104// not change that bound. Detail rows inside are bounded by the recorder's
105// shared record/byte limits.
106#[derive(Debug, Default)]
107pub(super) struct PlanExecutionMetrics {
108    pub(super) summary: QueryMetricsSummary,
109    pub(super) totals_saturated: bool,
110    pub(super) source_read_rows: Vec<SourceReadRuntimeMetrics>,
111    pub(super) source_read_rows_dropped: u64,
112    // Reduction state for every family other than source reads. Only this
113    // module mutates it, through the typed `MetricsRecorder` methods.
114    pub(super) reduction: QueryMetrics,
115    pub(super) detail_rows_dropped: u64,
116    // Open budget windows and the peaks each has seen. A window is a nesting
117    // scope, so there are at most a handful open at once.
118    pub(super) budget_windows: BTreeMap<u64, BudgetWindowPeaks>,
119    pub(super) next_budget_window: u64,
120    // Set once the plan's owner projects its metrics; later facts for the
121    // plan are ignored, so every projection agrees with this value.
122    pub(super) frozen: Option<QueryMetricsCheckpoint>,
123}
124
125impl PlanExecutionMetrics {
126    pub(super) const fn is_frozen(&self) -> bool {
127        self.frozen.is_some()
128    }
129}
130
131/// The largest allocator high-water and retained bytes the budget reports in
132/// one window carried.
133#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
134pub struct BudgetWindowPeaks {
135    /// Largest allocator high-water bytes, if any report carried one.
136    pub observed_peak_bytes: Option<u64>,
137    /// Largest retained bytes, if any report carried one.
138    pub retained_bytes: Option<u64>,
139}
140
141/// An open span over which budget reports are folded into one task's peaks.
142///
143/// Replaces a cursor into the retained budget events: a window sees every
144/// report recorded while it is open, including ones bounded retention later
145/// declines, so a task's peak never depends on what was kept.
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147#[must_use = "an open budget window must be closed onto its task"]
148pub struct BudgetWindow(pub(super) u64);
149
150impl RuntimeObservationRecorderState {
151    pub(super) fn take_sequence(&mut self) -> ObservationSequence {
152        let value = self.next_sequence.max(1);
153        self.next_sequence = value.checked_add(1).unwrap_or_else(|| {
154            self.counter_overflow = true;
155            u64::MAX
156        });
157        let sequence = ObservationSequence::new(value);
158        self.last_attempted_sequence = Some(sequence);
159        sequence
160    }
161
162    pub(super) fn rejection_reason(
163        &self,
164        limits: RuntimeObservationLimits,
165        logical_bytes: u64,
166    ) -> Option<RuntimeObservationLossReason> {
167        if self.retained_records >= limits.max_retained_records {
168            Some(RuntimeObservationLossReason::RecordLimit)
169        } else if logical_bytes > limits.max_retained_bytes {
170            Some(RuntimeObservationLossReason::Oversized)
171        } else if self.retained_bytes.checked_add(logical_bytes).is_none_or(|sum| sum > limits.max_retained_bytes) {
172            Some(RuntimeObservationLossReason::ByteLimit)
173        } else {
174            None
175        }
176    }
177
178    pub(super) fn record_drop(
179        &mut self,
180        class: RuntimeObservationRecordClass,
181        sequence: ObservationSequence,
182        reason: RuntimeObservationLossReason,
183    ) {
184        self.first_dropped_sequence.get_or_insert(sequence);
185        let class_count = self.dropped_by_class.get(&class).copied().unwrap_or(0);
186        let next_class_count = class_count.checked_add(1).unwrap_or_else(|| {
187            self.counter_overflow = true;
188            u64::MAX
189        });
190        self.dropped_by_class.insert(class, next_class_count);
191        let reason_count = self.dropped_by_reason.get(&reason).copied().unwrap_or(0);
192        let next_reason_count = reason_count.checked_add(1).unwrap_or_else(|| {
193            self.counter_overflow = true;
194            u64::MAX
195        });
196        self.dropped_by_reason.insert(reason, next_reason_count);
197    }
198
199    /// Admits one metrics detail row.
200    ///
201    /// Budget reports arrive once per budgeted allocation call, so they grow
202    /// with data far faster than any other family. They may take at most half
203    /// of the shared limits; otherwise a large query would spend the whole
204    /// capacity on them and lose every later strategy decision or operator row.
205    pub(super) fn admit_detail(
206        &mut self,
207        limits: RuntimeObservationLimits,
208        class: RuntimeObservationRecordClass,
209        sequence: ObservationSequence,
210        logical_bytes: u64,
211    ) -> Result<(), RuntimeObservationLossReason> {
212        if class != RuntimeObservationRecordClass::BudgetDetail {
213            return self.admit(limits, class, sequence, logical_bytes);
214        }
215        let share = RuntimeObservationLimits {
216            max_retained_records: limits.max_retained_records / 2,
217            max_retained_bytes: limits.max_retained_bytes / 2,
218        };
219        let share_rejection = if self.budget_detail_records >= share.max_retained_records {
220            Some(RuntimeObservationLossReason::RecordLimit)
221        } else if self.budget_detail_bytes.saturating_add(logical_bytes) > share.max_retained_bytes {
222            Some(RuntimeObservationLossReason::ByteLimit)
223        } else {
224            None
225        };
226        if let Some(reason) = share_rejection {
227            self.record_drop(class, sequence, reason);
228            return Err(reason);
229        }
230        self.admit(limits, class, sequence, logical_bytes)?;
231        self.budget_detail_records = self.budget_detail_records.saturating_add(1);
232        self.budget_detail_bytes = self.budget_detail_bytes.saturating_add(logical_bytes);
233        Ok(())
234    }
235
236    pub(super) fn admit(
237        &mut self,
238        limits: RuntimeObservationLimits,
239        class: RuntimeObservationRecordClass,
240        sequence: ObservationSequence,
241        logical_bytes: u64,
242    ) -> Result<(), RuntimeObservationLossReason> {
243        if let Some(reason) = self.rejection_reason(limits, logical_bytes) {
244            self.record_drop(class, sequence, reason);
245            return Err(reason);
246        }
247        self.retained_records = self.retained_records.checked_add(1).unwrap_or_else(|| {
248            self.counter_overflow = true;
249            u64::MAX
250        });
251        self.retained_bytes = self.retained_bytes.checked_add(logical_bytes).unwrap_or_else(|| {
252            self.counter_overflow = true;
253            u64::MAX
254        });
255        Ok(())
256    }
257}