hayson_engine/storage/
log_set.rs1use 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 Flat(PathBuf),
13 Partitioned(PathBuf),
15}
16
17pub struct LogSet {
22 layout: Layout,
23 logs: BTreeMap<String, LogFile>,
24 readonly: bool,
29 fsync: bool,
33}
34
35pub 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 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 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 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 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 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 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 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 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}