Skip to content

Commit c26d0be

Browse files
committed
feat: add sparse index to JSTable summary
Added a sparse index to the JSTable summary file to map keys to data file offsets. Updated JSTable writing to build the index and reading to allow seeking. Updated Collection to load the index and use it for optimized get queries. Updated specs.
1 parent 4f08edd commit c26d0be

3 files changed

Lines changed: 184 additions & 29 deletions

File tree

specs/storage.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ The files consist of a sequence of entries. Each entry is encoded as:
1717
* `timestamp`: The time the table was created (Unix timestamp in milliseconds).
1818
* `schema`: The JSON Schema for the documents.
1919
2. **Filter Entry**: The second entry in the file. It is a [Binary Fuse8](https://github.com/ayazhafiz/xorf) filter of the record IDs in the table, serialized as a JSON byte vector.
20+
3. **Index Entry**: The third entry in the file. It is a sparse index mapping keys to byte offsets in the data file, serialized as a JSON byte vector. It is a list of `[key, offset]` pairs, created by adding an entry for the first key and then for every key that appears at least 1KB of data after the previous indexed key.
2021

2122
### Data File
2223

src/db.rs

Lines changed: 48 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,11 @@ fn sanitize_filename(name: &str) -> String {
7979
result
8080
}
8181

82+
struct LoadedTable {
83+
filter: BinaryFuse8,
84+
index: Vec<(String, u64)>,
85+
}
86+
8287
struct Collection {
8388
name: String,
8489
pub memtable: MemTable,
@@ -87,7 +92,7 @@ struct Collection {
8792
logger: Box<dyn Log>,
8893
memtable_threshold: usize,
8994
jstable_threshold: u64,
90-
filters: Vec<BinaryFuse8>,
95+
tables: Vec<LoadedTable>,
9196
}
9297

9398
impl Collection {
@@ -106,20 +111,21 @@ impl Collection {
106111
Box::new(NullLogger)
107112
};
108113
let memtable = MemTable::new();
109-
// Count existing JSTables and load filters
114+
// Count existing JSTables and load filters/indices
110115
let mut jstable_count = 0;
111-
let mut filters = Vec::new();
116+
let mut tables = Vec::new();
112117
// Check for .summary file to confirm JSTable existence
113118
while dir
114119
.join(format!("jstable-{}.summary", jstable_count))
115120
.exists()
116121
{
117122
let path = dir.join(format!("jstable-{}", jstable_count));
118-
if let Ok(filter) = jstable::read_filter(path.to_str().unwrap()) {
119-
filters.push(filter);
120-
} else {
121-
panic!("Failed to read filter for jstable-{}", jstable_count);
122-
}
123+
let path_str = path.to_str().unwrap();
124+
125+
let filter = jstable::read_filter(path_str).expect("Failed to read filter");
126+
let index = jstable::read_index(path_str).expect("Failed to read index");
127+
128+
tables.push(LoadedTable { filter, index });
123129
jstable_count += 1;
124130
}
125131

@@ -131,7 +137,7 @@ impl Collection {
131137
logger,
132138
memtable_threshold,
133139
jstable_threshold,
134-
filters,
140+
tables,
135141
}
136142
}
137143

@@ -176,9 +182,12 @@ impl Collection {
176182
.flush(jstable_path.to_str().unwrap(), self.name.clone())
177183
.unwrap();
178184

179-
// Load the new filter
180-
let filter = jstable::read_filter(jstable_path.to_str().unwrap()).unwrap();
181-
self.filters.push(filter);
185+
// Load the new filter and index
186+
let path_str = jstable_path.to_str().unwrap();
187+
let filter = jstable::read_filter(path_str).unwrap();
188+
let index = jstable::read_index(path_str).unwrap();
189+
190+
self.tables.push(LoadedTable { filter, index });
182191

183192
self.jstable_count += 1;
184193
self.memtable = MemTable::new();
@@ -209,10 +218,12 @@ impl Collection {
209218
let new_path = self.dir.join("jstable-0");
210219
merged_table.write(new_path.to_str().unwrap()).unwrap();
211220

212-
// Reset filters
213-
self.filters.clear();
214-
let filter = jstable::read_filter(new_path.to_str().unwrap()).unwrap();
215-
self.filters.push(filter);
221+
// Reset tables
222+
self.tables.clear();
223+
let path_str = new_path.to_str().unwrap();
224+
let filter = jstable::read_filter(path_str).unwrap();
225+
let index = jstable::read_index(path_str).unwrap();
226+
self.tables.push(LoadedTable { filter, index });
216227

217228
self.jstable_count = 1;
218229
}
@@ -259,18 +270,29 @@ impl Collection {
259270
};
260271

261272
for i in (0..self.jstable_count).rev() {
262-
if let Some(filter) = self.filters.get(i as usize) {
263-
if filter.contains(&hash) {
264-
// Possible match, scan the table
273+
if let Some(table) = self.tables.get(i as usize) {
274+
if table.filter.contains(&hash) {
275+
// Possible match, find offset using index
276+
let index = &table.index;
277+
// Find first key > id. We want the one before that.
278+
let idx = index.partition_point(|(k, _)| k.as_str() <= id);
279+
let start_offset = if idx > 0 { index[idx - 1].1 } else { 0 };
280+
265281
let path = self.dir.join(format!("jstable-{}", i));
266-
if let Ok(iter) = jstable::JSTableIterator::new(path.to_str().unwrap()) {
267-
for res in iter {
268-
if let Ok((rid, doc)) = res {
269-
if rid == id {
270-
if doc.is_null() {
271-
return None; // Tombstone
282+
if let Ok(mut iter) = jstable::JSTableIterator::new(path.to_str().unwrap()) {
283+
if iter.seek(start_offset).is_ok() {
284+
for res in iter {
285+
if let Ok((rid, doc)) = res {
286+
if rid == id {
287+
if doc.is_null() {
288+
return None; // Tombstone
289+
}
290+
return Some(doc);
291+
}
292+
if rid > id.to_string() {
293+
// Not found in this table (sorted)
294+
break;
272295
}
273-
return Some(doc);
274296
}
275297
}
276298
}

src/jstable.rs

Lines changed: 135 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize};
33
use serde_json::Value;
44
use std::collections::BTreeMap;
55
use std::fs::File;
6-
use std::io::{self, BufReader, Read, Write};
6+
use std::io::{self, BufReader, Read, Seek, SeekFrom, Write};
77
use xorf::BinaryFuse8;
88

99
pub struct JSTable {
@@ -77,16 +77,41 @@ impl JSTable {
7777
summary_file.write_all(&filter_len.to_le_bytes())?;
7878
summary_file.write_all(&filter_bytes)?;
7979

80-
// Write Documents to data
80+
// Write Documents to data and build index
81+
let mut index: Vec<(String, u64)> = Vec::new();
82+
let mut current_offset: u64 = 0;
83+
let mut bytes_since_last_index: u64 = 0;
84+
let mut first = true;
85+
8186
for (id, doc) in &self.documents {
87+
// Add index entry if needed
88+
if first || bytes_since_last_index >= 1024 {
89+
index.push((id.clone(), current_offset));
90+
bytes_since_last_index = 0;
91+
first = false;
92+
}
93+
8294
let record: (String, &Value) = (id.clone(), doc);
8395
let record_blob = jsonb::to_owned_jsonb(&record)
8496
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
8597
let record_bytes = record_blob.to_vec();
8698
let record_len = record_bytes.len() as u32;
99+
87100
data_file.write_all(&record_len.to_le_bytes())?;
88101
data_file.write_all(&record_bytes)?;
102+
103+
let written = 4 + record_bytes.len() as u64;
104+
current_offset += written;
105+
bytes_since_last_index += written;
89106
}
107+
108+
// Write Index to summary
109+
let index_bytes = serde_json::to_vec(&index)
110+
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
111+
let index_len = index_bytes.len() as u32;
112+
summary_file.write_all(&index_len.to_le_bytes())?;
113+
summary_file.write_all(&index_bytes)?;
114+
90115
Ok(())
91116
}
92117
}
@@ -122,7 +147,7 @@ impl JSTableIterator {
122147
let header: JSTableHeader = serde_json::from_str(&header_str)
123148
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
124149

125-
// We don't need to read the filter here, so we are done with summary file
150+
// We don't need to read the filter or index here
126151

127152
let data_file = File::open(data_path)?;
128153
let data_reader = BufReader::new(data_file);
@@ -134,6 +159,11 @@ impl JSTableIterator {
134159
schema: header.schema,
135160
})
136161
}
162+
163+
pub fn seek(&mut self, offset: u64) -> io::Result<()> {
164+
self.reader.seek(SeekFrom::Start(offset))?;
165+
Ok(())
166+
}
137167
}
138168

139169
impl Iterator for JSTableIterator {
@@ -223,6 +253,49 @@ pub fn read_filter(path: &str) -> io::Result<BinaryFuse8> {
223253
Ok(filter)
224254
}
225255

256+
pub fn read_index(path: &str) -> io::Result<Vec<(String, u64)>> {
257+
let summary_path = format!("{}.summary", path);
258+
let file = File::open(summary_path)?;
259+
let mut reader = BufReader::new(file);
260+
261+
// Read Header Length
262+
let mut len_buf = [0u8; 4];
263+
reader.read_exact(&mut len_buf)?;
264+
let header_len = u32::from_le_bytes(len_buf) as usize;
265+
266+
// Skip Header Blob
267+
io::copy(
268+
&mut reader.by_ref().take(header_len as u64),
269+
&mut io::sink(),
270+
)?;
271+
272+
// Read Filter Length
273+
let mut len_buf = [0u8; 4];
274+
reader.read_exact(&mut len_buf)?;
275+
let filter_len = u32::from_le_bytes(len_buf) as usize;
276+
277+
// Skip Filter Blob
278+
io::copy(
279+
&mut reader.by_ref().take(filter_len as u64),
280+
&mut io::sink(),
281+
)?;
282+
283+
// Read Index Length
284+
let mut len_buf = [0u8; 4];
285+
reader.read_exact(&mut len_buf)?;
286+
let index_len = u32::from_le_bytes(len_buf) as usize;
287+
288+
// Read Index Blob
289+
let mut index_blob = vec![0u8; index_len];
290+
reader.read_exact(&mut index_blob)?;
291+
292+
// Deserialize
293+
let index: Vec<(String, u64)> = serde_json::from_slice(&index_blob)
294+
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
295+
296+
Ok(index)
297+
}
298+
226299
pub fn merge_jstables(tables: &[JSTable]) -> JSTable {
227300
let mut sorted_tables: Vec<&JSTable> = tables.iter().collect();
228301
sorted_tables.sort_by_key(|t| t.timestamp);
@@ -435,4 +508,63 @@ mod tests {
435508
assert_eq!(keys, vec!["a", "b", "c"]);
436509
Ok(())
437510
}
511+
512+
#[test]
513+
fn test_read_index() -> Result<(), Box<dyn std::error::Error>> {
514+
let schema = Schema::new(SchemaType::Object);
515+
let mut documents = BTreeMap::new();
516+
// Insert enough data to trigger indexing (threshold 1024 bytes)
517+
// Each entry: 4 bytes length + record bytes
518+
// Record: ["id", "val..."]
519+
// We want at least one entry after the first one.
520+
521+
let large_val = "x".repeat(500); // ~500 bytes
522+
documents.insert("a".to_string(), json!(large_val));
523+
documents.insert("b".to_string(), json!(large_val));
524+
documents.insert("c".to_string(), json!(large_val));
525+
// a: offset 0. write ~500+ -> offset ~500+.
526+
// b: offset ~500+. bytes_written since a ~ 500+. < 1024.
527+
// c: offset ~1000+. bytes_written since a ~ 1000+. >= 1024?
528+
// Let's make it larger.
529+
let larger_val = "x".repeat(1100);
530+
documents.insert("d".to_string(), json!(larger_val));
531+
documents.insert("e".to_string(), json!(1));
532+
533+
let jstable = JSTable::new(123, "idx_test".to_string(), schema, documents);
534+
let dir = tempdir()?;
535+
let path = dir.path().join("idx_table");
536+
jstable.write(path.to_str().unwrap())?;
537+
538+
let index = read_index(path.to_str().unwrap())?;
539+
540+
// Should contain at least "a" (first) and "e" (after "d" which is large)
541+
// actually "d" is ~1100.
542+
// a (0), b (large), c (large), d (larger), e (1)
543+
// sorted: a, b, c, d, e
544+
545+
// "a": offset 0.
546+
// write "a" (large). bytes=1100.
547+
// next is "b". bytes_since >= 1024. so "b" is indexed?
548+
// Logic:
549+
// if first || bytes_since >= 1024 { push; bytes=0 }
550+
// "a": first. push ("a", 0). bytes=0.
551+
// write "a" (1100). bytes=1100.
552+
// "b": bytes >= 1024. push ("b", off_b). bytes=0.
553+
// write "b" (1100). bytes=1100.
554+
// "c": bytes >= 1024. push ("c", off_c).
555+
556+
assert!(!index.is_empty());
557+
assert_eq!(index[0].0, "a");
558+
assert_eq!(index[0].1, 0);
559+
560+
// Check seeking
561+
let mut iter = JSTableIterator::new(path.to_str().unwrap())?;
562+
// Seek to last index entry
563+
let last = index.last().unwrap();
564+
iter.seek(last.1)?;
565+
let (key, _) = iter.next().unwrap()?;
566+
assert_eq!(key, last.0);
567+
568+
Ok(())
569+
}
438570
}

0 commit comments

Comments
 (0)