Skip to main content

heldar_kernel/services/
storage.rs

1//! Storage / disk observability: free space on the recordings filesystem (via statvfs), the
2//! recordings footprint, and a projected retention horizon from the recent write rate.
3
4use std::ffi::CString;
5use std::os::unix::ffi::OsStrExt;
6use std::path::{Path, PathBuf};
7
8use chrono::{DateTime, Duration, Utc};
9use serde::Serialize;
10use sqlx::SqlitePool;
11
12use crate::config::Config;
13
14#[derive(Debug, Clone, Copy, Serialize, Default)]
15pub struct DiskStats {
16    pub total_bytes: u64,
17    pub free_bytes: u64,
18    pub used_bytes: u64,
19    pub used_percent: f64,
20}
21
22/// Free/total bytes of the filesystem backing `path` (statvfs). Returns None if it can't be read.
23pub fn disk_stats(path: &Path) -> Option<DiskStats> {
24    let c_path = CString::new(path.as_os_str().as_bytes()).ok()?;
25    // SAFETY: c_path is a valid NUL-terminated C string; statvfs only reads through the pointer and
26    // writes into the zeroed stack-allocated struct.
27    let stat = unsafe {
28        let mut stat: libc::statvfs = std::mem::zeroed();
29        if libc::statvfs(c_path.as_ptr(), &mut stat) != 0 {
30            return None;
31        }
32        stat
33    };
34    let block = stat.f_frsize as u64;
35    let total = stat.f_blocks as u64 * block;
36    // free_bytes = f_bavail (blocks writable by an unprivileged user): the real write headroom.
37    let free = stat.f_bavail as u64 * block;
38    // used/used_percent use f_bfree (free blocks incl. root-reserved) for a consistent basis with
39    // total, so used_percent matches `df` rather than over-counting the reserved blocks as used.
40    let free_total = stat.f_bfree as u64 * block;
41    let used = total.saturating_sub(free_total);
42    let used_percent = if total > 0 {
43        used as f64 / total as f64 * 100.0
44    } else {
45        0.0
46    };
47    Some(DiskStats {
48        total_bytes: total,
49        free_bytes: free,
50        used_bytes: used,
51        used_percent,
52    })
53}
54
55/// Async wrapper: run the (potentially blocking on network filesystems) statvfs off the runtime.
56pub async fn disk_stats_async(path: PathBuf) -> Option<DiskStats> {
57    tokio::task::spawn_blocking(move || disk_stats(&path))
58        .await
59        .ok()
60        .flatten()
61}
62
63#[derive(Debug, Clone, Serialize)]
64pub struct StorageReport {
65    pub disk: Option<DiskStats>,
66    pub recordings_bytes: i64,
67    pub segment_count: i64,
68    pub oldest_segment: Option<DateTime<Utc>>,
69    pub newest_segment: Option<DateTime<Utc>>,
70    /// Bytes/day written over the last 24h of indexed segments (recent write rate).
71    pub write_rate_bytes_per_day: i64,
72    /// Projected days of free space remaining at the recent write rate (None if unknown/idle).
73    pub projected_days_remaining: Option<f64>,
74}
75
76/// Compute a storage report combining disk stats with the recordings footprint and write rate.
77pub async fn storage_report(pool: &SqlitePool, cfg: &Config) -> sqlx::Result<StorageReport> {
78    let recordings_bytes: i64 =
79        sqlx::query_scalar("SELECT COALESCE(SUM(size_bytes), 0) FROM segments")
80            .fetch_one(pool)
81            .await?;
82    let segment_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM segments")
83        .fetch_one(pool)
84        .await?;
85    let oldest_segment: Option<DateTime<Utc>> =
86        sqlx::query_scalar("SELECT MIN(start_time) FROM segments")
87            .fetch_one(pool)
88            .await?;
89    let newest_segment: Option<DateTime<Utc>> =
90        sqlx::query_scalar("SELECT MAX(end_time) FROM segments")
91            .fetch_one(pool)
92            .await?;
93
94    // Write rate over the last 24h of *recorded* footage (by end_time, not index time, so a
95    // post-restart backfill of old segments doesn't spike the projection).
96    let since = Utc::now() - Duration::hours(24);
97    let last_day_bytes: i64 =
98        sqlx::query_scalar("SELECT COALESCE(SUM(size_bytes), 0) FROM segments WHERE end_time >= ?")
99            .bind(since)
100            .fetch_one(pool)
101            .await?;
102
103    let disk = disk_stats_async(cfg.recordings_dir.clone()).await;
104    let projected_days_remaining = match (disk, last_day_bytes) {
105        (Some(d), rate) if rate > 0 => Some(d.free_bytes as f64 / rate as f64),
106        _ => None,
107    };
108
109    Ok(StorageReport {
110        disk,
111        recordings_bytes,
112        segment_count,
113        oldest_segment,
114        newest_segment,
115        write_rate_bytes_per_day: last_day_bytes,
116        projected_days_remaining,
117    })
118}