Skip to main content

hayson_engine/compaction/
compact.rs

1use std::collections::HashMap;
2use std::io;
3
4use crate::atomic::write_atomic;
5use crate::registry::table_registry::PrimaryEntry;
6use crate::storage::{LogFile, LogOp, Slot};
7
8pub struct CompactionResult {
9    pub(crate) slots: HashMap<String, PrimaryEntry>,
10    pub records: usize,
11    pub bytes_before: u64,
12    pub bytes_after: u64,
13    pub reclaimed_bytes: u64,
14}
15
16#[cfg(unix)]
17const PERMISSION_BITS: u32 = 0o777;
18
19#[cfg(unix)]
20fn permissions_of(path: &std::path::Path) -> io::Result<Option<u32>> {
21    use std::os::unix::fs::PermissionsExt;
22    match std::fs::metadata(path) {
23        Ok(metadata) => Ok(Some(metadata.permissions().mode() & PERMISSION_BITS)),
24        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
25        Err(error) => Err(error),
26    }
27}
28
29// NOTE: wasi has no permission bits at all; the host sandbox bounds fs access instead.
30#[cfg(not(unix))]
31fn permissions_of(_path: &std::path::Path) -> io::Result<Option<u32>> {
32    Ok(None)
33}
34
35fn plan(log: &LogFile) -> io::Result<(Vec<u8>, HashMap<String, PrimaryEntry>, usize)> {
36    let scan = log.scan(0)?;
37
38    let mut order: Vec<String> = Vec::new();
39    let mut position_of: HashMap<String, usize> = HashMap::new();
40    let mut kept: Vec<Option<Slot>> = Vec::new();
41    let mut seq_of: Vec<u64> = Vec::new();
42
43    for scanned in &scan.entries {
44        let key = &scanned.entry.meta.key;
45        match scanned.entry.meta.op {
46            LogOp::Del => {
47                if let Some(pos) = position_of.remove(key) {
48                    kept[pos] = None;
49                }
50            }
51            LogOp::Put => {
52                if let Some(&pos) = position_of.get(key) {
53                    kept[pos] = Some(scanned.slot);
54                    seq_of[pos] = scanned.entry.meta.seq;
55                } else {
56                    let pos = order.len();
57                    order.push(key.clone());
58                    kept.push(Some(scanned.slot));
59                    seq_of.push(scanned.entry.meta.seq);
60                    position_of.insert(key.clone(), pos);
61                }
62            }
63        }
64    }
65
66    let mut buffer = Vec::new();
67    let mut slots = HashMap::with_capacity(order.len());
68    for (pos, key) in order.into_iter().enumerate() {
69        let Some(slot) = kept[pos] else { continue };
70        let line = log.read_slice(&slot)?;
71        // NOTE: re-sealed in an encrypted log, so the new slot is as long as the new line, not the old one.
72        let stored = log.encode_lines(std::slice::from_ref(&line)).remove(0);
73        let new_offset = buffer.len() as u64;
74        buffer.extend_from_slice(stored.as_bytes());
75        buffer.push(b'\n');
76        slots.insert(
77            key,
78            PrimaryEntry {
79                offset: new_offset,
80                length: stored.len() as u64,
81                seq: seq_of[pos],
82            },
83        );
84    }
85
86    let records = slots.len();
87    Ok((buffer, slots, records))
88}
89
90/// Rewrites `log` down to its live records, dropping overwritten versions and tombstoned keys.
91/// The rewrite lands through [`write_atomic`] (temp file, `fsync`, rename), so a crash before the
92/// rename leaves the original log exactly as it was. `log` is reopened at its (unchanged) path
93/// afterwards, so the handle keeps working for further scans and appends.
94pub fn compact_log(log: &mut LogFile) -> io::Result<CompactionResult> {
95    // NOTE: the rewrite lands at the path — over a swapped-in file it would erase what is there now.
96    log.refuse_if_replaced()?;
97    let bytes_before = log.size();
98    let (buffer, slots, records) = plan(log)?;
99    let bytes_after = buffer.len() as u64;
100
101    let mode = permissions_of(log.path())?;
102    write_atomic(log.path(), &buffer, mode)?;
103    log.reopen()?;
104
105    Ok(CompactionResult {
106        slots,
107        records,
108        bytes_before,
109        bytes_after,
110        reclaimed_bytes: bytes_before.saturating_sub(bytes_after),
111    })
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117    use crate::atomic::open_temporary;
118    use crate::registry::table_registry::TableRegistry;
119    use crate::schema::{parse_schema, HaysonRecord};
120    use crate::storage::{serialize_entry, LogMeta};
121    use serde_json::json;
122    use std::fs;
123    use std::io::Write;
124
125    fn put(key: &str, seq: u64, name: &str) -> String {
126        let mut data = HaysonRecord::new();
127        data.insert("name".into(), json!(name));
128        serialize_entry(
129            &LogMeta {
130                key: key.into(),
131                op: LogOp::Put,
132                seq,
133                ts: 1,
134            },
135            Some(&data),
136        )
137    }
138
139    fn del(key: &str, seq: u64) -> String {
140        serialize_entry(
141            &LogMeta {
142                key: key.into(),
143                op: LogOp::Del,
144                seq,
145                ts: 1,
146            },
147            None,
148        )
149    }
150
151    #[test]
152    fn keeps_only_the_last_version_of_an_overwritten_key() {
153        let dir = tempfile::tempdir().unwrap();
154        let path = dir.path().join("users.jsonl");
155        let mut log = LogFile::open(&path, false).unwrap();
156        log.append(&[put("a", 1, "first"), put("a", 2, "second")]).unwrap();
157
158        let result = compact_log(&mut log).unwrap();
159
160        assert_eq!(result.records, 1);
161        let scan = log.scan(0).unwrap();
162        assert_eq!(scan.entries.len(), 1);
163        assert_eq!(
164            scan.entries[0].entry.data.as_ref().unwrap().get("name"),
165            Some(&json!("second"))
166        );
167    }
168
169    #[test]
170    fn drops_a_key_that_was_put_then_deleted() {
171        let dir = tempfile::tempdir().unwrap();
172        let path = dir.path().join("users.jsonl");
173        let mut log = LogFile::open(&path, false).unwrap();
174        log.append(&[put("a", 1, "first"), put("b", 2, "kept"), del("a", 3)])
175            .unwrap();
176
177        let result = compact_log(&mut log).unwrap();
178
179        assert_eq!(result.records, 1);
180        assert!(!result.slots.contains_key("a"));
181        let scan = log.scan(0).unwrap();
182        assert_eq!(scan.entries.len(), 1);
183        assert_eq!(scan.entries[0].entry.meta.key, "b");
184    }
185
186    #[test]
187    fn compacts_an_all_tombstones_log_down_to_empty() {
188        let dir = tempfile::tempdir().unwrap();
189        let path = dir.path().join("users.jsonl");
190        let mut log = LogFile::open(&path, false).unwrap();
191        log.append(&[put("a", 1, "first"), del("a", 2)]).unwrap();
192
193        let result = compact_log(&mut log).unwrap();
194
195        assert_eq!(result.records, 0);
196        assert_eq!(log.size(), 0);
197        assert_eq!(result.bytes_after, 0);
198    }
199
200    #[test]
201    fn reclaims_the_space_freed_by_dead_entries_and_reports_it() {
202        let dir = tempfile::tempdir().unwrap();
203        let path = dir.path().join("users.jsonl");
204        let mut log = LogFile::open(&path, false).unwrap();
205        log.append(&[put("a", 1, "first"), put("a", 2, "second"), put("b", 3, "kept")])
206            .unwrap();
207        let bytes_before = log.size();
208
209        let result = compact_log(&mut log).unwrap();
210
211        assert_eq!(result.bytes_before, bytes_before);
212        assert!(result.reclaimed_bytes > 0);
213        assert_eq!(result.bytes_after, log.size());
214    }
215
216    #[test]
217    fn resulting_file_is_a_usable_log_file_that_still_accepts_appends() {
218        let dir = tempfile::tempdir().unwrap();
219        let path = dir.path().join("users.jsonl");
220        let mut log = LogFile::open(&path, false).unwrap();
221        log.append(&[put("a", 1, "first"), put("a", 2, "second")]).unwrap();
222
223        compact_log(&mut log).unwrap();
224        log.append(&[put("c", 3, "third")]).unwrap();
225
226        let reopened = LogFile::open(&path, true).unwrap();
227        let scan = reopened.scan(0).unwrap();
228        assert_eq!(scan.entries.len(), 2);
229        assert_eq!(scan.entries[0].entry.meta.key, "a");
230        assert_eq!(scan.entries[1].entry.meta.key, "c");
231    }
232
233    #[test]
234    fn original_log_is_untouched_if_the_process_dies_before_the_rename() {
235        let dir = tempfile::tempdir().unwrap();
236        let path = dir.path().join("users.jsonl");
237        let mut log = LogFile::open(&path, false).unwrap();
238        log.append(&[put("a", 1, "first"), put("a", 2, "second")]).unwrap();
239        let before = fs::read(&path).unwrap();
240
241        let (buffer, _, _) = plan(&log).unwrap();
242        let temp_path = path.with_extension("jsonl.tmp");
243        let mut temp = open_temporary(&temp_path, None).unwrap();
244        temp.write_all(&buffer).unwrap();
245        temp.sync_all().unwrap();
246        drop(temp);
247
248        assert_eq!(
249            fs::read(&path).unwrap(),
250            before,
251            "crash before rename must leave the original log intact"
252        );
253
254        fs::rename(&temp_path, &path).unwrap();
255        assert_eq!(fs::read(&path).unwrap(), buffer);
256    }
257
258    fn users_schema() -> crate::schema::TableSchema {
259        parse_schema(&json!({
260            "schemaVersion": 1,
261            "table": "users",
262            "primaryKey": "id",
263            "fields": { "id": { "type": "string" }, "name": { "type": "string" } },
264        }))
265        .unwrap()
266    }
267
268    #[test]
269    fn registry_remap_after_compaction_still_answers_queries_correctly() {
270        let dir = tempfile::tempdir().unwrap();
271        let path = dir.path().join("users.jsonl");
272        let mut log = LogFile::open(&path, false).unwrap();
273        log.append(&[
274            put("a", 1, "first"),
275            put("a", 2, "second"),
276            put("b", 3, "kept"),
277            del("b", 4),
278            put("c", 5, "also-kept"),
279        ])
280        .unwrap();
281
282        let mut registry = TableRegistry::build_from_log(users_schema(), &log).unwrap();
283        assert_eq!(registry.records(), 2);
284
285        let result = compact_log(&mut log).unwrap();
286        registry.remap(&result.slots, "");
287
288        assert_eq!(registry.records(), 2);
289        assert_eq!(registry.stats().dead_bytes, 0);
290
291        for key in ["a", "c"] {
292            let entry = registry.slot_of(key).expect("key survives compaction");
293            let slot = Slot {
294                offset: entry.offset,
295                length: entry.length,
296            };
297            let line = log.read_slice(&slot).unwrap();
298            assert!(line.contains(&format!("\"_k\":\"{key}\"")));
299        }
300        assert!(registry.slot_of("b").is_none());
301    }
302}