Skip to main content

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}