rustio_core/admin/schema_cache.rs
1//! Process-local schema cache — 0.7.2.
2//!
3//! The planner / review / executor chain already reads
4//! `rustio.schema.json` on every invocation. What was missing was a
5//! *runtime-visible* reload: the dashboard and suggestion engine
6//! baked their decisions against the compiled `AdminEntry` list, so
7//! after `rustio schema` regenerated the file or after an
8//! `ai apply`, the admin kept showing stale suggestions until the
9//! operator restarted the server.
10//!
11//! This module gives the admin a single refreshable source of truth:
12//! a `RwLock<Option<Schema>>` behind a `OnceLock`. The suggestion
13//! engine reads from here; the `/admin/schema/reload` handler writes
14//! to here; the apply handler writes to here automatically on
15//! success. No restart required.
16//!
17//! ## Safety
18//!
19//! - The cache stores only a parsed [`Schema`] — no handlers, no
20//! mutable project state. Reloading a bad file returns `Err`; the
21//! previous good value stays in the cache.
22//! - Reads take a `RwLock` read guard. Writes take a write guard
23//! and swap the inner `Option`. A poisoned lock falls back to
24//! "cache empty" rather than panic.
25//! - The cache is pure data — it cannot cause file writes, DB
26//! access, or any side effect.
27
28use std::path::Path;
29use std::sync::{OnceLock, RwLock};
30use std::time::SystemTime;
31
32use crate::schema::Schema;
33
34/// Lazily-initialised global. `None` inside the RwLock means the
35/// schema file wasn't present or didn't parse — still a valid
36/// state; the admin degrades to compile-time fallbacks.
37fn cell() -> &'static RwLock<Option<CachedSchema>> {
38 static INSTANCE: OnceLock<RwLock<Option<CachedSchema>>> = OnceLock::new();
39 INSTANCE.get_or_init(|| RwLock::new(initial_load()))
40}
41
42#[derive(Debug, Clone)]
43pub struct CachedSchema {
44 pub schema: Schema,
45 /// Wall-clock timestamp of the load that produced this cached
46 /// value, for the dashboard's "schema loaded at …" line.
47 pub loaded_at: SystemTime,
48}
49
50fn initial_load() -> Option<CachedSchema> {
51 read_current_schema_file().ok().map(|schema| CachedSchema {
52 schema,
53 loaded_at: SystemTime::now(),
54 })
55}
56
57/// Read `rustio.schema.json` from disk and return the parsed value.
58/// Caller decides whether to update the cache.
59fn read_current_schema_file() -> Result<Schema, String> {
60 let path = Path::new("rustio.schema.json");
61 if !path.exists() {
62 return Err("rustio.schema.json not found".into());
63 }
64 let raw = std::fs::read_to_string(path).map_err(|e| format!("read error: {e}"))?;
65 Schema::parse(&raw).map_err(|e| format!("parse error: {e}"))
66}
67
68/// Snapshot of the currently-cached schema, or `None` if the file
69/// is missing / unreadable / unparsable.
70pub fn snapshot() -> Option<CachedSchema> {
71 cell().read().ok().and_then(|g| g.clone())
72}
73
74/// Re-read `rustio.schema.json` and atomically replace the cached
75/// value. Returns the freshly-loaded schema on success; leaves the
76/// previous value intact on any error.
77pub fn refresh() -> Result<CachedSchema, String> {
78 let fresh = read_current_schema_file()?;
79 let mut guard = cell()
80 .write()
81 .map_err(|_| "schema cache lock is poisoned — restart the server to recover".to_string())?;
82 let cached = CachedSchema {
83 schema: fresh,
84 loaded_at: SystemTime::now(),
85 };
86 *guard = Some(cached.clone());
87 Ok(cached)
88}
89
90/// Like [`refresh`] but does not surface errors. Used by the apply
91/// handler to auto-reload after a successful write; the user-visible
92/// [`refresh`] endpoint returns a clear error on failure.
93pub fn refresh_best_effort() {
94 let _ = refresh();
95}
96
97/// Formats `loaded_at` as `YYYY-MM-DD HH:MM:SS UTC` for the
98/// dashboard footer.
99pub fn format_loaded_at(t: SystemTime) -> String {
100 let datetime: chrono::DateTime<chrono::Utc> = t.into();
101 datetime.format("%Y-%m-%d %H:%M:%S UTC").to_string()
102}
103
104#[cfg(test)]
105mod tests {
106 use super::*;
107
108 #[test]
109 fn format_loaded_at_produces_stable_shape() {
110 // Pin a known epoch so the assertion doesn't depend on wall
111 // clock; verifies the format string doesn't regress.
112 let t = SystemTime::UNIX_EPOCH;
113 let s = format_loaded_at(t);
114 assert_eq!(s, "1970-01-01 00:00:00 UTC");
115 }
116
117 #[test]
118 fn snapshot_returns_same_value_when_not_refreshed() {
119 // The cache value is immutable between refreshes, so two
120 // consecutive snapshots see byte-identical data.
121 let a = snapshot();
122 let b = snapshot();
123 // Compare the schema only — loaded_at is identical too
124 // because refresh wasn't called between the two reads.
125 assert_eq!(a.map(|c| c.schema), b.map(|c| c.schema),);
126 }
127}