khive_db/checkpoint/severity_state.rs
1//! Checkpoint escalation and transaction-age state machines.
2
3use std::time::{Duration, Instant};
4
5use super::CheckpointConfig;
6#[cfg(unix)]
7use super::{
8 CachedWalpinAttribution, WalpinFullScanPlan, DEFAULT_SESSION_SWEEP_INTERVAL,
9 DEFAULT_WALPIN_FULL_SCAN_INTERVAL,
10};
11
12/// Mutable escalation state carried across ticks by the caller (ADR-091 Plank 2).
13///
14/// Kept separate from [`CheckpointConfig`] because it is *state*, not
15/// configuration: `last_attempt` and `consecutive_failures` mutate every tick,
16/// while `CheckpointConfig` is parsed once and held immutable for the life of
17/// the task.
18#[derive(Debug)]
19pub struct TruncateState {
20 /// When the last TRUNCATE *attempt* ran (armed + writer held), regardless
21 /// of whether it succeeded in reclaiming pages. `None` means no attempt
22 /// has ever run, so the first armed tick is immediately eligible.
23 pub(super) last_attempt: Option<Instant>,
24 /// Count of measured TRUNCATE outcomes that failed to bring `wal_pages`
25 /// below `warn_pages`, ignoring attempts whose post-TRUNCATE measurement
26 /// was unavailable. A measured clearing result resets it; a one-shot
27 /// escalated WARN fires at exactly 3 failures.
28 pub(super) consecutive_failures: u32,
29 /// Fallback freshness cadence for legacy sidecar records that do not
30 /// declare their producer interval. Captured once when the daemon task
31 /// starts; this is ADR-091's compiled 5000 ms session-sweep default, never
32 /// the daemon's faster checkpoint cadence or a local environment override.
33 #[cfg(unix)]
34 pub(super) legacy_walpin_fallback_interval: Duration,
35 /// Minimum spacing between full sidecar/OS-holder enumeration attempts.
36 /// The attempt timestamp advances before blocking work starts, so an I/O
37 /// failure or worker panic cannot turn sustained pressure into a hot retry
38 /// loop. A successful report is retained only for diagnostic reuse.
39 #[cfg(unix)]
40 pub(super) walpin_full_scan_interval: Duration,
41 #[cfg(unix)]
42 pub(super) walpin_full_scan_last_attempt: Option<Instant>,
43 #[cfg(unix)]
44 pub(super) walpin_cached_attribution: Option<CachedWalpinAttribution>,
45 /// Whether the no-progress attribution arm already attempted the one
46 /// bounded sidecar enumeration allowed for this checkpoint tick.
47 #[cfg(unix)]
48 pub(super) sidecar_attribution_attempted_this_tick: bool,
49}
50
51impl Default for TruncateState {
52 fn default() -> Self {
53 Self {
54 last_attempt: None,
55 consecutive_failures: 0,
56 #[cfg(unix)]
57 legacy_walpin_fallback_interval: DEFAULT_SESSION_SWEEP_INTERVAL,
58 #[cfg(unix)]
59 walpin_full_scan_interval: DEFAULT_WALPIN_FULL_SCAN_INTERVAL,
60 #[cfg(unix)]
61 walpin_full_scan_last_attempt: None,
62 #[cfg(unix)]
63 walpin_cached_attribution: None,
64 #[cfg(unix)]
65 sidecar_attribution_attempted_this_tick: false,
66 }
67 }
68}
69
70impl TruncateState {
71 #[cfg(unix)]
72 pub(super) fn with_legacy_walpin_fallback(interval: Duration) -> Self {
73 Self {
74 legacy_walpin_fallback_interval: interval,
75 ..Self::default()
76 }
77 }
78
79 #[cfg(all(test, unix))]
80 pub(super) fn with_walpin_full_scan_cadence(interval: Duration) -> Self {
81 Self {
82 walpin_full_scan_interval: interval,
83 ..Self::default()
84 }
85 }
86
87 #[cfg(unix)]
88 pub(super) fn begin_tick(&mut self) {
89 self.sidecar_attribution_attempted_this_tick = false;
90 }
91
92 #[cfg(unix)]
93 pub(super) fn housekeeping_due(&self) -> bool {
94 !self.sidecar_attribution_attempted_this_tick
95 && self.walpin_full_scan_due_at(Instant::now())
96 }
97
98 #[cfg(unix)]
99 fn walpin_full_scan_due_at(&self, now: Instant) -> bool {
100 self.walpin_full_scan_last_attempt.is_none_or(|last| {
101 now.saturating_duration_since(last) >= self.walpin_full_scan_interval
102 })
103 }
104
105 #[cfg(unix)]
106 pub(super) fn claim_walpin_full_scan_at(&mut self, now: Instant) -> bool {
107 if !self.walpin_full_scan_due_at(now) {
108 return false;
109 }
110 self.walpin_full_scan_last_attempt = Some(now);
111 true
112 }
113
114 #[cfg(unix)]
115 pub(super) fn plan_walpin_attribution_at(&mut self, now: Instant) -> WalpinFullScanPlan {
116 if self.walpin_full_scan_due_at(now) {
117 let previous_last_attempt = self.walpin_full_scan_last_attempt.replace(now);
118 WalpinFullScanPlan::Refresh {
119 previous_last_attempt,
120 }
121 } else if let Some(cached) = self.walpin_cached_attribution.clone() {
122 WalpinFullScanPlan::Cached(cached)
123 } else {
124 WalpinFullScanPlan::Suppressed
125 }
126 }
127
128 #[cfg(unix)]
129 pub(super) fn restore_walpin_full_scan_reservation(
130 &mut self,
131 previous_last_attempt: Option<Instant>,
132 ) {
133 self.walpin_full_scan_last_attempt = previous_last_attempt;
134 }
135
136 #[cfg(unix)]
137 pub(super) fn cache_walpin_attribution(
138 &mut self,
139 report: crate::walpin::WalpinReport,
140 census: Result<crate::walpin::CensusResult, String>,
141 captured_at: Instant,
142 ) {
143 self.walpin_cached_attribution = Some(CachedWalpinAttribution {
144 report,
145 census,
146 captured_at,
147 });
148 }
149}
150
151/// ADR-091 graduated severity rung for sustained WAL pressure.
152///
153/// `Alarm` is never produced by [`CheckpointSeverityState::observe_wal_pages`]
154/// — it labels the existing TRUNCATE-escalation tier (`maybe_truncate`),
155/// which is gated on its own threshold/interval state, not on this ladder.
156/// It exists here so callers and tests can name all three rungs uniformly.
157#[derive(Debug, Clone, Copy, PartialEq, Eq)]
158pub enum CheckpointSeverityRung {
159 /// First observed tick crossing `warn_pages` after a below-warn tick.
160 Info,
161 /// `warn_sustained_cycles` consecutive observed ticks at/above
162 /// `warn_pages`; edge-triggered once per elevation episode.
163 Warn,
164 /// The TRUNCATE-escalation tier (`checkpoint_high_water_pages` and
165 /// above); never emitted by `observe_wal_pages`.
166 Alarm,
167}
168
169/// ADR-091 severity ladder state, carried across ticks by the caller
170/// alongside [`TruncateState`]. Pure state machine: no I/O, no logging —
171/// callers turn the returned emissions into `tracing` calls.
172#[derive(Debug, Default, Clone)]
173pub struct CheckpointSeverityState {
174 /// Whether the previous observed tick was at/above `warn_pages`. Drives
175 /// the below→above edge that fires INFO.
176 was_above_warn: bool,
177 /// Run-length of consecutive observed ticks at/above `warn_pages` in the
178 /// current elevation episode. Resets to 0 on any below-warn tick.
179 consecutive_above_warn: u8,
180 /// Whether WARN has already fired for the current elevation episode, so
181 /// sustained pressure logs WARN once per episode, not once per tick past
182 /// the threshold.
183 pub(super) warn_emitted_for_episode: bool,
184}
185
186/// One severity-ladder emission produced by a single
187/// [`CheckpointSeverityState::observe_wal_pages`] call.
188#[derive(Debug, Clone, Copy, PartialEq, Eq)]
189pub struct CheckpointSeverityEmission {
190 /// Which rung this emission represents (`Info` or `Warn`; see
191 /// [`CheckpointSeverityRung::Alarm`] doc for why `Alarm` never appears
192 /// here).
193 pub rung: CheckpointSeverityRung,
194 /// The WAL page count observed on the tick that produced this emission.
195 pub wal_pages: u64,
196 /// The `warn_pages` threshold in effect for this tick.
197 pub threshold_pages: u64,
198 /// Consecutive above-warn cycle count as of this tick (1 on the INFO
199 /// edge, `warn_sustained_cycles` on the WARN edge).
200 pub consecutive_cycles: u8,
201}
202
203impl CheckpointSeverityState {
204 /// Advance the severity ladder by one observed tick and return every
205 /// rung crossed on this tick (zero, one, or two emissions: a fresh
206 /// elevation episode can produce INFO and, if `warn_sustained_cycles`
207 /// is 1, WARN on the very same tick).
208 ///
209 /// A below-warn tick resets both the consecutive-cycle counter and the
210 /// per-episode WARN latch, re-arming INFO/WARN for a later episode.
211 /// Skipped ticks must not be passed here at all — the caller only calls
212 /// this on `CheckpointTick::Observed`, matching the existing
213 /// threshold-crossing WARN's skip-leaves-state-unchanged rule.
214 pub fn observe_wal_pages(
215 &mut self,
216 wal_pages: u64,
217 config: &CheckpointConfig,
218 ) -> Vec<CheckpointSeverityEmission> {
219 let mut emissions = Vec::new();
220 let above_warn = wal_pages >= config.warn_pages;
221
222 if above_warn {
223 self.consecutive_above_warn = self.consecutive_above_warn.saturating_add(1);
224
225 if !self.was_above_warn {
226 emissions.push(CheckpointSeverityEmission {
227 rung: CheckpointSeverityRung::Info,
228 wal_pages,
229 threshold_pages: config.warn_pages,
230 consecutive_cycles: self.consecutive_above_warn,
231 });
232 }
233
234 if !self.warn_emitted_for_episode
235 && self.consecutive_above_warn >= config.warn_sustained_cycles
236 {
237 emissions.push(CheckpointSeverityEmission {
238 rung: CheckpointSeverityRung::Warn,
239 wal_pages,
240 threshold_pages: config.warn_pages,
241 consecutive_cycles: self.consecutive_above_warn,
242 });
243 self.warn_emitted_for_episode = true;
244 }
245 } else {
246 self.consecutive_above_warn = 0;
247 self.warn_emitted_for_episode = false;
248 }
249
250 self.was_above_warn = above_warn;
251 emissions
252 }
253}
254
255/// ADR-091 Plank 1 rung for the open-transaction registry's background age
256/// sweep: independent of the WAL-pressure ladder above, keyed purely off how
257/// long the registry's oldest entry has been open.
258#[derive(Debug, Clone, Copy, PartialEq, Eq)]
259pub enum TxAgeRung {
260 /// The oldest registry entry's age crossed `tx_warn_secs`.
261 Warn,
262 /// The oldest registry entry's age crossed `tx_max_age_secs` — the ADR's
263 /// "cooperative stale-op guard" cap. No in-process mechanism force-closes
264 /// it (see [`CheckpointConfig::tx_max_age_secs`]); this rung is the
265 /// sweep's strongest available signal.
266 Stale,
267}
268
269/// One emission produced by a single [`TxAgeSweepState::observe`] call.
270#[derive(Debug, Clone, PartialEq, Eq)]
271pub struct TxAgeEmission {
272 pub rung: TxAgeRung,
273 pub age: Duration,
274 pub label: Option<String>,
275}
276
277/// ADR-091 Plank 1 background-sweep state, carried across ticks by the
278/// caller alongside [`CheckpointSeverityState`] and [`TruncateState`]. Pure
279/// state machine: no I/O, no logging — callers turn the returned emissions
280/// into `tracing` calls, mirroring [`CheckpointSeverityState`]'s shape.
281///
282/// Keyed off `khive_storage::tx_registry::oldest()` — the single oldest
283/// entry across every registered span, regardless of which call site created
284/// it. Deliberately a different signal from the WAL-pressure ladder: a span
285/// can go stale under low WAL pressure, or vice versa. See
286/// `crates/khive-db/docs/api/checkpoint.md` for the full rationale.
287#[derive(Debug, Default, Clone)]
288pub struct TxAgeSweepState {
289 /// Whether the previous observed tick's oldest entry was at/above
290 /// `tx_warn_secs`. Drives the below→above edge that fires `Warn`.
291 was_above_warn: bool,
292 /// Whether the previous observed tick's oldest entry was at/above
293 /// `tx_max_age_secs`. Drives the below→above edge that fires `Stale`.
294 was_above_max_age: bool,
295 /// Identity of the entry the previous observed tick reported as oldest,
296 /// or `None` if the registry was empty. Tracked separately from the two
297 /// latches above so a change in *which span* is oldest can be detected
298 /// even when both latches are already `true` (see [`Self::observe`]).
299 tracked_id: Option<khive_storage::tx_registry::TxId>,
300}
301
302impl TxAgeSweepState {
303 /// Advance by one observed tick given the registry's current oldest
304 /// entry (identity, age, label), or `None` if empty. Returns zero, one,
305 /// or two emissions — an entry already stale the first time it's seen
306 /// under a given identity crosses both rungs on the same tick.
307 ///
308 /// A below-threshold (or absent) oldest entry resets both latches. A
309 /// change in the oldest entry's [`TxId`](khive_storage::tx_registry::TxId)
310 /// also force-resets both latches before re-evaluating age, so a
311 /// departed span's latched state cannot suppress the crossing for an
312 /// already-stale successor. See `crates/khive-db/docs/api/checkpoint.md`
313 /// for why identity tracking is required here, not just the age check.
314 pub fn observe(
315 &mut self,
316 oldest: Option<(khive_storage::tx_registry::TxId, Duration, Option<String>)>,
317 tx_warn_secs: Duration,
318 tx_max_age_secs: Duration,
319 ) -> Vec<TxAgeEmission> {
320 let mut emissions = Vec::new();
321
322 let Some((id, age, label)) = oldest else {
323 self.was_above_warn = false;
324 self.was_above_max_age = false;
325 self.tracked_id = None;
326 return emissions;
327 };
328
329 if self.tracked_id != Some(id) {
330 self.was_above_warn = false;
331 self.was_above_max_age = false;
332 }
333 self.tracked_id = Some(id);
334
335 let above_warn = age >= tx_warn_secs;
336 let above_max_age = age >= tx_max_age_secs;
337
338 if above_warn && !self.was_above_warn {
339 emissions.push(TxAgeEmission {
340 rung: TxAgeRung::Warn,
341 age,
342 label: label.clone(),
343 });
344 }
345 if above_max_age && !self.was_above_max_age {
346 emissions.push(TxAgeEmission {
347 rung: TxAgeRung::Stale,
348 age,
349 label,
350 });
351 }
352
353 self.was_above_warn = above_warn;
354 self.was_above_max_age = above_max_age;
355 emissions
356 }
357}