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}