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}