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