Skip to main content

rig_memory/
dir.rs

1//! A session kept as files in a directory: `logs/<name>.jsonl` and
2//! `files/<name…>`, its blobs under `files/blobs/`, and the lock file
3//! `.rig-memory.lock` every change takes. The store reads only the two
4//! folders, so whatever else the directory holds is left alone.
5
6use std::fs::{self, File, OpenOptions};
7use std::io::{self, Write};
8use std::path::{Path, PathBuf};
9
10use crate::store::check_leaf;
11use crate::{Files, LogWriteError, SessionError, Store, check_name};
12
13const LOGS: &str = "logs";
14const FILES: &str = "files";
15
16/// The extension of a log.
17const LOG: &str = ".jsonl";
18
19/// What a file being written is called until it is complete; a name ending
20/// in it is refused.
21const PART: &str = ".rig-part";
22
23/// What a folder is renamed to while [`Store::destroy`] removes it.
24const TRASH: &str = ".rig-trash";
25
26/// The file every change of a [`DirStore`] locks, beside its folders. It
27/// is never removed, so every store open on the directory locks the same
28/// file.
29const LOCK: &str = ".rig-memory.lock";
30
31/// A [`Store`] in the directory it was opened on, whose folders are made
32/// as files are written. Each change locks the directory's lock file, so
33/// stores open on one directory, in this process or another, take turns;
34/// reads take no lock. Cloning opens the same directory.
35#[derive(Clone)]
36pub struct DirStore {
37    dir: PathBuf,
38    files: DirFiles,
39}
40
41/// [`Files`] kept as plain files under a directory, each name its path
42/// there, such as data kept outside any session. Folders are made as files
43/// are written, and nothing is written before.
44#[derive(Clone, Debug, PartialEq, Eq)]
45pub struct DirFiles(PathBuf);
46
47impl DirFiles {
48    /// The files under `dir`, which need not exist yet.
49    pub fn new(dir: impl Into<PathBuf>) -> Self {
50        Self(dir.into())
51    }
52
53    /// The directory itself.
54    pub fn path(&self) -> &Path {
55        &self.0
56    }
57
58    fn file(&self, name: &str) -> io::Result<PathBuf> {
59        check_name(name)?;
60        refuse_part(name)?;
61        Ok(self.0.join(name))
62    }
63}
64
65impl DirStore {
66    /// The session in `dir`, which need not exist yet. Nothing is written.
67    ///
68    /// # Errors
69    ///
70    /// [`SessionError::OldLayout`] when `dir` holds a top-level `*.jsonl`
71    /// file, as a session saved by an older rig does; it is left as it is.
72    /// [`SessionError::Io`] when `dir` cannot be listed.
73    pub fn open(dir: impl Into<PathBuf>) -> Result<Self, SessionError> {
74        let dir = dir.into();
75        let old = names(&dir)?
76            .into_iter()
77            .any(|name| name.ends_with(LOG) && dir.join(&name).is_file());
78        if old {
79            return Err(SessionError::OldLayout);
80        }
81        Ok(Self {
82            files: DirFiles::new(dir.join(FILES)),
83            dir,
84        })
85    }
86
87    /// The directory's lock file, locked until it is dropped.
88    fn lock(&self) -> io::Result<File> {
89        fs::create_dir_all(&self.dir)?;
90        let lock = OpenOptions::new()
91            .create(true)
92            .write(true)
93            .truncate(false)
94            .open(self.dir.join(LOCK))?;
95        lock.lock()?;
96        Ok(lock)
97    }
98
99    fn log(&self, name: &str) -> io::Result<PathBuf> {
100        check_leaf(name)?;
101        Ok(self.dir.join(LOGS).join(format!("{name}{LOG}")))
102    }
103}
104
105fn refuse_part(name: &str) -> io::Result<()> {
106    if name.ends_with(PART) {
107        let why = format!("{name:?} ends as a file being written does");
108        return Err(io::Error::new(io::ErrorKind::InvalidInput, why));
109    }
110    Ok(())
111}
112
113/// The names in the directory `dir`; none when it does not exist.
114fn names(dir: &Path) -> io::Result<Vec<String>> {
115    let entries = match fs::read_dir(dir) {
116        Err(failure) if failure.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
117        entries => entries?,
118    };
119    let names = entries.filter_map(|entry| entry.ok()?.file_name().into_string().ok());
120    Ok(names.collect())
121}
122
123/// Removing what is not there succeeds.
124fn absent_is_fine(removed: io::Result<()>) -> io::Result<()> {
125    match removed {
126        Err(failure) if failure.kind() == io::ErrorKind::NotFound => Ok(()),
127        removed => removed,
128    }
129}
130
131/// Writes `bytes` to `path` atomically: to a part file beside it, then
132/// renamed over it.
133fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
134    let part = write_part(path, bytes, false)?;
135    fs::rename(&part, path)
136}
137
138/// As [`write_atomic`], and durably: the part file is synced before the
139/// rename and the folder after it (best effort), so a power loss leaves
140/// the old file or the new, never an empty one.
141fn write_durable(path: &Path, bytes: &[u8]) -> io::Result<()> {
142    let part = write_part(path, bytes, true)?;
143    fs::rename(&part, path)?;
144    if let Some(parent) = path.parent() {
145        let _ = File::open(parent).and_then(|folder| folder.sync_all());
146    }
147    Ok(())
148}
149
150/// Writes `bytes` to the part file beside `path`, whose folder it makes,
151/// synced with `sync`, and returns the part file's path.
152fn write_part(path: &Path, bytes: &[u8], sync: bool) -> io::Result<PathBuf> {
153    if let Some(parent) = path.parent() {
154        fs::create_dir_all(parent)?;
155    }
156    let mut part = path.as_os_str().to_owned();
157    part.push(PART);
158    let part = PathBuf::from(part);
159    let mut file = File::create(&part)?;
160    file.write_all(bytes)?;
161    if sync {
162        file.sync_all()?;
163    }
164    Ok(part)
165}
166
167impl Files for DirFiles {
168    fn write_file(&self, name: &str, bytes: &[u8]) -> io::Result<()> {
169        write_atomic(&self.file(name)?, bytes)
170    }
171
172    fn append_file(&self, name: &str, bytes: &[u8]) -> io::Result<()> {
173        let path = self.file(name)?;
174        if let Some(parent) = path.parent() {
175            fs::create_dir_all(parent)?;
176        }
177        let mut file = OpenOptions::new().create(true).append(true).open(path)?;
178        file.write_all(bytes)
179    }
180
181    fn read_file(&self, name: &str) -> io::Result<Vec<u8>> {
182        fs::read(self.file(name)?)
183    }
184
185    fn remove_file(&self, name: &str) -> io::Result<()> {
186        absent_is_fine(fs::remove_file(self.file(name)?))
187    }
188
189    fn files(&self) -> io::Result<Vec<String>> {
190        let mut files = Vec::new();
191        let mut dirs = vec![(self.0.clone(), String::new())];
192        while let Some((dir, prefix)) = dirs.pop() {
193            for name in names(&dir)? {
194                if name.ends_with(PART) {
195                    continue;
196                }
197                let path = dir.join(&name);
198                let name = format!("{prefix}{name}");
199                if path.is_dir() {
200                    dirs.push((path, format!("{name}/")));
201                } else {
202                    files.push(name);
203                }
204            }
205        }
206        Ok(files)
207    }
208}
209
210/// Refuses with [`LogWriteError::Changed`] unless the file at `path` is
211/// `len` bytes long, 0 when there is none.
212fn check_len(path: &Path, len: u64) -> Result<(), LogWriteError> {
213    let held = match fs::metadata(path) {
214        Err(failure) if failure.kind() == io::ErrorKind::NotFound => 0,
215        metadata => metadata?.len(),
216    };
217    if held != len {
218        return Err(LogWriteError::Changed);
219    }
220    Ok(())
221}
222
223impl Files for DirStore {
224    fn write_file(&self, name: &str, bytes: &[u8]) -> io::Result<()> {
225        let _lock = self.lock()?;
226        self.files.write_file(name, bytes)
227    }
228
229    fn append_file(&self, name: &str, bytes: &[u8]) -> io::Result<()> {
230        let _lock = self.lock()?;
231        self.files.append_file(name, bytes)
232    }
233
234    fn read_file(&self, name: &str) -> io::Result<Vec<u8>> {
235        self.files.read_file(name)
236    }
237
238    fn remove_file(&self, name: &str) -> io::Result<()> {
239        let _lock = self.lock()?;
240        self.files.remove_file(name)
241    }
242
243    fn files(&self) -> io::Result<Vec<String>> {
244        self.files.files()
245    }
246}
247
248impl Store for DirStore {
249    fn logs(&self) -> io::Result<Vec<String>> {
250        let names = names(&self.dir.join(LOGS))?.into_iter();
251        let logs = names.filter_map(|name| Some(name.strip_suffix(LOG)?.to_owned()));
252        Ok(logs.filter(|name| !name.is_empty()).collect())
253    }
254
255    fn read_log(&self, name: &str) -> io::Result<Vec<u8>> {
256        fs::read(self.log(name)?)
257    }
258
259    fn append_log(&self, name: &str, len: u64, bytes: &[u8]) -> Result<(), LogWriteError> {
260        let path = self.log(name)?;
261        let _lock = self.lock()?;
262        check_len(&path, len)?;
263        if let Some(parent) = path.parent() {
264            fs::create_dir_all(parent)?;
265        }
266        let mut log = OpenOptions::new().create(true).append(true).open(path)?;
267        Ok(log.write_all(bytes)?)
268    }
269
270    /// The new log is written beside, synced and renamed over the old one:
271    /// the one synced write, as it is rare and replaces history.
272    fn replace_log(&self, name: &str, len: u64, bytes: &[u8]) -> Result<(), LogWriteError> {
273        let path = self.log(name)?;
274        let _lock = self.lock()?;
275        check_len(&path, len)?;
276        Ok(write_durable(&path, bytes)?)
277    }
278
279    /// Each folder is renamed to a trash name inside the directory, so it
280    /// is gone at once, then removed; a second call removes what a crash
281    /// left of the first. The lock file stays.
282    fn destroy(&self) -> io::Result<()> {
283        let _lock = self.lock()?;
284        for folder in [LOGS, FILES] {
285            let (path, trash) = (
286                self.dir.join(folder),
287                self.dir.join(format!("{folder}{TRASH}")),
288            );
289            if path.exists() {
290                absent_is_fine(fs::remove_dir_all(&trash))?;
291                fs::rename(&path, &trash)?;
292            }
293            absent_is_fine(fs::remove_dir_all(&trash))?;
294        }
295        Ok(())
296    }
297}
298
299#[cfg(test)]
300mod tests;