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 Flat(PathBuf),
15 Partitioned(PathBuf),
17}
18
19pub struct LogSet {
24 layout: Layout,
25 logs: BTreeMap<String, LogFile>,
26 readonly: bool,
31 fsync: bool,
35 codec: Option<Arc<dyn LineCodec>>,
37}
38
39pub 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 pub fn flat(path: impl AsRef<Path>, readonly: bool) -> io::Result<Self> {
60 LogSet::flat_with(path, readonly, None)
61 }
62
63 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 pub fn partitioned(dir: impl AsRef<Path>, readonly: bool) -> io::Result<Self> {
80 LogSet::partitioned_with(dir, readonly, None)
81 }
82
83 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 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 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 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 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 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 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}