Skip to content

Commit a75b67a

Browse files
committed
Implement JSTable compaction with tombstone support for deletes
1 parent a588ace commit a75b67a

5 files changed

Lines changed: 185 additions & 30 deletions

File tree

ROADMAP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ This stage will allow the data stored in memory to be dumped to disk to allow st
3434

3535
This stage will implement the compaction algorithm for the LSM tree.
3636

37-
- [] Implement compaction for JSTables by merging their schemas and updating the compressed documents
37+
- [x] Implement compaction for JSTables by merging their schemas and updating the compressed documents
3838

3939
# Stage 6 - Internal query API
4040

specs/storage.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,13 @@ Example:
1515
{"type":"object","properties":{"a":{"type":["integer"]}}}
1616
["01H4J3J4J3J4J3J4J3J4J3J4J3", {"a": 1}]
1717
["01H4J3J4J3J4J3J4J3J4J3J4J4", {"a": 2}]
18+
["01H4J3J4J3J4J3J4J3J4J3J4J5", null]
1819
```
1920

2021
## Compression
2122

2223
Documents are stored as full JSON objects. This means that no compression is currently applied.
24+
A document can be `null` to indicate that it has been deleted.
2325
Future versions of the format might include schema-based compression. For example, if a field must be an integer, the type information need not be stored with each document. If a field can have multiple possible types, then the type information must be stored with each document.
2426

2527

src/db.rs

Lines changed: 110 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1+
use crate::jstable;
12
use crate::log::{LogEntry, Logger, Operation};
23
use crate::storage::MemTable;
3-
use serde_json::Value;
4+
use serde_json::{json, Value};
5+
use std::fs;
46
use uuid::Uuid;
57

68
const MEMTABLE_THRESHOLD: usize = 10;
9+
const JSTABLE_THRESHOLD: u64 = 5;
710

811
pub struct DB {
912
memtable: MemTable,
@@ -14,7 +17,7 @@ pub struct DB {
1417

1518
impl DB {
1619
pub fn new(jstable_dir: &str) -> Self {
17-
std::fs::create_dir_all(jstable_dir).unwrap();
20+
fs::create_dir_all(jstable_dir).unwrap();
1821
let log_path = format!("{}/argus.log", jstable_dir);
1922
let logger = Logger::new(&log_path, 1024 * 1024).unwrap();
2023
let mut memtable = MemTable::new();
@@ -84,6 +87,30 @@ impl DB {
8487
self.jstable_count += 1;
8588
self.memtable = MemTable::new();
8689
self.logger.rotate().unwrap();
90+
91+
if self.jstable_count >= JSTABLE_THRESHOLD {
92+
self.compact();
93+
}
94+
}
95+
96+
fn compact(&mut self) {
97+
let mut tables = Vec::new();
98+
for i in 0..self.jstable_count {
99+
let path = format!("{}/jstable-{}", self.jstable_dir, i);
100+
tables.push(jstable::read_jstable(&path).unwrap());
101+
}
102+
103+
let merged_table = jstable::merge_jstables(&tables);
104+
105+
for i in 0..self.jstable_count {
106+
let path = format!("{}/jstable-{}", self.jstable_dir, i);
107+
fs::remove_file(path).unwrap();
108+
}
109+
110+
let new_path = format!("{}/jstable-0", self.jstable_dir);
111+
merged_table.write(&new_path).unwrap();
112+
113+
self.jstable_count = 1;
87114
}
88115
}
89116

@@ -170,4 +197,85 @@ mod tests {
170197
assert_eq!(db2.memtable.len(), 1);
171198
assert_eq!(*db2.memtable.documents.get(&id2).unwrap(), doc2);
172199
}
200+
201+
#[test]
202+
fn test_db_compaction() {
203+
let dir = tempdir().unwrap();
204+
let mut db = DB::new(dir.path().to_str().unwrap());
205+
206+
for i in 0..(MEMTABLE_THRESHOLD * JSTABLE_THRESHOLD as usize) {
207+
db.insert(json!({ "a": i }));
208+
}
209+
210+
assert_eq!(db.jstable_count, JSTABLE_THRESHOLD - 1);
211+
db.insert(json!({ "a": 999 })); // Trigger another flush, which triggers compaction
212+
assert_eq!(db.jstable_count, 1);
213+
214+
// Verify data is preserved
215+
// We inserted 0..50 (50 items) + 1 (999). 51 items total.
216+
// Item "0" should be in the compacted table.
217+
// We can't easily query DB yet (no read path implemented in DB),
218+
// so we manually check the file.
219+
let jstable_path = format!("{}/jstable-0", dir.path().to_str().unwrap());
220+
let table = jstable::read_jstable(&jstable_path).unwrap();
221+
assert!(table.documents.len() >= 50); // It should have the 50 items from the first 5 flushes (0-49)
222+
// The last inserted item (999) is in memtable, not in jstable-0 yet.
223+
}
224+
225+
#[test]
226+
fn test_db_compaction_with_delete() {
227+
let dir = tempdir().unwrap();
228+
let mut db = DB::new(dir.path().to_str().unwrap());
229+
230+
// 1. Insert doc to be deleted
231+
let id_to_delete = db.insert(json!({ "a": 100 }));
232+
233+
// Fill memtable to force flush 1 (jstable-0)
234+
// 1 item already inserted. Insert 9 more to fill (total 10).
235+
for i in 0..9 {
236+
db.insert(json!({ "fill": i }));
237+
}
238+
// 11th insert triggers flush of the first 10
239+
db.insert(json!({ "trigger_1": 1 }));
240+
assert_eq!(db.jstable_count, 1);
241+
242+
// 2. Delete the doc
243+
// id_to_delete is in jstable-0.
244+
// delete adds tombstone to memtable.
245+
// memtable currently has "trigger_1" (1 item).
246+
// delete adds 1 item. len = 2.
247+
db.delete(&id_to_delete);
248+
249+
// Fill memtable to force flush 2 (jstable-1)
250+
// Memtable len is 2. Need 8 more to fill (total 10).
251+
for i in 0..8 {
252+
db.insert(json!({ "fill_2": i }));
253+
}
254+
// 11th insert (relative to this batch) triggers flush
255+
db.insert(json!({ "trigger_2": 1 }));
256+
assert_eq!(db.jstable_count, 2);
257+
258+
// 3. Create 3 more tables to reach threshold 5
259+
for t in 0..3 {
260+
// Fill memtable (10 items)
261+
// Memtable has 1 item ("trigger_2" or previous trigger).
262+
// Need 9 more.
263+
for i in 0..9 {
264+
db.insert(json!({ "fill_more": t, "i": i }));
265+
}
266+
// Trigger flush
267+
db.insert(json!({ "trigger_more": t }));
268+
}
269+
// After 3rd iteration (total 5th flush), compaction triggers.
270+
// jstable_count goes 4 -> 5 -> 1.
271+
assert_eq!(db.jstable_count, 1);
272+
273+
// 4. Verify id_to_delete is NOT in jstable-0
274+
let jstable_path = format!("{}/jstable-0", dir.path().to_str().unwrap());
275+
let table = jstable::read_jstable(&jstable_path).unwrap();
276+
assert!(!table.documents.contains_key(&id_to_delete));
277+
278+
// Verify other documents exist (e.g. from flush 1)
279+
assert!(table.documents.len() > 40);
280+
}
173281
}

src/jstable.rs

Lines changed: 65 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,32 @@
11
use crate::schema::Schema;
22
use serde_json::Value;
33
use std::collections::BTreeMap;
4-
use std::io::{self, BufReader, BufRead};
4+
use std::io::{self, BufReader, BufRead, Write};
55
use std::fs::File;
66

77
pub struct JSTable {
88
pub schema: Schema,
99
pub documents: BTreeMap<String, Value>,
1010
}
1111

12+
impl JSTable {
13+
pub fn new(schema: Schema, documents: BTreeMap<String, Value>) -> Self {
14+
JSTable { schema, documents }
15+
}
16+
17+
pub fn write(&self, path: &str) -> io::Result<()> {
18+
let mut file = File::create(path)?;
19+
let schema_json = serde_json::to_string(&self.schema)?;
20+
writeln!(file, "{}", schema_json)?;
21+
for (id, doc) in &self.documents {
22+
let record: (String, &Value) = (id.clone(), doc);
23+
let record_json = serde_json::to_string(&record)?;
24+
writeln!(file, "{}", record_json)?;
25+
}
26+
Ok(())
27+
}
28+
}
29+
1230
pub fn read_jstable(path: &str) -> io::Result<JSTable> {
1331
let file = File::open(path)?;
1432
let reader = BufReader::new(file);
@@ -20,13 +38,32 @@ pub fn read_jstable(path: &str) -> io::Result<JSTable> {
2038
let mut documents = BTreeMap::new();
2139
for line_result in lines {
2240
let line = line_result?;
41+
if line.is_empty() {
42+
continue;
43+
}
2344
let record: (String, Value) = serde_json::from_str(&line)?;
2445
documents.insert(record.0, record.1);
2546
}
2647

2748
Ok(JSTable { schema, documents })
2849
}
2950

51+
pub fn merge_jstables(tables: &[JSTable]) -> JSTable {
52+
let mut merged_schema = Schema::new(crate::schema::SchemaType::Object);
53+
let mut merged_documents = BTreeMap::new();
54+
55+
for table in tables {
56+
merged_schema.merge(table.schema.clone());
57+
for (id, doc) in &table.documents {
58+
merged_documents.insert(id.clone(), doc.clone());
59+
}
60+
}
61+
62+
merged_documents.retain(|_, v| !v.is_null());
63+
64+
JSTable::new(merged_schema, merged_documents)
65+
}
66+
3067
#[cfg(test)]
3168
mod tests {
3269
use super::*;
@@ -46,8 +83,8 @@ mod tests {
4683
items: None,
4784
}).unwrap();
4885
writeln!(file, "{}", schema_json).unwrap();
49-
writeln!(file, "{}", serde_json::to_string(&json!(["id1", {"a": 1}]))?).unwrap();
50-
writeln!(file, "{}", serde_json::to_string(&json!(["id2", {"a": 2}]))?).unwrap();
86+
writeln!(file, "{}", serde_json::to_string(&json!(["id1", {"a": 1}])).unwrap()).unwrap();
87+
writeln!(file, "{}", serde_json::to_string(&json!(["id2", {"a": 2}])).unwrap()).unwrap();
5188

5289
let jstable = read_jstable(file.path().to_str().unwrap()).unwrap();
5390

@@ -57,4 +94,29 @@ mod tests {
5794
assert_eq!(*jstable.documents.get("id2").unwrap(), json!({"a": 2}));
5895
Ok(())
5996
}
97+
98+
#[test]
99+
fn test_write_jstable() -> Result<(), Box<dyn std::error::Error>> {
100+
let schema = Schema {
101+
types: vec![SchemaType::Object],
102+
properties: Some(BTreeMap::from([
103+
("a".to_string(), Schema::new(SchemaType::Integer)),
104+
])),
105+
items: None,
106+
};
107+
let mut documents = BTreeMap::new();
108+
documents.insert("id1".to_string(), json!({"a": 1}));
109+
documents.insert("id2".to_string(), json!({"a": 2}));
110+
let jstable = JSTable::new(schema, documents);
111+
112+
let file = NamedTempFile::new().unwrap();
113+
jstable.write(file.path().to_str().unwrap()).unwrap();
114+
115+
let content = std::fs::read_to_string(file.path()).unwrap();
116+
assert!(content.contains("{\"type\":\"object\",\"properties\":{\"a\":{\"type\":[\"integer\"]}}}"));
117+
assert!(content.contains("[\"id1\",{\"a\":1}]"));
118+
assert!(content.contains("[\"id2\",{\"a\":2}]"));
119+
120+
Ok(())
121+
}
60122
}

src/storage.rs

Lines changed: 7 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
1+
use crate::jstable::JSTable;
12
use crate::schema::{infer_schema, Schema};
23
use serde_json::Value;
34
use std::collections::BTreeMap;
4-
use std::io::Write;
5-
use serde_json::json;
65

76
pub struct MemTable {
87
pub documents: BTreeMap<String, Value>,
@@ -26,15 +25,8 @@ impl MemTable {
2625
}
2726

2827
pub fn flush(&self, path: &str) -> std::io::Result<()> {
29-
let mut file = std::fs::File::create(path)?;
30-
let schema_json = serde_json::to_string(&self.schema)?;
31-
writeln!(file, "{}", schema_json)?;
32-
for (id, doc) in &self.documents {
33-
let record = json!([id, doc]);
34-
let record_json = serde_json::to_string(&record)?;
35-
writeln!(file, "{}", record_json)?;
36-
}
37-
Ok(())
28+
let jstable = JSTable::new(self.schema.clone(), self.documents.clone());
29+
jstable.write(path)
3830
}
3931

4032
pub fn insert(&mut self, id: String, doc: Value) {
@@ -43,15 +35,15 @@ impl MemTable {
4335
self.documents.insert(id, doc);
4436
}
4537

46-
pub fn delete(&mut self, id: &str) {
47-
self.documents.remove(id);
48-
}
49-
5038
pub fn update(&mut self, id: &str, doc: Value) {
5139
let doc_schema = infer_schema(&doc);
5240
self.schema.merge(doc_schema);
5341
self.documents.insert(id.to_string(), doc);
5442
}
43+
44+
pub fn delete(&mut self, id: &str) {
45+
self.documents.insert(id.to_string(), Value::Null);
46+
}
5547
}
5648

5749
#[cfg(test)]
@@ -75,15 +67,6 @@ mod tests {
7567
assert_eq!(props.get("b").unwrap().types, vec![SchemaType::String]);
7668
}
7769

78-
#[test]
79-
fn test_memtable_delete() {
80-
let mut memtable = MemTable::new();
81-
memtable.insert("test-id".to_string(), json!({"a": 1}));
82-
assert_eq!(memtable.len(), 1);
83-
memtable.delete("test-id");
84-
assert_eq!(memtable.len(), 0);
85-
}
86-
8770
#[test]
8871
fn test_memtable_update() {
8972
let mut memtable = MemTable::new();

0 commit comments

Comments
 (0)