Skip to main content

runmat_runtime/analysis/
storage.rs

1use std::collections::HashMap;
2use std::io::ErrorKind;
3use std::path::{Path, PathBuf};
4use std::sync::atomic::{AtomicU64, Ordering};
5use std::sync::{Arc, OnceLock, RwLock};
6
7use chrono::{DateTime, Utc};
8use runmat_filesystem::{DirEntry, FsFileType};
9use serde::{Deserialize, Serialize};
10
11use super::contracts::{AnalysisArtifactRecord, AnalysisRunResult};
12
13const ARTIFACT_SCHEMA_VERSION: &str = "analysis_run_artifact/v1";
14
15#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
16struct PersistedRunArtifact {
17    schema_version: String,
18    created_at: String,
19    op_version: String,
20    run: AnalysisRunResult,
21}
22
23pub trait AnalysisArtifactStore: Send + Sync {
24    fn persist_run(&self, run: &AnalysisRunResult) -> Result<AnalysisArtifactRecord, String>;
25    fn load_run(&self, run_id: &str) -> Result<Option<AnalysisRunResult>, String>;
26    fn list_runs(&self) -> Result<Vec<AnalysisRunResult>, String>;
27}
28
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum AnalysisArtifactStoreConfig {
31    InMemory,
32    Filesystem { root: PathBuf },
33}
34
35#[derive(Debug, Clone, Default, PartialEq, Eq)]
36pub struct AnalysisArtifactRetentionConfig {
37    pub max_runs: Option<usize>,
38    pub max_runs_per_kind: Option<usize>,
39}
40
41pub struct InMemoryAnalysisArtifactStore {
42    runs: RwLock<HashMap<String, AnalysisRunResult>>,
43}
44
45impl InMemoryAnalysisArtifactStore {
46    pub fn new() -> Self {
47        Self {
48            runs: RwLock::new(HashMap::new()),
49        }
50    }
51}
52
53impl Default for InMemoryAnalysisArtifactStore {
54    fn default() -> Self {
55        Self::new()
56    }
57}
58
59impl AnalysisArtifactStore for InMemoryAnalysisArtifactStore {
60    fn persist_run(&self, run: &AnalysisRunResult) -> Result<AnalysisArtifactRecord, String> {
61        let mut guard = self
62            .runs
63            .write()
64            .map_err(|_| "analysis artifact store lock poisoned".to_string())?;
65        guard.insert(run.run_id.clone(), run.clone());
66        Ok(AnalysisArtifactRecord {
67            run_id: run.run_id.clone(),
68            created_at: Utc::now().to_rfc3339(),
69            op_version: run_operation_version(run),
70            field_ids: super::analysis_run_field_ids(run),
71        })
72    }
73
74    fn load_run(&self, run_id: &str) -> Result<Option<AnalysisRunResult>, String> {
75        let guard = self
76            .runs
77            .read()
78            .map_err(|_| "analysis artifact store lock poisoned".to_string())?;
79        Ok(guard.get(run_id).cloned())
80    }
81
82    fn list_runs(&self) -> Result<Vec<AnalysisRunResult>, String> {
83        let guard = self
84            .runs
85            .read()
86            .map_err(|_| "analysis artifact store lock poisoned".to_string())?;
87        Ok(guard.values().cloned().collect())
88    }
89}
90
91pub struct FilesystemAnalysisArtifactStore {
92    root: PathBuf,
93}
94
95impl FilesystemAnalysisArtifactStore {
96    pub fn new(root: PathBuf) -> Self {
97        Self { root }
98    }
99
100    fn run_path(&self, run_id: &str) -> PathBuf {
101        self.root.join("runs").join(format!("{run_id}.json"))
102    }
103}
104
105impl AnalysisArtifactStore for FilesystemAnalysisArtifactStore {
106    fn persist_run(&self, run: &AnalysisRunResult) -> Result<AnalysisArtifactRecord, String> {
107        let path = self.run_path(&run.run_id);
108        if let Some(parent) = path.parent() {
109            fs_create_dir_all(parent)
110                .map_err(|err| format!("failed to create artifact directory: {err}"))?;
111        }
112        let op_version = run_operation_version(run);
113        let created_at = Utc::now().to_rfc3339();
114        let persisted = PersistedRunArtifact {
115            schema_version: ARTIFACT_SCHEMA_VERSION.to_string(),
116            created_at: created_at.clone(),
117            op_version: op_version.clone(),
118            run: run.clone(),
119        };
120        let bytes = serde_json::to_vec_pretty(&persisted)
121            .map_err(|err| format!("failed to encode run artifact: {err}"))?;
122        atomic_write(&path, &bytes)?;
123        prune_filesystem_runs(&self.root)?;
124
125        Ok(AnalysisArtifactRecord {
126            run_id: run.run_id.clone(),
127            created_at,
128            op_version,
129            field_ids: super::analysis_run_field_ids(run),
130        })
131    }
132
133    fn load_run(&self, run_id: &str) -> Result<Option<AnalysisRunResult>, String> {
134        let path = self.run_path(run_id);
135        if !fs_exists(&path).map_err(|err| format!("failed to inspect run artifact: {err}"))? {
136            return Ok(None);
137        }
138        let bytes = fs_read(&path).map_err(|err| format!("failed to read run artifact: {err}"))?;
139        let run = match serde_json::from_slice::<PersistedRunArtifact>(&bytes) {
140            Ok(persisted) => persisted.run,
141            Err(_) => serde_json::from_slice::<AnalysisRunResult>(&bytes)
142                .map_err(|err| format!("failed to parse run artifact: {err}"))?,
143        };
144        Ok(Some(run))
145    }
146
147    fn list_runs(&self) -> Result<Vec<AnalysisRunResult>, String> {
148        let runs_dir = self.root.join("runs");
149        if !fs_exists(&runs_dir).map_err(|err| format!("failed to inspect artifacts: {err}"))? {
150            return Ok(Vec::new());
151        }
152        let mut runs = Vec::new();
153        for entry in
154            fs_read_dir(&runs_dir).map_err(|err| format!("failed to scan artifacts: {err}"))?
155        {
156            let path = entry.path().to_path_buf();
157            if path.extension().and_then(|ext| ext.to_str()) != Some("json") {
158                continue;
159            }
160            let bytes =
161                fs_read(&path).map_err(|err| format!("failed to read run artifact: {err}"))?;
162            let parsed = match serde_json::from_slice::<PersistedRunArtifact>(&bytes) {
163                Ok(persisted) => Some(persisted.run),
164                Err(_) => serde_json::from_slice::<AnalysisRunResult>(&bytes).ok(),
165            };
166            if let Some(run) = parsed {
167                runs.push(run);
168            }
169        }
170        Ok(runs)
171    }
172}
173
174#[derive(Debug)]
175struct FilesystemRunArtifact {
176    path: PathBuf,
177    op_version: String,
178    run_id: String,
179    created_at: Option<DateTime<Utc>>,
180    modified_at: Option<DateTime<Utc>>,
181}
182
183impl FilesystemRunArtifact {
184    fn recorded_at(&self) -> Option<DateTime<Utc>> {
185        self.created_at.or(self.modified_at)
186    }
187}
188
189fn generated_run_id_order(run_id: &str) -> Option<(i64, u64)> {
190    let suffix = run_id.strip_prefix("run_")?;
191    let (timestamp_millis, sequence) = suffix.rsplit_once('_')?;
192    Some((timestamp_millis.parse().ok()?, sequence.parse().ok()?))
193}
194
195fn sort_filesystem_artifacts_newest_first(artifacts: &mut [FilesystemRunArtifact]) {
196    artifacts.sort_by(|a, b| {
197        b.recorded_at()
198            .cmp(&a.recorded_at())
199            .then_with(|| generated_run_id_order(&b.run_id).cmp(&generated_run_id_order(&a.run_id)))
200            .then_with(|| b.run_id.cmp(&a.run_id))
201            .then_with(|| b.path.cmp(&a.path))
202    });
203}
204
205fn run_operation_version(run: &AnalysisRunResult) -> String {
206    if run
207        .run
208        .diagnostics
209        .iter()
210        .any(|diag| diag.code == "FEA_ACOUSTIC_HARMONIC_RESPONSE")
211    {
212        "fea.run_acoustic/v1".to_string()
213    } else if run.electromagnetic_results.is_some()
214        || run
215            .run
216            .diagnostics
217            .iter()
218            .any(|diag| diag.code == "FEA_EM_STATIC")
219    {
220        "fea.run_electromagnetic/v1".to_string()
221    } else if run
222        .run
223        .diagnostics
224        .iter()
225        .any(|diag| diag.code == "FEA_CHT_COUPLING")
226    {
227        "fea.run_cht/v1".to_string()
228    } else if run
229        .run
230        .diagnostics
231        .iter()
232        .any(|diag| diag.code == "FEA_FSI_COUPLING")
233    {
234        "fea.run_fsi/v1".to_string()
235    } else if run
236        .run
237        .diagnostics
238        .iter()
239        .any(|diag| diag.code == "FEA_CFD_FLOW")
240    {
241        "fea.run_cfd/v1".to_string()
242    } else if run.nonlinear_results.is_some() {
243        "fea.run_nonlinear/v1".to_string()
244    } else if run.transient_results.is_some() {
245        "fea.run_transient/v1".to_string()
246    } else if run.modal_results.is_some() {
247        "fea.run_modal/v1".to_string()
248    } else {
249        "fea.run_linear_static/v1".to_string()
250    }
251}
252
253fn prune_filesystem_runs(root: &Path) -> Result<(), String> {
254    let retention = current_retention_config();
255    let max_runs = retention.max_runs.unwrap_or_else(|| {
256        std::env::var("RUNMAT_FEA_ARTIFACT_MAX_RUNS")
257            .or_else(|_| std::env::var("RUNMAT_ANALYSIS_ARTIFACT_MAX_RUNS"))
258            .ok()
259            .and_then(|value| value.parse::<usize>().ok())
260            .unwrap_or(0)
261    });
262    let max_runs_per_kind = retention.max_runs_per_kind.unwrap_or_else(|| {
263        std::env::var("RUNMAT_FEA_ARTIFACT_MAX_RUNS_PER_KIND")
264            .or_else(|_| std::env::var("RUNMAT_ANALYSIS_ARTIFACT_MAX_RUNS_PER_KIND"))
265            .ok()
266            .and_then(|value| value.parse::<usize>().ok())
267            .unwrap_or(0)
268    });
269    if max_runs == 0 && max_runs_per_kind == 0 {
270        return Ok(());
271    }
272
273    let runs_dir = root.join("runs");
274    if !fs_exists(&runs_dir).map_err(|err| format!("failed to inspect artifacts: {err}"))? {
275        return Ok(());
276    }
277    let mut artifacts = Vec::new();
278    for entry in fs_read_dir(&runs_dir).map_err(|err| format!("failed to scan artifacts: {err}"))? {
279        let path = entry.path().to_path_buf();
280        if path.extension().and_then(|ext| ext.to_str()) != Some("json") {
281            continue;
282        }
283        let bytes = fs_read(&path).map_err(|err| format!("failed to read artifact file: {err}"))?;
284        let (op_version, run_id, created_at) =
285            match serde_json::from_slice::<PersistedRunArtifact>(&bytes) {
286                Ok(persisted) => (
287                    persisted.op_version,
288                    persisted.run.run_id,
289                    DateTime::parse_from_rfc3339(&persisted.created_at)
290                        .ok()
291                        .map(|value| value.with_timezone(&Utc)),
292                ),
293                Err(_) => match serde_json::from_slice::<AnalysisRunResult>(&bytes) {
294                    Ok(run) => (run_operation_version(&run), run.run_id, None),
295                    Err(_) => continue,
296                },
297            };
298        let modified_at = fs_modified(&path).ok().flatten().map(DateTime::<Utc>::from);
299        artifacts.push(FilesystemRunArtifact {
300            path,
301            op_version,
302            run_id,
303            created_at,
304            modified_at,
305        });
306    }
307    sort_filesystem_artifacts_newest_first(&mut artifacts);
308
309    let mut to_remove = Vec::new();
310    if max_runs_per_kind > 0 {
311        let mut per_kind_counts: HashMap<String, usize> = HashMap::new();
312        for artifact in &artifacts {
313            let count = per_kind_counts
314                .entry(artifact.op_version.clone())
315                .or_default();
316            *count += 1;
317            if *count > max_runs_per_kind {
318                to_remove.push(artifact.path.clone());
319            }
320        }
321    }
322    if max_runs > 0 {
323        for (index, artifact) in artifacts.iter().enumerate() {
324            if index >= max_runs {
325                to_remove.push(artifact.path.clone());
326            }
327        }
328    }
329    to_remove.sort();
330    to_remove.dedup();
331    for path in to_remove {
332        let _ = fs_remove_file(path);
333    }
334    Ok(())
335}
336
337fn global_store() -> &'static RwLock<Arc<dyn AnalysisArtifactStore>> {
338    static STORE: OnceLock<RwLock<Arc<dyn AnalysisArtifactStore>>> = OnceLock::new();
339    STORE.get_or_init(|| {
340        let default = store_from_config(config_from_env());
341        RwLock::new(default)
342    })
343}
344
345fn retention_config() -> &'static RwLock<AnalysisArtifactRetentionConfig> {
346    static CONFIG: OnceLock<RwLock<AnalysisArtifactRetentionConfig>> = OnceLock::new();
347    CONFIG.get_or_init(|| RwLock::new(AnalysisArtifactRetentionConfig::default()))
348}
349
350fn current_retention_config() -> AnalysisArtifactRetentionConfig {
351    retention_config()
352        .read()
353        .map(|guard| guard.clone())
354        .unwrap_or_default()
355}
356
357static NEXT_RUN_ID: AtomicU64 = AtomicU64::new(1);
358
359pub fn next_run_id() -> String {
360    let seq = NEXT_RUN_ID.fetch_add(1, Ordering::Relaxed);
361    format!("run_{}_{}", Utc::now().timestamp_millis(), seq)
362}
363
364pub fn persist_run_result(run: &AnalysisRunResult) -> Result<AnalysisArtifactRecord, String> {
365    let guard = global_store()
366        .read()
367        .map_err(|_| "analysis artifact store lock poisoned".to_string())?;
368    guard.persist_run(run)
369}
370
371pub fn load_run_result(run_id: &str) -> Result<Option<AnalysisRunResult>, String> {
372    let guard = global_store()
373        .read()
374        .map_err(|_| "analysis artifact store lock poisoned".to_string())?;
375    guard.load_run(run_id)
376}
377
378pub fn list_run_results() -> Result<Vec<AnalysisRunResult>, String> {
379    let guard = global_store()
380        .read()
381        .map_err(|_| "analysis artifact store lock poisoned".to_string())?;
382    guard.list_runs()
383}
384
385pub fn configure_artifact_store(config: AnalysisArtifactStoreConfig) -> Result<(), String> {
386    let mut guard = global_store()
387        .write()
388        .map_err(|_| "analysis artifact store lock poisoned".to_string())?;
389    *guard = store_from_config(config);
390    Ok(())
391}
392
393pub fn configure_artifact_retention(config: AnalysisArtifactRetentionConfig) -> Result<(), String> {
394    let mut guard = retention_config()
395        .write()
396        .map_err(|_| "analysis artifact retention config lock poisoned".to_string())?;
397    *guard = config;
398    Ok(())
399}
400
401pub fn configure_artifact_store_from_env() -> Result<(), String> {
402    configure_artifact_store(config_from_env())
403}
404
405fn store_from_config(config: AnalysisArtifactStoreConfig) -> Arc<dyn AnalysisArtifactStore> {
406    match config {
407        AnalysisArtifactStoreConfig::InMemory => Arc::new(InMemoryAnalysisArtifactStore::new()),
408        AnalysisArtifactStoreConfig::Filesystem { root } => {
409            Arc::new(FilesystemAnalysisArtifactStore::new(root))
410        }
411    }
412}
413
414fn config_from_env() -> AnalysisArtifactStoreConfig {
415    let mode = std::env::var("RUNMAT_FEA_ARTIFACT_STORE")
416        .or_else(|_| std::env::var("RUNMAT_ANALYSIS_ARTIFACT_STORE"))
417        .unwrap_or_else(|_| "filesystem".to_string())
418        .to_lowercase();
419    if mode == "filesystem" {
420        let root = std::env::var("RUNMAT_FEA_ARTIFACT_ROOT")
421            .or_else(|_| std::env::var("RUNMAT_ANALYSIS_ARTIFACT_ROOT"))
422            .map(PathBuf::from)
423            .unwrap_or_else(|_| default_filesystem_artifact_root());
424        AnalysisArtifactStoreConfig::Filesystem { root }
425    } else {
426        AnalysisArtifactStoreConfig::InMemory
427    }
428}
429
430pub fn default_filesystem_artifact_root() -> PathBuf {
431    PathBuf::from("artifacts")
432}
433
434fn atomic_write(path: &PathBuf, bytes: &[u8]) -> Result<(), String> {
435    let tmp = path.with_extension(format!(
436        "tmp-{}-{}",
437        std::process::id(),
438        Utc::now().timestamp_nanos_opt().unwrap_or_default()
439    ));
440    fs_write(&tmp, bytes).map_err(|err| format!("failed to write temp artifact file: {err}"))?;
441    fs_rename(&tmp, path).map_err(|err| {
442        let _ = fs_remove_file(&tmp);
443        format!("failed to atomically replace run artifact: {err}")
444    })
445}
446
447fn fs_create_dir_all(path: impl Into<PathBuf>) -> std::io::Result<()> {
448    runmat_filesystem::create_dir_all(path.into())
449}
450
451fn fs_read(path: impl Into<PathBuf>) -> std::io::Result<Vec<u8>> {
452    runmat_filesystem::read(path.into())
453}
454
455fn fs_write(path: impl Into<PathBuf>, bytes: &[u8]) -> std::io::Result<()> {
456    runmat_filesystem::write(path.into(), bytes)
457}
458
459fn fs_remove_file(path: impl Into<PathBuf>) -> std::io::Result<()> {
460    match runmat_filesystem::remove_file(path.into()) {
461        Ok(()) => Ok(()),
462        Err(err) if err.kind() == ErrorKind::NotFound => Ok(()),
463        Err(err) => Err(err),
464    }
465}
466
467fn fs_rename(from: impl Into<PathBuf>, to: impl Into<PathBuf>) -> std::io::Result<()> {
468    runmat_filesystem::rename(from.into(), to.into())
469}
470
471fn fs_read_dir(path: impl Into<PathBuf>) -> std::io::Result<Vec<DirEntry>> {
472    runmat_filesystem::read_dir(path.into())
473}
474
475fn fs_exists(path: impl Into<PathBuf>) -> std::io::Result<bool> {
476    match runmat_filesystem::metadata(path.into()) {
477        Ok(metadata) => Ok(matches!(
478            metadata.file_type(),
479            FsFileType::Directory | FsFileType::File | FsFileType::Symlink | FsFileType::Other
480        )),
481        Err(err) if err.kind() == ErrorKind::NotFound => Ok(false),
482        Err(err) => Err(err),
483    }
484}
485
486fn fs_modified(path: impl Into<PathBuf>) -> std::io::Result<Option<std::time::SystemTime>> {
487    runmat_filesystem::metadata(path.into()).map(|metadata| metadata.modified())
488}
489
490#[cfg(test)]
491pub fn set_artifact_store_for_tests(store: Arc<dyn AnalysisArtifactStore>) {
492    let mut guard = global_store()
493        .write()
494        .expect("analysis artifact store lock poisoned");
495    *guard = store;
496}
497
498#[cfg(test)]
499pub fn reset_artifact_store_for_tests() {
500    let mut guard = global_store()
501        .write()
502        .expect("analysis artifact store lock poisoned");
503    *guard = Arc::new(InMemoryAnalysisArtifactStore::new());
504    *retention_config()
505        .write()
506        .expect("analysis artifact retention config lock poisoned") =
507        AnalysisArtifactRetentionConfig::default();
508}
509
510#[cfg(test)]
511mod tests {
512    use super::{sort_filesystem_artifacts_newest_first, FilesystemRunArtifact};
513    use chrono::{TimeZone, Utc};
514    use std::path::PathBuf;
515
516    fn artifact(run_id: &str) -> FilesystemRunArtifact {
517        let recorded_at = Utc.timestamp_opt(1_000, 0).single().unwrap();
518        FilesystemRunArtifact {
519            path: PathBuf::from(format!("{run_id}.json")),
520            op_version: "fea.run_nonlinear/v1".to_string(),
521            run_id: run_id.to_string(),
522            created_at: Some(recorded_at),
523            modified_at: Some(recorded_at),
524        }
525    }
526
527    #[test]
528    fn artifact_retention_breaks_timestamp_ties_by_generated_run_sequence() {
529        let mut artifacts = vec![artifact("run_1000_9"), artifact("run_1000_10")];
530
531        sort_filesystem_artifacts_newest_first(&mut artifacts);
532
533        assert_eq!(artifacts[0].run_id, "run_1000_10");
534        assert_eq!(artifacts[1].run_id, "run_1000_9");
535    }
536}