onevcs_testing/store.rs
1//! Where a provider's state lives, and the only thing the two flavours differ in.
2//!
3//! Both flavours of each interface are one implementation over one of these, so a
4//! behaviour cannot be taught to the in-memory provider and forgotten on the
5//! file-backed one: there is only one place it could be written.
6
7use std::marker::PhantomData;
8use std::path::{Path, PathBuf};
9use std::sync::atomic::{AtomicU64, Ordering};
10use std::sync::{Arc, Mutex};
11
12use serde::de::DeserializeOwned;
13use serde::Serialize;
14
15use onevcs::{Error, Result};
16
17/// A state that can say whether it is one a provider may act on, and what it
18/// becomes when it arrived at an older version.
19///
20/// Shape is what serde proves, and shape is not enough for a document that came
21/// off a disk: a session token names a file under the state root and a branch name
22/// goes on to spell a ref, so a seeded state that carries an unusable one is
23/// refused where it is read rather than wherever it first happens to be used.
24///
25/// Both halves belong to the same boundary — the read — which is why they are one
26/// trait. A document is refused or it is the current shape; nothing past here has
27/// to ask which version it came in as.
28pub trait Checked {
29 /// Refuse this state, naming what is wrong with it.
30 fn check(&self) -> Result<()>;
31
32 /// Carry a document that declared an older readable version forward to the one
33 /// this build writes.
34 ///
35 /// Called on every read, after [`check`](Checked::check) has decided the
36 /// version is one this build reads at all. It has no default: a state type that
37 /// says nothing about what an older document becomes is one whose next field
38 /// addition writes a document declaring a version it is not.
39 fn carry_forward(&mut self);
40}
41
42/// A provider's state, however it is kept.
43pub trait Store<S> {
44 /// Read the state, act on it, and keep whatever the action left.
45 ///
46 /// One call rather than a read and a write, so a file-backed provider's
47 /// read-modify-write is atomic from a caller's point of view and an action that
48 /// fails leaves the state it started from.
49 fn with<R, F>(&self, act: F) -> Result<R>
50 where
51 F: FnOnce(&mut S) -> Result<R>;
52
53 /// The state as it stands.
54 fn snapshot(&self) -> Result<S>;
55}
56
57/// State held in this process and nowhere else: no disk, and no visibility to a
58/// second process.
59#[derive(Debug)]
60pub struct MemoryStore<S>(Arc<Mutex<S>>);
61
62impl<S> MemoryStore<S> {
63 /// Hold this state in memory.
64 pub fn new(state: S) -> Self {
65 Self(Arc::new(Mutex::new(state)))
66 }
67}
68
69/// A second handle on the *same* state, which is what lets a host factory hand out
70/// a per-repository host that a journey can still read back through the value it
71/// created.
72impl<S> Clone for MemoryStore<S> {
73 fn clone(&self) -> Self {
74 Self(Arc::clone(&self.0))
75 }
76}
77
78impl<S: Clone> Store<S> for MemoryStore<S> {
79 fn with<R, F>(&self, act: F) -> Result<R>
80 where
81 F: FnOnce(&mut S) -> Result<R>,
82 {
83 // A panic inside an action poisons the lock, and refusing every later call
84 // because an earlier assertion failed would bury the failure a journey is
85 // there to read.
86 let mut guard = self
87 .0
88 .lock()
89 .unwrap_or_else(|poisoned| poisoned.into_inner());
90 act(&mut guard)
91 }
92
93 fn snapshot(&self) -> Result<S> {
94 let guard = self
95 .0
96 .lock()
97 .unwrap_or_else(|poisoned| poisoned.into_inner());
98 Ok(guard.clone())
99 }
100}
101
102/// State held in one JSON document, so a second process — the next `onevcs`
103/// invocation — sees what the first one left.
104#[derive(Debug)]
105pub struct FileStore<S> {
106 path: PathBuf,
107 marker: PhantomData<S>,
108}
109
110impl<S> Clone for FileStore<S> {
111 fn clone(&self) -> Self {
112 Self {
113 path: self.path.clone(),
114 marker: PhantomData,
115 }
116 }
117}
118
119impl<S: Serialize + DeserializeOwned + Checked> FileStore<S> {
120 /// The store at `path`: whatever is already there, or `fallback` written out.
121 ///
122 /// Attaching rather than replacing is what makes a second provider over the
123 /// same path the *same* state — which is the whole reason to keep it in a file.
124 /// A document that is there but is not this shape is refused here rather than
125 /// at whichever call first read it.
126 pub fn attach(path: impl Into<PathBuf>, fallback: &S) -> Result<Self> {
127 let store = Self::at(path)?;
128 let _held = store.held()?;
129 if store.path.exists() {
130 store.snapshot()?;
131 return Ok(store);
132 }
133 store.save(fallback)?;
134 Ok(store)
135 }
136
137 /// The store at `path`, holding this state whatever was there before.
138 ///
139 /// Written eagerly rather than on first use: a journey that reads the document
140 /// before anything has driven the provider must find the scenario it seeded.
141 pub fn replace(path: impl Into<PathBuf>, state: &S) -> Result<Self> {
142 let store = Self::at(path)?;
143 let _held = store.held()?;
144 store.save(state)?;
145 Ok(store)
146 }
147
148 fn at(path: impl Into<PathBuf>) -> Result<Self> {
149 let path = path.into();
150 if let Some(parent) = path
151 .parent()
152 .filter(|parent| !parent.as_os_str().is_empty())
153 {
154 std::fs::create_dir_all(parent).map_err(|e| Error::Invalid {
155 reason: format!("cannot create {}: {e}", parent.display()),
156 })?;
157 }
158 Ok(Self {
159 path,
160 marker: PhantomData,
161 })
162 }
163
164 /// The document this state is kept in.
165 pub fn path(&self) -> &Path {
166 &self.path
167 }
168
169 /// Hold this document's writers off until the guard is dropped.
170 ///
171 /// Two handles over one path are one state, and a write is a read, an action and
172 /// a save: without this, a provider polling from its own thread reads, a journey
173 /// re-seeds, and the provider's save puts back the document it read — the re-seed
174 /// is lost, and a check the journey settled is pending again for as long as the
175 /// watch is willing to wait. An advisory lock on a sibling file, because the
176 /// document itself is renamed over on every save and a lock on it would hold the
177 /// inode nobody reads any more. The file is left in place, since deleting it under
178 /// a waiter would let two writers each lock a different file; it is dot-prefixed
179 /// so a listing of the documents beside it does not count it as one.
180 fn held(&self) -> Result<std::fs::File> {
181 let mut name = std::ffi::OsString::from(".");
182 name.push(self.path.file_name().unwrap_or_default());
183 name.push(".lock");
184 let lock = self.path.with_file_name(name);
185 let refused = |e: std::io::Error| Error::Invalid {
186 reason: format!("cannot lock {}: {e}", lock.display()),
187 };
188 let file = std::fs::OpenOptions::new()
189 .create(true)
190 .truncate(false)
191 .write(true)
192 .open(&lock)
193 .map_err(refused)?;
194 file.lock().map_err(refused)?;
195 Ok(file)
196 }
197
198 fn save(&self, state: &S) -> Result<()> {
199 let json = serde_json::to_string_pretty(state).map_err(|e| Error::Invalid {
200 reason: format!(
201 "cannot serialize the state for {}: {e}",
202 self.path.display()
203 ),
204 })?;
205 // A sibling file renamed over the document, never a truncate-and-write in
206 // place: a provider in another thread or process reads this document while a
207 // journey re-seeds it, and a read that lands between the truncate and the
208 // write sees an empty file and refuses it as the wrong shape. The name is
209 // unique to this save so two concurrent writers never share one.
210 static SAVES: AtomicU64 = AtomicU64::new(0);
211 let mut name = self.path.file_name().unwrap_or_default().to_os_string();
212 name.push(format!(
213 ".{}.{}.tmp",
214 std::process::id(),
215 SAVES.fetch_add(1, Ordering::Relaxed)
216 ));
217 let staged = self.path.with_file_name(name);
218 std::fs::write(&staged, format!("{json}\n"))
219 .and_then(|()| std::fs::rename(&staged, &self.path))
220 .map_err(|e| {
221 // Best effort: the error below is the one that matters.
222 let _ = std::fs::remove_file(&staged);
223 Error::Invalid {
224 reason: format!("cannot write {}: {e}", self.path.display()),
225 }
226 })
227 }
228}
229
230impl<S: Serialize + DeserializeOwned + Checked> Store<S> for FileStore<S> {
231 fn with<R, F>(&self, act: F) -> Result<R>
232 where
233 F: FnOnce(&mut S) -> Result<R>,
234 {
235 let _held = self.held()?;
236 let mut state = self.snapshot()?;
237 let outcome = act(&mut state)?;
238 self.save(&state)?;
239 Ok(outcome)
240 }
241
242 fn snapshot(&self) -> Result<S> {
243 let raw = std::fs::read_to_string(&self.path).map_err(|e| Error::Invalid {
244 reason: format!(
245 "cannot read the provider state at {}: {e}",
246 self.path.display()
247 ),
248 })?;
249 let mut state: S = serde_json::from_str(&raw).map_err(|e| Error::Invalid {
250 reason: format!(
251 "the provider state at {} is not the shape this crate writes: {e}",
252 self.path.display()
253 ),
254 })?;
255 state.check().map_err(|e| Error::Invalid {
256 reason: format!("the provider state at {}: {e}", self.path.display()),
257 })?;
258 // On the way in, not on the way out: everything past this point works with
259 // the shape this build writes, so the next write of a document that arrived
260 // at an older version declares the version it now holds rather than the one
261 // it was seeded at.
262 state.carry_forward();
263 Ok(state)
264 }
265}