Skip to main content

moonlight_core/
storage.rs

1use crate::{Classification, ComparisonRun, ComparisonRunListItem, LatencyStats, StatsSummary};
2use std::{
3    path::{Path, PathBuf},
4    sync::Arc,
5};
6use tokio::{
7    fs::{self, File, OpenOptions},
8    io::{AsyncBufReadExt, AsyncWriteExt, BufReader, BufWriter},
9    sync::{Mutex, RwLock},
10};
11use uuid::Uuid;
12
13#[derive(Clone)]
14pub struct Storage {
15    write_path: PathBuf,
16    scan_dir: PathBuf,
17    runs: Arc<RwLock<Vec<ComparisonRun>>>,
18}
19
20#[derive(Clone)]
21pub struct RunWriter {
22    file: Arc<Mutex<BufWriter<File>>>,
23}
24
25impl RunWriter {
26    pub async fn open(write_path: PathBuf) -> anyhow::Result<Self> {
27        if let Some(parent) = write_path.parent() {
28            fs::create_dir_all(parent).await?;
29        }
30        let file = OpenOptions::new()
31            .create(true)
32            .append(true)
33            .open(write_path)
34            .await?;
35
36        Ok(Self {
37            file: Arc::new(Mutex::new(BufWriter::new(file))),
38        })
39    }
40
41    pub async fn append(&self, run: &ComparisonRun) -> anyhow::Result<()> {
42        let line = serde_json::to_string(run)?;
43        let mut file = self.file.lock().await;
44        file.write_all(line.as_bytes()).await?;
45        file.write_all(b"\n").await?;
46        Ok(())
47    }
48
49    pub async fn flush(&self) -> anyhow::Result<()> {
50        self.file.lock().await.flush().await?;
51        Ok(())
52    }
53}
54
55impl Storage {
56    pub async fn load(write_path: PathBuf) -> anyhow::Result<Self> {
57        if let Some(parent) = write_path.parent() {
58            fs::create_dir_all(parent).await?;
59        }
60        let scan_dir = write_path
61            .parent()
62            .map(Path::to_path_buf)
63            .unwrap_or_else(|| PathBuf::from("."));
64        let runs = load_runs_from_dir(&scan_dir).await?;
65
66        Ok(Self {
67            write_path,
68            scan_dir,
69            runs: Arc::new(RwLock::new(runs)),
70        })
71    }
72
73    pub async fn insert(&self, run: ComparisonRun) -> anyhow::Result<()> {
74        let writer = RunWriter::open(self.write_path.clone()).await?;
75        writer.append(&run).await?;
76        writer.flush().await?;
77        self.runs.write().await.push(run);
78        Ok(())
79    }
80
81    pub async fn refresh(&self) -> anyhow::Result<()> {
82        let runs = load_runs_from_dir(&self.scan_dir).await?;
83        *self.runs.write().await = runs;
84        Ok(())
85    }
86
87    pub async fn list(&self) -> Vec<ComparisonRunListItem> {
88        let runs = self.runs.read().await;
89        runs.iter().rev().map(ComparisonRunListItem::from).collect()
90    }
91
92    pub async fn get(&self, id: Uuid) -> Option<ComparisonRun> {
93        let runs = self.runs.read().await;
94        runs.iter().find(|run| run.id == id).cloned()
95    }
96
97    pub async fn stats(&self) -> StatsSummary {
98        let runs = self.runs.read().await;
99        let mut matches = 0;
100        let mut suspicious_differences = 0;
101        let mut reference_noise = 0;
102        let mut suspicious_with_noise = 0;
103        let mut target_errors = 0;
104        let mut primary_total = 0_u128;
105        let mut candidate_total = 0_u128;
106        let mut secondary_latencies = Vec::new();
107
108        for run in runs.iter() {
109            match run.comparison.classification {
110                Classification::Match => matches += 1,
111                Classification::SuspiciousDifference => suspicious_differences += 1,
112                Classification::ReferenceNoise => reference_noise += 1,
113                Classification::SuspiciousWithNoise => suspicious_with_noise += 1,
114                Classification::TargetError => target_errors += 1,
115            }
116            primary_total += run.primary.latency_ms;
117            candidate_total += run.candidate.latency_ms;
118            if let Some(secondary) = &run.secondary {
119                secondary_latencies.push(secondary.latency_ms);
120            }
121        }
122
123        let total_runs = runs.len();
124        StatsSummary {
125            total_runs,
126            matches,
127            suspicious_differences,
128            reference_noise,
129            suspicious_with_noise,
130            target_errors,
131            latency: LatencyStats {
132                primary_avg_ms: avg(total_runs, primary_total),
133                candidate_avg_ms: avg(total_runs, candidate_total),
134                secondary_avg_ms: avg_opt(&secondary_latencies),
135            },
136            latest_runs: runs
137                .iter()
138                .rev()
139                .take(20)
140                .map(ComparisonRunListItem::from)
141                .collect(),
142        }
143    }
144}
145
146async fn load_runs_from_dir(scan_dir: &Path) -> anyhow::Result<Vec<ComparisonRun>> {
147    let mut runs = Vec::new();
148    if !fs::try_exists(scan_dir).await? {
149        return Ok(runs);
150    }
151
152    let mut entries = fs::read_dir(scan_dir).await?;
153    while let Some(entry) = entries.next_entry().await? {
154        let path = entry.path();
155        if path.extension().and_then(|value| value.to_str()) != Some("jsonl") {
156            continue;
157        }
158        load_runs_from_file(&path, &mut runs).await?;
159    }
160
161    runs.sort_by_key(|run| run.timestamp);
162    Ok(runs)
163}
164
165async fn load_runs_from_file(path: &Path, runs: &mut Vec<ComparisonRun>) -> anyhow::Result<()> {
166    let file = fs::File::open(path).await?;
167    let mut lines = BufReader::new(file).lines();
168    while let Some(line) = lines.next_line().await? {
169        if line.trim().is_empty() {
170            continue;
171        }
172        match serde_json::from_str::<ComparisonRun>(&line) {
173            Ok(run) => runs.push(run),
174            Err(error) => eprintln!(
175                "skipping corrupt moonlight JSONL run in {}: {error}",
176                path.display()
177            ),
178        }
179    }
180    Ok(())
181}
182
183fn avg(count: usize, total: u128) -> f64 {
184    if count == 0 {
185        0.0
186    } else {
187        total as f64 / count as f64
188    }
189}
190
191fn avg_opt(values: &[u128]) -> Option<f64> {
192    if values.is_empty() {
193        None
194    } else {
195        Some(values.iter().sum::<u128>() as f64 / values.len() as f64)
196    }
197}
198
199#[cfg(test)]
200mod tests;