Skip to main content

wm_substrate/
write_budget.rs

1//! Write-budget telemetry — the system learning its own cost.
2//!
3//! The irony tax: WhiteMagic gives agents durable continuity while
4//! shortening the life of the SSD that holds the store. This module makes
5//! the cost *visible*: it tracks how many bytes the store grew per day
6//! (LMDB high-water mark + Tantivy index size), keeps a 90-day daily
7//! ledger at `<store-root>/write_budget.json`, and answers the operational
8//! question "did the store write more than usual today?" — the Harmony
9//! Vector's frugality dimension.
10//!
11//! Measurement model (honest approximations, stated plainly):
12//! - LMDB: `data.mdb` file size is a high-water mark — it grows in
13//!   increments and never shrinks without compaction, so deltas measure
14//!   growth, and free-after-delete space is not credited. This is the
15//!   right bias for SSD-wear accounting (over-count writes, never under).
16//! - Tantivy: directory size on disk, walked at most once per 5 minutes
17//!   and cached between walks (a segment-merging index changes size in
18//!   steps; per-request walks would cost more I/O than they measure).
19//! - Backups and dream-cycle compactions are attributed to the day they
20//!   grew the files, same as any write.
21
22#![forbid(unsafe_code)]
23
24use chrono::{DateTime, Duration, NaiveDate, Utc};
25use serde::{Deserialize, Serialize};
26use serde_json::json;
27use std::collections::BTreeMap;
28use std::path::{Path, PathBuf};
29
30/// Daily ledger retention — 90 days is enough for "30-day average" plus
31/// seasonal drift, small enough that the file stays a few KB.
32const RETENTION_DAYS: i64 = 90;
33/// Minimum interval between Tantivy directory walks (expensive I/O).
34const TANTIVY_WALK_INTERVAL_SECS: i64 = 300;
35/// How many days of history feed the reported average.
36const AVERAGE_WINDOW_DAYS: i64 = 30;
37
38/// Per-day write totals attributed to one UTC date.
39#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
40pub struct DayWrites {
41    /// Attributed bytes for this day (positive growth only).
42    pub bytes: u64,
43    /// How many observation samples contributed (diagnostics).
44    pub samples: u32,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
48struct LastSample {
49    ts: String,
50    lmdb_bytes: u64,
51    tantivy_bytes: u64,
52}
53
54#[derive(Debug, Default, Serialize, Deserialize)]
55struct WriteBudgetState {
56    version: u8,
57    /// UTC date (YYYY-MM-DD) → totals.
58    days: BTreeMap<String, DayWrites>,
59    last_sample: Option<LastSample>,
60    /// First day the ledger recorded — lifetime context for the report.
61    tracking_since: Option<String>,
62}
63
64impl WriteBudgetState {
65    const fn new() -> Self {
66        Self {
67            version: 1,
68            days: BTreeMap::new(),
69            last_sample: None,
70            tracking_since: None,
71        }
72    }
73}
74
75/// One observation's outcome: how many bytes were attributed, and to when.
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub struct WriteBudgetDelta {
78    /// Bytes attributed to `date` by this observation.
79    pub attributed_bytes: u64,
80    /// UTC date the bytes were attributed to.
81    pub date: NaiveDate,
82}
83
84/// The operational summary `wm doctor` and `/status` render.
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct WriteBudgetReport {
87    /// Bytes attributed today (UTC) so far.
88    pub today_bytes: u64,
89    /// Bytes attributed yesterday, for "more than usual?" comparison.
90    pub yesterday_bytes: Option<u64>,
91    /// Mean daily bytes over the available history (≤ 30 days).
92    pub avg_30d_bytes: u64,
93    /// Days of history currently in the ledger.
94    pub days_tracked: u32,
95    /// Busiest day in the ledger: (date, bytes).
96    pub busiest_day: Option<(String, u64)>,
97    /// Current LMDB high-water size (bytes).
98    pub lmdb_bytes: u64,
99    /// Current Tantivy index size (bytes).
100    pub tantivy_bytes: u64,
101}
102
103/// Daily write-budget ledger for one store.
104///
105/// Thread-safe through the caller (`McpServer` holds it in a `Mutex`);
106/// persistence is an atomic tmp+rename of a small JSON file.
107#[derive(Debug)]
108pub struct WriteBudgetLedger {
109    path: PathBuf,
110    lmdb_data_mdb: PathBuf,
111    tantivy_dir: PathBuf,
112    state: WriteBudgetState,
113    last_tantivy_walk: Option<DateTime<Utc>>,
114    cached_tantivy_bytes: u64,
115}
116
117impl WriteBudgetLedger {
118    /// Ledger path convention: `<store-root>/write_budget.json` (read by
119    /// `wm doctor`), with the LMDB env and Tantivy index as siblings of
120    /// the store root's `lmdb/` directory.
121    #[must_use]
122    pub fn paths(store_root: &Path) -> (PathBuf, PathBuf, PathBuf) {
123        (
124            store_root.join("write_budget.json"),
125            store_root.join("lmdb").join("data.mdb"),
126            store_root.join("lmdb").join("tantivy"),
127        )
128    }
129
130    /// Open (or initialize) the ledger for a store root.
131    #[must_use]
132    pub fn load(store_root: &Path) -> Self {
133        let (path, lmdb_data_mdb, tantivy_dir) = Self::paths(store_root);
134        let state = std::fs::read_to_string(&path)
135            .ok()
136            .and_then(|raw| {
137                let parsed = serde_json::from_str::<WriteBudgetState>(&raw).ok()?;
138                Some(parsed)
139            })
140            .unwrap_or_else(|| {
141                if path.exists() {
142                    // A corrupt ledger must never wedge the server: start
143                    // fresh (advisory telemetry; the next write rebuilds).
144                    tracing::warn!(
145                        path = %path.display(),
146                        "write_budget.json unreadable — starting a fresh ledger"
147                    );
148                }
149                WriteBudgetState::new()
150            });
151        Self {
152            path,
153            lmdb_data_mdb,
154            tantivy_dir,
155            state,
156            last_tantivy_walk: None,
157            cached_tantivy_bytes: 0,
158        }
159    }
160
161    /// Observe current sizes, attribute positive growth to today (UTC),
162    /// prune history beyond retention, and persist. Cheap when called
163    /// more often than the Tantivy walk interval — only a `stat` runs.
164    pub fn observe(&mut self, now: DateTime<Utc>) -> WriteBudgetDelta {
165        let lmdb_bytes = std::fs::metadata(&self.lmdb_data_mdb).map_or(0, |m| m.len());
166        let walk_due = self
167            .last_tantivy_walk
168            .is_none_or(|last| (now - last).num_seconds() >= TANTIVY_WALK_INTERVAL_SECS);
169        if walk_due {
170            self.cached_tantivy_bytes = dir_size(&self.tantivy_dir);
171            self.last_tantivy_walk = Some(now);
172        }
173        let total_bytes = lmdb_bytes.saturating_add(self.cached_tantivy_bytes);
174
175        let (attributed, date) = match &self.state.last_sample {
176            Some(last) => {
177                let growth =
178                    total_bytes.saturating_sub(last.lmdb_bytes.saturating_add(last.tantivy_bytes));
179                (growth, now.date_naive())
180            }
181            None => (0, now.date_naive()),
182        };
183
184        self.state.last_sample = Some(LastSample {
185            ts: now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
186            lmdb_bytes,
187            tantivy_bytes: self.cached_tantivy_bytes,
188        });
189
190        let day_key = date.to_string();
191        if attributed > 0 {
192            let entry = self.state.days.entry(day_key.clone()).or_default();
193            entry.bytes = entry.bytes.saturating_add(attributed);
194            entry.samples = entry.samples.saturating_add(1);
195        }
196        if self.state.tracking_since.is_none() {
197            self.state.tracking_since = Some(day_key);
198        }
199        self.prune(now.date_naive());
200        self.persist();
201        WriteBudgetDelta {
202            attributed_bytes: attributed,
203            date,
204        }
205    }
206
207    /// Drop days older than the retention window.
208    fn prune(&mut self, today: NaiveDate) {
209        let Some(cutoff) = today.checked_sub_signed(Duration::days(RETENTION_DAYS)) else {
210            return;
211        };
212        self.state.days.retain(|day, _| {
213            NaiveDate::parse_from_str(day, "%Y-%m-%d").is_ok_and(|d| d >= cutoff) // unparseable keys are kept for inspection
214        });
215    }
216
217    /// Persist atomically; failures are warn-only (advisory telemetry must
218    /// never take a server down).
219    fn persist(&self) {
220        match serde_json::to_string_pretty(&self.state) {
221            Ok(body) => {
222                let tmp = self
223                    .path
224                    .with_file_name(format!("write_budget.json.tmp.{}", std::process::id()));
225                if let Err(e) =
226                    std::fs::write(&tmp, body).and_then(|()| std::fs::rename(&tmp, &self.path))
227                {
228                    tracing::warn!(
229                        path = %self.path.display(),
230                        error = %e,
231                        "failed to persist write budget ledger"
232                    );
233                }
234            }
235            Err(e) => tracing::warn!(error = %e, "write budget serialization failed"),
236        }
237    }
238
239    /// Refresh the size sample WITHOUT persisting — the read-only surface
240    /// for `wm doctor` and `/status` (a status probe must not write).
241    pub fn fresh_report(&mut self) -> WriteBudgetReport {
242        let lmdb_bytes = std::fs::metadata(&self.lmdb_data_mdb).map_or(0, |m| m.len());
243        let walk_due = self
244            .last_tantivy_walk
245            .is_none_or(|last| (Utc::now() - last).num_seconds() >= TANTIVY_WALK_INTERVAL_SECS);
246        if walk_due {
247            self.cached_tantivy_bytes = dir_size(&self.tantivy_dir);
248            self.last_tantivy_walk = Some(Utc::now());
249        }
250        self.state.last_sample = Some(LastSample {
251            ts: Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
252            lmdb_bytes,
253            tantivy_bytes: self.cached_tantivy_bytes,
254        });
255        self.report()
256    }
257
258    /// Operational summary for `wm doctor` and `/status` (as of now).
259    #[must_use]
260    pub fn report(&self) -> WriteBudgetReport {
261        self.report_as_of(Utc::now())
262    }
263
264    /// Operational summary relative to an explicit instant (tests, replay).
265    #[must_use]
266    pub fn report_as_of(&self, now: DateTime<Utc>) -> WriteBudgetReport {
267        let today = now.date_naive();
268        let today_key = today.to_string();
269        let yesterday_key = (today - Duration::days(1)).to_string();
270        let today_bytes = self.state.days.get(&today_key).map_or(0, |d| d.bytes);
271        let yesterday_bytes = self.state.days.get(&yesterday_key).map(|d| d.bytes);
272
273        // Average over the trailing 30-day window (not the ledger's whole
274        // retention): recent behavior is the comparison that matters.
275        let mut sum = 0u64;
276        let mut counted = 0u32;
277        for offset in 0..AVERAGE_WINDOW_DAYS {
278            let key = (today - Duration::days(offset)).to_string();
279            if let Some(day) = self.state.days.get(&key) {
280                sum = sum.saturating_add(day.bytes);
281                counted += 1;
282            }
283        }
284        let avg = if counted > 0 {
285            sum / u64::from(counted)
286        } else {
287            0
288        };
289
290        let busiest_day = self
291            .state
292            .days
293            .iter()
294            .max_by_key(|(_, d)| d.bytes)
295            .map(|(day, d)| (day.clone(), d.bytes))
296            .filter(|(_, bytes)| *bytes > 0);
297
298        let (lmdb_bytes, tantivy_bytes) = match &self.state.last_sample {
299            Some(last) => (last.lmdb_bytes, last.tantivy_bytes),
300            None => (0, 0),
301        };
302
303        WriteBudgetReport {
304            today_bytes,
305            yesterday_bytes,
306            avg_30d_bytes: avg,
307            days_tracked: u32::try_from(self.state.days.len()).unwrap_or(u32::MAX),
308            busiest_day,
309            lmdb_bytes,
310            tantivy_bytes,
311        }
312    }
313
314    /// JSON rendering for `/status`.
315    #[must_use]
316    pub fn report_json(&self) -> serde_json::Value {
317        let r = self.report();
318        json!({
319            "today_bytes": r.today_bytes,
320            "yesterday_bytes": r.yesterday_bytes,
321            "avg_30d_bytes": r.avg_30d_bytes,
322            "days_tracked": r.days_tracked,
323            "busiest_day": r.busiest_day,
324            "lmdb_bytes": r.lmdb_bytes,
325            "tantivy_bytes": r.tantivy_bytes,
326        })
327    }
328
329    /// Current ledger size on disk (diagnostics).
330    #[must_use]
331    pub fn ledger_path(&self) -> &Path {
332        &self.path
333    }
334
335    /// Year-of-era helper used by tests to build dates portably.
336    #[cfg(test)]
337    const fn base_date() -> NaiveDate {
338        NaiveDate::from_ymd_opt(2026, 8, 28).expect("valid date")
339    }
340}
341
342/// Recursive directory size (bytes). Bounded by what exists; missing dirs
343/// count as zero.
344fn dir_size(path: &Path) -> u64 {
345    let mut total = 0u64;
346    let Ok(entries) = std::fs::read_dir(path) else {
347        return 0;
348    };
349    for entry in entries.flatten() {
350        let Ok(meta) = entry.metadata() else {
351            continue;
352        };
353        if meta.is_dir() {
354            total = total.saturating_add(dir_size(&entry.path()));
355        } else {
356            total = total.saturating_add(meta.len());
357        }
358    }
359    total
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365    use chrono::{Datelike, TimeZone};
366
367    fn at(day: NaiveDate, hour: u32) -> DateTime<Utc> {
368        Utc.with_ymd_and_hms(day.year(), day.month(), day.day(), hour, 0, 0)
369            .single()
370            .expect("valid test time")
371    }
372
373    /// Store root with a data.mdb and a tantivy dir whose sizes the test
374    /// controls directly.
375    fn fake_store() -> (tempfile::TempDir, PathBuf) {
376        let dir = tempfile::tempdir().unwrap();
377        let root = dir.path().to_path_buf();
378        std::fs::create_dir_all(root.join("lmdb/tantivy")).unwrap();
379        (dir, root)
380    }
381
382    fn write_sizes(root: &Path, lmdb: u64, tantivy: u64) {
383        let mdb = root.join("lmdb/data.mdb");
384        if mdb.exists() {
385            std::fs::remove_file(&mdb).unwrap();
386        }
387        std::fs::write(&mdb, vec![0u8; lmdb as usize]).unwrap();
388        let seg = root.join("lmdb/tantivy/seg.0");
389        if seg.exists() {
390            std::fs::remove_file(&seg).unwrap();
391        }
392        std::fs::write(&seg, vec![0u8; tantivy as usize]).unwrap();
393    }
394
395    #[test]
396    fn first_observation_baselines_without_attributing() {
397        let (_dir, root) = fake_store();
398        write_sizes(&root, 1_000, 500);
399        let mut ledger = WriteBudgetLedger::load(&root);
400        let delta = ledger.observe(at(WriteBudgetLedger::base_date(), 10));
401        assert_eq!(delta.attributed_bytes, 0, "first sample is a baseline");
402        assert_eq!(ledger.report().lmdb_bytes, 1_000);
403        assert_eq!(ledger.report().tantivy_bytes, 500);
404    }
405
406    #[test]
407    fn growth_attributed_to_current_day() {
408        let (_dir, root) = fake_store();
409        write_sizes(&root, 1_000, 500);
410        let mut ledger = WriteBudgetLedger::load(&root);
411        ledger.observe(at(WriteBudgetLedger::base_date(), 10));
412
413        write_sizes(&root, 1_000 + 4_200, 500 + 100);
414        let delta = ledger.observe(at(WriteBudgetLedger::base_date(), 11));
415        assert_eq!(delta.attributed_bytes, 4_300);
416        // Day-sensitive assertions pin the clock — report() is wall-clock
417        // based, and a UTC rollover mid-suite must not flip these.
418        let report = ledger.report_as_of(at(WriteBudgetLedger::base_date(), 11));
419        assert_eq!(report.today_bytes, 4_300);
420        assert_eq!(report.yesterday_bytes, None);
421        assert_eq!(report.busiest_day, Some(("2026-08-28".into(), 4_300)));
422    }
423
424    #[test]
425    fn shrink_attributed_as_zero_not_negative() {
426        let (_dir, root) = fake_store();
427        write_sizes(&root, 10_000, 0);
428        let mut ledger = WriteBudgetLedger::load(&root);
429        ledger.observe(at(WriteBudgetLedger::base_date(), 10));
430        write_sizes(&root, 500, 0); // compaction shrank the store
431        let delta = ledger.observe(at(WriteBudgetLedger::base_date(), 11));
432        assert_eq!(delta.attributed_bytes, 0, "never credit deletes as writes");
433        assert_eq!(
434            ledger
435                .report_as_of(at(WriteBudgetLedger::base_date(), 11))
436                .today_bytes,
437            0
438        );
439    }
440
441    #[test]
442    fn utc_midnight_rollover_starts_new_day() {
443        let (_dir, root) = fake_store();
444        write_sizes(&root, 1_000, 0);
445        let mut ledger = WriteBudgetLedger::load(&root);
446        ledger.observe(at(WriteBudgetLedger::base_date(), 10));
447
448        // Give the first day real attributed growth.
449        write_sizes(&root, 3_000, 0);
450        ledger.observe(at(WriteBudgetLedger::base_date(), 22));
451
452        write_sizes(&root, 4_000, 0);
453        let next = WriteBudgetLedger::base_date().succ_opt().unwrap();
454        ledger.observe(at(next, 1));
455        let report = ledger.report_as_of(at(next, 1));
456        assert_eq!(report.today_bytes, 1_000);
457        assert_eq!(report.yesterday_bytes, Some(2_000));
458    }
459
460    #[test]
461    fn tantivy_walk_throttled_within_interval() {
462        let (_dir, root) = fake_store();
463        write_sizes(&root, 1_000, 100);
464        let mut ledger = WriteBudgetLedger::load(&root);
465        ledger.observe(at(WriteBudgetLedger::base_date(), 10));
466
467        // Grow tantivy and observe 1s later — inside the 5-min walk window,
468        // so the cached size (100) is used and only the baseline shift shows.
469        write_sizes(&root, 1_000, 9_999);
470        let delta = ledger.observe(at(WriteBudgetLedger::base_date(), 10) + Duration::seconds(1));
471        assert_eq!(
472            delta.attributed_bytes, 0,
473            "stale tantivy cache must not over-attribute"
474        );
475
476        // After the walk window the new size is observed.
477        let delta = ledger.observe(at(WriteBudgetLedger::base_date(), 10) + Duration::seconds(301));
478        assert_eq!(delta.attributed_bytes, 9_899);
479    }
480
481    #[test]
482    fn ledger_roundtrips_and_recovers_from_corruption() {
483        let (_dir, root) = fake_store();
484        write_sizes(&root, 1_000, 0);
485        let mut ledger = WriteBudgetLedger::load(&root);
486        ledger.observe(at(WriteBudgetLedger::base_date(), 10));
487        write_sizes(&root, 5_000, 0);
488        ledger.observe(at(WriteBudgetLedger::base_date(), 12));
489
490        // Roundtrip: a fresh load sees the recorded history (clock pinned).
491        let reloaded = WriteBudgetLedger::load(&root);
492        assert_eq!(
493            reloaded
494                .report_as_of(at(WriteBudgetLedger::base_date(), 12))
495                .today_bytes,
496            4_000
497        );
498
499        // Corruption: a fresh load starts clean instead of panicking.
500        std::fs::write(root.join("write_budget.json"), "{not json").unwrap();
501        let corrupted = WriteBudgetLedger::load(&root);
502        assert_eq!(corrupted.report().days_tracked, 0);
503    }
504
505    #[test]
506    fn retention_prunes_old_days() {
507        let (_dir, root) = fake_store();
508        write_sizes(&root, 1_000, 0);
509        let mut ledger = WriteBudgetLedger::load(&root);
510        ledger.observe(at(WriteBudgetLedger::base_date(), 10));
511
512        // Hand-write an ancient day and a real recent day into the ledger.
513        let ancient = (WriteBudgetLedger::base_date() - Duration::days(120)).to_string();
514        ledger.state.days.insert(
515            ancient.clone(),
516            DayWrites {
517                bytes: 7,
518                samples: 1,
519            },
520        );
521        ledger.state.days.insert(
522            "2026-08-28".into(),
523            DayWrites {
524                bytes: 5,
525                samples: 1,
526            },
527        );
528        ledger.prune(WriteBudgetLedger::base_date());
529        assert!(!ledger.state.days.contains_key(&ancient), "old days prune");
530        assert!(
531            ledger.state.days.contains_key("2026-08-28"),
532            "recent days survive"
533        );
534    }
535}