moonlight_core/
storage.rs1use 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;