Skip to main content

khive_db/checkpoint/
config.rs

1//! Checkpoint configuration, defaults, and shared transaction-age thresholds.
2
3use std::time::Duration;
4
5#[cfg(doc)]
6use super::{CheckpointSeverityState, SessionSweepConfig};
7
8/// Default number of consecutive above-`warn_pages` observed ticks required
9/// to escalate from the INFO to the WARN rung of the ADR-091 severity ladder.
10pub const DEFAULT_WARN_SUSTAINED_CYCLES: u8 = 3;
11
12/// Configuration for the WAL checkpoint background task.
13///
14/// All fields default to conservative production values. Override via the
15/// environment variables documented on each field.
16#[derive(Clone, Debug)]
17pub struct CheckpointConfig {
18    /// How often to run a passive checkpoint when there is no active write.
19    ///
20    /// Overridable via `KHIVE_CHECKPOINT_INTERVAL_MS` (milliseconds).
21    /// Default: 500 ms.
22    pub interval: Duration,
23
24    /// WAL page count above which a warning is logged.
25    ///
26    /// Overridable via `KHIVE_WAL_WARN_PAGES`.
27    /// Default: 2000 pages (~8 MB at 4 KiB page size).
28    pub warn_pages: u64,
29
30    /// Number of consecutive observed ticks with `wal_pages >= warn_pages`
31    /// required before the ADR-091 severity ladder escalates from INFO
32    /// (first crossing) to WARN (sustained pressure). Edge-triggered once
33    /// per elevation episode — see [`CheckpointSeverityState`].
34    ///
35    /// Overridable via `KHIVE_WAL_WARN_SUSTAINED_CYCLES`.
36    /// Default: 3 cycles.
37    pub warn_sustained_cycles: u8,
38
39    /// WAL page count above which a high-pressure WARNING is logged.
40    ///
41    /// The periodic task always runs PASSIVE regardless; this threshold signals
42    /// only that the WAL is not draining. Whether an old snapshot is pinning it
43    /// is informed at the crossing by the in-process transaction registry,
44    /// against `tx_warn_secs` — see `log_wal_high_water_warn`. This registry
45    /// cannot exclude readers in another process. Either way an
46    /// operator can schedule a blocking TRUNCATE at a safe moment outside
47    /// normal write traffic; the two cases differ in what else is worth doing.
48    ///
49    /// Overridable via `KHIVE_WAL_HIGH_WATER_PAGES`.
50    /// Default: 6000 pages (~24 MB at 4 KiB page size).
51    pub high_water_pages: u64,
52
53    /// WAL page count above which a TRUNCATE escalation attempt is armed
54    /// (ADR-091 Plank 2).
55    ///
56    /// This is a separate, much higher threshold than `high_water_pages`:
57    /// crossing it does not itself attempt TRUNCATE — it only arms the
58    /// attempt, which additionally requires `truncate_min_interval` to have
59    /// elapsed since the last attempt.
60    ///
61    /// Overridable via `KHIVE_WAL_TRUNCATE_HIGH_WATER_PAGES`.
62    /// Default: 20000 pages.
63    pub truncate_high_water_pages: u64,
64
65    /// Minimum spacing between TRUNCATE *attempts* (not successes).
66    ///
67    /// A skipped tick (dedicated connection unavailable, below threshold, or
68    /// interval not yet elapsed) never advances the "last attempt" clock, so
69    /// the next tick where the connection is available and the threshold is
70    /// still crossed is immediately eligible rather than waiting out the
71    /// full interval again.
72    ///
73    /// Overridable via `KHIVE_WAL_TRUNCATE_MIN_INTERVAL_SECS`.
74    /// Default: 300 seconds (5 minutes).
75    pub truncate_min_interval: Duration,
76
77    /// Temporary `busy_timeout` used only for the duration of a TRUNCATE
78    /// attempt, restored to the pool's configured busy timeout immediately
79    /// after the attempt completes (win or lose).
80    ///
81    /// Overridable via `KHIVE_WAL_TRUNCATE_BUSY_MS`.
82    /// Default: 2000 ms.
83    pub truncate_busy_timeout: Duration,
84
85    /// ADR-091 Plank 1 soft cap: age past which the oldest entry in the
86    /// shared open-transaction registry is surfaced at `tracing::warn!` on
87    /// every tick (Skipped or Observed), independent of WAL page pressure.
88    /// See `crates/khive-db/docs/api/checkpoint.md` for the Plank 1 rationale.
89    ///
90    /// Overridable via `KHIVE_TX_WARN_SECS`.
91    /// Default: 30 seconds.
92    pub tx_warn_secs: Duration,
93
94    /// ADR-091 Plank 1 hard cap: age past which the same sweep escalates the
95    /// oldest registry entry to `tracing::error!`. The sweep itself is
96    /// visibility only — nothing in `TxAgeSweepState` force-closes a stale
97    /// span. `sql_bridge.rs`'s cached-reader read-transaction path shares
98    /// this exact value (via `PoolConfig::read_tx_max_age`, #1846) to
99    /// actually roll back and evict an explicit read transaction the next
100    /// time its handle is reused past this age — reclamation for the
101    /// "reused periodically" case, not the "held idle with no further calls"
102    /// case the ADR named as its accepted gap; see
103    /// `crates/khive-db/docs/api/checkpoint.md`'s Plank 1 section for the
104    /// distinction and why the latter remains open design work.
105    ///
106    /// Overridable via `KHIVE_TX_MAX_AGE_SECS`.
107    /// Default: 120 seconds.
108    pub tx_max_age_secs: Duration,
109}
110
111impl Default for CheckpointConfig {
112    fn default() -> Self {
113        Self {
114            interval: Duration::from_millis(500),
115            warn_pages: 2000,
116            warn_sustained_cycles: DEFAULT_WARN_SUSTAINED_CYCLES,
117            high_water_pages: 6000,
118            truncate_high_water_pages: 20_000,
119            truncate_min_interval: Duration::from_secs(300),
120            truncate_busy_timeout: Duration::from_millis(2000),
121            tx_warn_secs: Duration::from_secs(30),
122            tx_max_age_secs: Duration::from_secs(120),
123        }
124    }
125}
126
127impl CheckpointConfig {
128    /// Build a `CheckpointConfig` from the environment.
129    ///
130    /// Unset or unparseable variables fall back to the compiled-in defaults.
131    pub fn from_env() -> Self {
132        let mut cfg = Self::default();
133
134        let n = crate::env::env_parse_or("KHIVE_CHECKPOINT_INTERVAL_MS", 0_u64);
135        if n > 0 {
136            cfg.interval = Duration::from_millis(n);
137        }
138
139        let n = crate::env::env_parse_or("KHIVE_WAL_WARN_PAGES", 0_u64);
140        if n > 0 {
141            cfg.warn_pages = n;
142        }
143
144        let n = crate::env::env_parse_or("KHIVE_WAL_WARN_SUSTAINED_CYCLES", 0_u8);
145        if n > 0 {
146            cfg.warn_sustained_cycles = n;
147        }
148
149        let n = crate::env::env_parse_or("KHIVE_WAL_HIGH_WATER_PAGES", 0_u64);
150        if n > 0 {
151            cfg.high_water_pages = n;
152        }
153
154        let n = crate::env::env_parse_or("KHIVE_WAL_TRUNCATE_HIGH_WATER_PAGES", 0_u64);
155        if n > 0 {
156            cfg.truncate_high_water_pages = n;
157        }
158
159        let n = crate::env::env_parse_or("KHIVE_WAL_TRUNCATE_MIN_INTERVAL_SECS", 0_u64);
160        if n > 0 {
161            cfg.truncate_min_interval = Duration::from_secs(n);
162        }
163
164        let n = crate::env::env_parse_or("KHIVE_WAL_TRUNCATE_BUSY_MS", 0_u64);
165        if n > 0 {
166            cfg.truncate_busy_timeout = Duration::from_millis(n);
167        }
168
169        (cfg.tx_warn_secs, cfg.tx_max_age_secs) =
170            tx_age_thresholds_from_env(cfg.tx_warn_secs, cfg.tx_max_age_secs);
171
172        cfg
173    }
174}
175
176/// Parse `KHIVE_TX_WARN_SECS`/`KHIVE_TX_MAX_AGE_SECS` against the given
177/// defaults, applying the same ordering guard both [`CheckpointConfig`] and
178/// [`SessionSweepConfig`] need (minor, ADR-091 Amendment 2: this was
179/// previously duplicated verbatim in both `from_env` methods).
180///
181/// The severity ladder assumes `tx_warn_secs < tx_max_age_secs` (Warn fires
182/// before Stale as an entry ages). A reversed or equal pair — whether from
183/// one misconfigured var or the interaction of both — would invert or
184/// collapse that ordering (e.g. WARN_SECS=120, MAX_AGE_SECS=30 emits Stale at
185/// 30s and never reaches the Warn crossing until 120s), so both are rejected
186/// together rather than silently honored. Resetting both to the caller's
187/// defaults (rather than just clamping one) avoids guessing which of the two
188/// the operator actually meant to change.
189pub(crate) fn tx_age_thresholds_from_env(
190    default_warn: Duration,
191    default_max: Duration,
192) -> (Duration, Duration) {
193    let mut warn_secs = default_warn;
194    let mut max_age_secs = default_max;
195
196    let n = crate::env::env_parse_or("KHIVE_TX_WARN_SECS", 0_u64);
197    if n > 0 {
198        warn_secs = Duration::from_secs(n);
199    }
200
201    let n = crate::env::env_parse_or("KHIVE_TX_MAX_AGE_SECS", 0_u64);
202    if n > 0 {
203        max_age_secs = Duration::from_secs(n);
204    }
205
206    if warn_secs >= max_age_secs {
207        tracing::warn!(
208            configured_tx_warn_secs = warn_secs.as_secs_f64(),
209            configured_tx_max_age_secs = max_age_secs.as_secs_f64(),
210            fallback_tx_warn_secs = default_warn.as_secs_f64(),
211            fallback_tx_max_age_secs = default_max.as_secs_f64(),
212            "KHIVE_TX_WARN_SECS must be strictly less than KHIVE_TX_MAX_AGE_SECS; \
213             both transaction-age thresholds were rejected and reset to their defaults"
214        );
215        return (default_warn, default_max);
216    }
217
218    (warn_secs, max_age_secs)
219}