hayson_engine/compaction/
compact.rs1use 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#[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 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
90pub fn compact_log(log: &mut LogFile) -> io::Result<CompactionResult> {
95 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}