zuka
zuka/src/store/cached.rs

worklyn / zukapublic

Agent-first git hosting. One Rust binary: git over HTTP and SSH, a REST API, MCP, CI, and multi-tenant isolation.

Get a copy: git clone https://zuka.worklyn.com/worklyn/zuka.git
zuka/src/store/cached.rs
RScached.rs5.7 KBDownload
1// A file parsed once and re-parsed only when it changes on disk.
2//
3// Two failure modes this exists to avoid, both real:
4//
5// * Re-reading and re-parsing per request puts a blocking read on a runtime
6// worker for every request, and turns one bad byte into a 500 on every
7// request — including ones presenting a valid credential.
8// * Parsing only at boot means a credential added by the CLI does not work
9// until the service is restarted, which is a surprising thing to discover
10// in production.
11//
12// So: stat on access (cheap), re-parse only when mtime or length moved, and on a
13// parse error keep the last good value rather than failing open or failing shut.
14
15use crate::error::Result;
16use std::path::{Path, PathBuf};
17use std::sync::RwLock;
18use std::time::SystemTime;
19
20/// What was observed about the file when it was last parsed.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22struct Stamp {
23 modified: Option<SystemTime>,
24 len: u64,
25}
26
27impl Stamp {
28 fn of(path: &Path) -> Self {
29 match std::fs::metadata(path) {
30 Ok(meta) => Stamp {
31 modified: meta.modified().ok(),
32 len: meta.len(),
33 },
34 Err(_) => Stamp {
35 modified: None,
36 len: 0,
37 },
38 }
39 }
40}
41
42pub struct Cached<T> {
43 path: PathBuf,
44 inner: RwLock<(Stamp, T)>,
45 parse: fn(&Path) -> Result<T>,
46}
47
48impl<T: Clone> Cached<T> {
49 /// Parse once at construction, so a broken file fails boot loudly rather than
50 /// surfacing later as a confusing 401.
51 pub fn load(path: PathBuf, parse: fn(&Path) -> Result<T>) -> Result<Self> {
52 let stamp = Stamp::of(&path);
53 let value = parse(&path)?;
54 Ok(Cached {
55 path,
56 inner: RwLock::new((stamp, value)),
57 parse,
58 })
59 }
60
61 /// The current value, re-parsing first if the file changed.
62 pub fn get(&self) -> T {
63 let stamp = Stamp::of(&self.path);
64 {
65 let guard = self.inner.read().expect("cache lock is never poisoned");
66 if guard.0 == stamp {
67 return guard.1.clone();
68 }
69 }
70
71 let mut guard = self.inner.write().expect("cache lock is never poisoned");
72 // Another writer may have refreshed while this thread waited for the lock.
73 if guard.0 == stamp {
74 return guard.1.clone();
75 }
76
77 match (self.parse)(&self.path) {
78 Ok(fresh) => {
79 *guard = (stamp, fresh);
80 }
81 Err(e) => {
82 // Keeping the last good value is the safe direction: a half-written
83 // file must not revoke every credential at once.
84 eprintln!(
85 "[cache] {} changed but did not parse, keeping the previous contents: {e}",
86 self.path.display()
87 );
88 }
89 }
90 guard.1.clone()
91 }
92
93 /// Replace the value and its file together.
94 pub fn put(&self, value: T, write: impl FnOnce(&Path) -> Result<()>) -> Result<()> {
95 let mut guard = self.inner.write().expect("cache lock is never poisoned");
96 write(&self.path)?;
97 *guard = (Stamp::of(&self.path), value);
98 Ok(())
99 }
100}
101
102#[cfg(test)]
103mod tests {
104 use super::*;
105
106 fn read_text(path: &Path) -> Result<String> {
107 match std::fs::read_to_string(path) {
108 Ok(text) if text.starts_with("bad") => Err(crate::error::Error::Internal(
109 anyhow::anyhow!("refusing to parse {text:?}"),
110 )),
111 Ok(text) => Ok(text),
112 Err(_) => Ok(String::new()),
113 }
114 }
115
116 #[test]
117 fn picks_up_a_change_made_after_loading() {
118 let dir = tempfile::tempdir().unwrap();
119 let path = dir.path().join("f");
120 std::fs::write(&path, "first").unwrap();
121
122 let cached = Cached::load(path.clone(), read_text).unwrap();
123 assert_eq!(cached.get(), "first");
124
125 // The CLI writing a new credential while the server runs.
126 std::fs::write(&path, "second-and-longer").unwrap();
127 assert_eq!(
128 cached.get(),
129 "second-and-longer",
130 "a credential added while running must take effect"
131 );
132 }
133
134 #[test]
135 fn a_file_that_stops_parsing_keeps_the_last_good_value() {
136 let dir = tempfile::tempdir().unwrap();
137 let path = dir.path().join("f");
138 std::fs::write(&path, "good").unwrap();
139
140 let cached = Cached::load(path.clone(), read_text).unwrap();
141 assert_eq!(cached.get(), "good");
142
143 std::fs::write(&path, "bad-and-much-longer").unwrap();
144 assert_eq!(
145 cached.get(),
146 "good",
147 "a half-written file must not revoke every credential"
148 );
149 }
150
151 #[test]
152 fn an_unchanged_file_is_not_reparsed() {
153 use std::sync::atomic::{AtomicUsize, Ordering};
154 static PARSES: AtomicUsize = AtomicUsize::new(0);
155
156 fn counting(path: &Path) -> Result<String> {
157 PARSES.fetch_add(1, Ordering::SeqCst);
158 Ok(std::fs::read_to_string(path).unwrap_or_default())
159 }
160
161 let dir = tempfile::tempdir().unwrap();
162 let path = dir.path().join("f");
163 std::fs::write(&path, "x").unwrap();
164
165 let cached = Cached::load(path, counting).unwrap();
166 for _ in 0..10 {
167 cached.get();
168 }
169 assert_eq!(
170 PARSES.load(Ordering::SeqCst),
171 1,
172 "a stable file must be parsed once, not per access"
173 );
174 }
175
176 #[test]
177 fn a_broken_file_fails_at_load_rather_than_later() {
178 let dir = tempfile::tempdir().unwrap();
179 let path = dir.path().join("f");
180 std::fs::write(&path, "bad").unwrap();
181
182 assert!(Cached::load(path, read_text).is_err());
183 }
184}