Skip to main content

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}