Skip to main content

hayson_engine/storage/
log_set.rs

1use std::collections::BTreeMap;
2use std::fs;
3use std::io;
4use std::path::{Path, PathBuf};
5
6use super::log_file::{LogFile, Slot};
7
8pub const LOG_SUFFIX: &str = ".jsonl";
9
10enum Layout {
11    /// One log file at exactly this path, held under the [`crate::schema::UNPARTITIONED`] name.
12    Flat(PathBuf),
13    /// One log file per partition value, all inside this directory.
14    Partitioned(PathBuf),
15}
16
17/// Every log file of one table: a single file when the table declares no `partitionBy`, one file
18/// per partition value when it does. Routing only — which file a line goes to and which file a slot
19/// came from; it holds no registry and knows nothing about records, which is why it sits in
20/// `storage/` next to [`LogFile`] rather than in `table/`.
21pub struct LogSet {
22    layout: Layout,
23    logs: BTreeMap<String, LogFile>,
24    /// Passed on to every [`LogFile`] this set opens, now and on a later [`LogSet::discover`], so a
25    /// readonly table holds descriptors that cannot write rather than descriptors that merely never
26    /// do — which is what keeps a readonly open from cutting a half-written tail off somebody
27    /// else's file (see [`LogFile::discard_partial_tail`]).
28    readonly: bool,
29    /// Carried the same way as `readonly`, and for the same reason: a partition file first opened
30    /// by [`LogSet::ensure`]/[`LogSet::discover`], long after the table was configured, has to end
31    /// up with the durability its table was given rather than the [`LogFile`] default.
32    fsync: bool,
33}
34
35/// The partition names `dir` already holds a log file for, sorted. A directory that does not exist
36/// yet is simply a table nothing was written to.
37pub fn partition_names_in(dir: &Path) -> Vec<String> {
38    let Ok(entries) = fs::read_dir(dir) else {
39        return Vec::new();
40    };
41    let mut names: Vec<String> = entries
42        .filter_map(Result::ok)
43        .filter_map(|entry| entry.file_name().into_string().ok())
44        .filter_map(|name| name.strip_suffix(LOG_SUFFIX).map(str::to_string))
45        .filter(|name| !name.is_empty())
46        .collect();
47    names.sort_unstable();
48    names
49}
50
51impl LogSet {
52    /// The layout of a table with no `partitionBy`: the single log file at `path`, created there
53    /// and then when this set is writable and absent — a readonly set refuses a log that is not on
54    /// disk, since it has nothing to create it with.
55    pub fn flat(path: impl AsRef<Path>, readonly: bool) -> io::Result<Self> {
56        let mut set = LogSet {
57            layout: Layout::Flat(path.as_ref().to_path_buf()),
58            logs: BTreeMap::new(),
59            readonly,
60            fsync: true,
61        };
62        set.load(crate::schema::UNPARTITIONED)?;
63        Ok(set)
64    }
65
66    /// The layout of a table that declares `partitionBy`: every `<value>.jsonl` already in `dir`.
67    /// A table whose partitions were never written yet opens with no log file at all — the first
68    /// [`LogSet::append`] creates both the directory and the file it needs.
69    pub fn partitioned(dir: impl AsRef<Path>, readonly: bool) -> io::Result<Self> {
70        let dir = dir.as_ref().to_path_buf();
71        let mut set = LogSet {
72            layout: Layout::Partitioned(dir.clone()),
73            logs: BTreeMap::new(),
74            readonly,
75            fsync: true,
76        };
77        for part in partition_names_in(&dir) {
78            set.load(&part)?;
79        }
80        Ok(set)
81    }
82
83    /// Whether these descriptors can write at all — `false` for a set a readonly open built, which
84    /// refuses an append rather than merely never making one.
85    pub fn is_readonly(&self) -> bool {
86        self.readonly
87    }
88
89    pub fn is_partitioned(&self) -> bool {
90        matches!(self.layout, Layout::Partitioned(_))
91    }
92
93    /// Every open partition, in name order.
94    pub fn parts(&self) -> Vec<String> {
95        self.logs.keys().cloned().collect()
96    }
97
98    pub fn log_of(&self, part: &str) -> Option<&LogFile> {
99        self.logs.get(part)
100    }
101
102    pub fn log_of_mut(&mut self, part: &str) -> Option<&mut LogFile> {
103        self.logs.get_mut(part)
104    }
105
106    pub fn path_of(&self, part: &str) -> PathBuf {
107        match &self.layout {
108            Layout::Flat(path) => path.clone(),
109            Layout::Partitioned(dir) => dir.join(format!("{part}{LOG_SUFFIX}")),
110        }
111    }
112
113    pub fn total_size(&self) -> u64 {
114        self.logs.values().map(LogFile::size).sum()
115    }
116
117    /// The log for `part`, created on disk the first time a record names it.
118    pub fn ensure(&mut self, part: &str) -> io::Result<&mut LogFile> {
119        if !self.logs.contains_key(part) {
120            if let Layout::Partitioned(dir) = &self.layout {
121                fs::create_dir_all(dir)?;
122            }
123            self.load(part)?;
124        }
125        self.logs
126            .get_mut(part)
127            .ok_or_else(|| io::Error::other(format!("partition \"{part}\" is not open")))
128    }
129
130    pub fn append(&mut self, part: &str, lines: &[String]) -> io::Result<Vec<Slot>> {
131        if lines.is_empty() {
132            return Ok(Vec::new());
133        }
134        self.ensure(part)?.append(lines)
135    }
136
137    pub fn read_slice(&self, part: &str, slot: &Slot) -> io::Result<String> {
138        self.logs
139            .get(part)
140            .ok_or_else(|| io::Error::other(format!("partition \"{part}\" is not open")))?
141            .read_slice(slot)
142    }
143
144    /// Sets [`LogFile::set_fsync`] on every partition already open and on every one this set opens
145    /// later — the whole table shares one durability setting, since the catalog declares `sync` per
146    /// table and not per partition.
147    pub fn set_fsync(&mut self, fsync: bool) {
148        self.fsync = fsync;
149        for log in self.logs.values_mut() {
150            log.set_fsync(fsync);
151        }
152    }
153
154    pub fn sync(&self) -> io::Result<()> {
155        for log in self.logs.values() {
156            log.sync()?;
157        }
158        Ok(())
159    }
160
161    /// [`LogFile::refuse_if_replaced`] over every open partition — the whole table is refused as
162    /// soon as one of its files is no longer the one this set holds open.
163    pub(crate) fn refuse_if_replaced(&self) -> io::Result<()> {
164        for log in self.logs.values() {
165            log.refuse_if_replaced()?;
166        }
167        Ok(())
168    }
169
170    /// Opens partition files that appeared in the directory since this handle last looked, and
171    /// reports their names — an unpartitioned set never has any.
172    pub fn discover(&mut self) -> io::Result<Vec<String>> {
173        let Layout::Partitioned(dir) = &self.layout else {
174            return Ok(Vec::new());
175        };
176        let mut found = Vec::new();
177        for part in partition_names_in(&dir.clone()) {
178            if self.logs.contains_key(&part) {
179                continue;
180            }
181            self.load(&part)?;
182            found.push(part);
183        }
184        Ok(found)
185    }
186
187    fn load(&mut self, part: &str) -> io::Result<()> {
188        let mut log = LogFile::open(self.path_of(part), self.readonly)?;
189        log.set_fsync(self.fsync);
190        self.logs.insert(part.to_string(), log);
191        Ok(())
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    fn line(key: &str) -> String {
200        format!("{{\"_k\":\"{key}\",\"_op\":\"put\",\"_seq\":1,\"_ts\":1,\"id\":\"{key}\"}}")
201    }
202
203    #[test]
204    fn a_flat_set_holds_exactly_the_file_it_was_given() {
205        let dir = tempfile::tempdir().unwrap();
206        let path = dir.path().join("notes.jsonl");
207        let mut set = LogSet::flat(&path, false).unwrap();
208
209        assert!(!set.is_partitioned());
210        assert_eq!(set.parts(), vec![String::new()]);
211        assert_eq!(set.path_of(""), path);
212        set.append("", &[line("n1")]).unwrap();
213        assert_eq!(set.total_size(), fs::metadata(&path).unwrap().len());
214    }
215
216    #[test]
217    fn a_new_partition_value_creates_its_own_file_on_first_append() {
218        let dir = tempfile::tempdir().unwrap();
219        let mut set = LogSet::partitioned(dir.path().join("notes"), false).unwrap();
220        assert!(set.parts().is_empty());
221
222        set.append("note", &[line("n1")]).unwrap();
223        set.append("adr", &[line("a1")]).unwrap();
224
225        assert_eq!(set.parts(), vec!["adr".to_string(), "note".to_string()]);
226        assert!(dir.path().join("notes/note.jsonl").is_file());
227        assert!(dir.path().join("notes/adr.jsonl").is_file());
228    }
229
230    #[test]
231    fn appending_to_a_known_value_reuses_its_file_instead_of_making_another() {
232        let dir = tempfile::tempdir().unwrap();
233        let mut set = LogSet::partitioned(dir.path().join("notes"), false).unwrap();
234        set.append("note", &[line("n1")]).unwrap();
235        set.append("note", &[line("n2")]).unwrap();
236
237        assert_eq!(set.parts(), vec!["note".to_string()]);
238        let content = fs::read_to_string(dir.path().join("notes/note.jsonl")).unwrap();
239        assert_eq!(content.trim().lines().count(), 2);
240    }
241
242    #[test]
243    fn reopening_finds_every_partition_file_already_on_disk() {
244        let dir = tempfile::tempdir().unwrap();
245        {
246            let mut set = LogSet::partitioned(dir.path().join("notes"), false).unwrap();
247            set.append("note", &[line("n1")]).unwrap();
248            set.append("rule", &[line("r1")]).unwrap();
249            set.sync().unwrap();
250        }
251
252        let set = LogSet::partitioned(dir.path().join("notes"), false).unwrap();
253        assert_eq!(set.parts(), vec!["note".to_string(), "rule".to_string()]);
254        assert!(set.log_of("note").unwrap().size() > 0);
255    }
256
257    #[test]
258    fn discover_opens_a_partition_file_another_writer_created_since_the_last_look() {
259        let dir = tempfile::tempdir().unwrap();
260        let mut reader = LogSet::partitioned(dir.path().join("notes"), false).unwrap();
261        {
262            let mut writer = LogSet::partitioned(dir.path().join("notes"), false).unwrap();
263            writer.append("adr", &[line("a1")]).unwrap();
264            writer.sync().unwrap();
265        }
266
267        assert!(reader.log_of("adr").is_none());
268        assert_eq!(reader.discover().unwrap(), vec!["adr".to_string()]);
269        assert!(reader.log_of("adr").unwrap().size() > 0);
270        assert!(reader.discover().unwrap().is_empty());
271    }
272
273    #[test]
274    fn reads_a_slice_back_from_the_partition_it_was_written_to() {
275        let dir = tempfile::tempdir().unwrap();
276        let mut set = LogSet::partitioned(dir.path().join("notes"), false).unwrap();
277        let slots = set.append("adr", &[line("a1")]).unwrap();
278
279        assert_eq!(set.read_slice("adr", &slots[0]).unwrap(), line("a1"));
280        assert!(set.read_slice("note", &slots[0]).is_err());
281    }
282
283    #[test]
284    fn turning_fsync_off_reaches_partitions_opened_before_and_after_the_call() {
285        let dir = tempfile::tempdir().unwrap();
286        let mut set = LogSet::partitioned(dir.path().join("notes"), false).unwrap();
287        set.append("adr", &[line("a1")]).unwrap();
288
289        set.set_fsync(false);
290        set.append("note", &[line("n1")]).unwrap();
291        set.sync().unwrap();
292
293        assert_eq!(
294            set.log_of("adr").unwrap().fsyncs(),
295            0,
296            "a partition open before the call"
297        );
298        assert_eq!(
299            set.log_of("note").unwrap().fsyncs(),
300            0,
301            "a partition created after the call"
302        );
303    }
304
305    #[test]
306    fn a_set_left_alone_keeps_fsyncing_every_partition_it_holds() {
307        let dir = tempfile::tempdir().unwrap();
308        let mut set = LogSet::partitioned(dir.path().join("notes"), false).unwrap();
309        set.append("adr", &[line("a1")]).unwrap();
310        set.append("note", &[line("n1")]).unwrap();
311        set.sync().unwrap();
312
313        assert_eq!(set.log_of("adr").unwrap().fsyncs(), 1);
314        assert_eq!(set.log_of("note").unwrap().fsyncs(), 1);
315    }
316
317    #[test]
318    fn a_file_that_is_not_a_log_never_becomes_a_partition() {
319        let dir = tempfile::tempdir().unwrap();
320        fs::create_dir_all(dir.path().join("notes")).unwrap();
321        fs::write(dir.path().join("notes/note.jsonl"), "").unwrap();
322        fs::write(dir.path().join("notes/README.md"), "not a log").unwrap();
323        fs::write(dir.path().join("notes/.jsonl"), "").unwrap();
324
325        let set = LogSet::partitioned(dir.path().join("notes"), false).unwrap();
326        assert_eq!(set.parts(), vec!["note".to_string()]);
327    }
328}