1#![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
30const RETENTION_DAYS: i64 = 90;
33const TANTIVY_WALK_INTERVAL_SECS: i64 = 300;
35const AVERAGE_WINDOW_DAYS: i64 = 30;
37
38#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
40pub struct DayWrites {
41 pub bytes: u64,
43 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 days: BTreeMap<String, DayWrites>,
59 last_sample: Option<LastSample>,
60 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub struct WriteBudgetDelta {
78 pub attributed_bytes: u64,
80 pub date: NaiveDate,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct WriteBudgetReport {
87 pub today_bytes: u64,
89 pub yesterday_bytes: Option<u64>,
91 pub avg_30d_bytes: u64,
93 pub days_tracked: u32,
95 pub busiest_day: Option<(String, u64)>,
97 pub lmdb_bytes: u64,
99 pub tantivy_bytes: u64,
101}
102
103#[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 #[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 #[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 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 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 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) });
215 }
216
217 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 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 #[must_use]
260 pub fn report(&self) -> WriteBudgetReport {
261 self.report_as_of(Utc::now())
262 }
263
264 #[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 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 #[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 #[must_use]
331 pub fn ledger_path(&self) -> &Path {
332 &self.path
333 }
334
335 #[cfg(test)]
337 const fn base_date() -> NaiveDate {
338 NaiveDate::from_ymd_opt(2026, 8, 28).expect("valid date")
339 }
340}
341
342fn 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 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 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); 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 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 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 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 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 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 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}