Skip to main content

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}