Skip to content

Commit 6e1bfb4

Browse files
authored
Add Record struct representing metadata stored in LevelDB (#5)
1 parent 71d7526 commit 6e1bfb4

4 files changed

Lines changed: 555 additions & 0 deletions

File tree

minikv-core/src/error.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@ pub enum Error {
44
/// Errors from the rusty-leveldb storage engine.
55
#[error("LevelDB error: {0}")]
66
LevelDb(String),
7+
8+
/// Malformed record bytes that cannot be decoded.
9+
#[error("Record decode error: {0}")]
10+
RecordDecode(String),
11+
12+
/// A HARD-deleted record was passed to `Record::encode`, which is forbidden.
13+
#[error("Cannot encode a HARD-deleted record into the database")]
14+
HardDeleteEncode,
715
}
816

917
impl From<rusty_leveldb::Status> for Error {

minikv-core/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
pub mod error;
22
pub mod hashing;
33
pub mod locking;
4+
pub mod record;
45
pub mod storage;
56
pub mod volumes;
67

minikv-core/src/record.rs

Lines changed: 373 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,373 @@
1+
//! Metadata record stored in LevelDB.
2+
//!
3+
//! Each key in the database maps to a `Record`, which encodes the
4+
//! deletion state, content hash, content type, and the list of volume
5+
//! addresses holding the object. The wire format is byte-exact and must
6+
//! never change once data is written:
7+
//!
8+
//! ```text
9+
//! [DELETED][HASH<64 hex chars>][TYPE<mime>|]<vol1>,<vol2>,...
10+
//! ```
11+
//!
12+
//! The `DELETED` prefix indicates a soft-deleted object. `HASH` stores
13+
//! a BLAKE3-256 checksum of the object. `TYPE` is optional and terminated
14+
//! by a pipe (`|`), representing the MIME type. The remaining bytes are
15+
//! comma-separated volume addresses.
16+
//!
17+
//! The encode/decode functions are strict inverses for `No` and `Soft`
18+
//! records, ensuring that `Record::encode(Record::decode(bytes)) == bytes`.
19+
//!
20+
//! `Hard` deletion is represented by `Record::not_found()` and is never
21+
//! written to the database.
22+
23+
use std::fmt;
24+
25+
use crate::error::Error;
26+
27+
/// Whether a key has been deleted.
28+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29+
pub enum Deleted {
30+
/// Key exists and is accessible.
31+
No,
32+
/// Key has been soft-deleted (UNLINK). Still in DB, not on volumes.
33+
Soft,
34+
/// Sentinel: key was never written or has been hard-deleted from DB.
35+
/// Never stored in LevelDB. Only returned by `AppState::get_record`
36+
/// when a key is absent.
37+
Hard,
38+
}
39+
40+
impl fmt::Display for Deleted {
41+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
42+
match self {
43+
Deleted::No => write!(f, "no"),
44+
Deleted::Soft => write!(f, "soft"),
45+
Deleted::Hard => write!(f, "hard"),
46+
}
47+
}
48+
}
49+
50+
/// Metadata record for a stored object.
51+
#[derive(Debug, Clone, PartialEq, Eq)]
52+
pub struct Record {
53+
/// Volume server addresses that hold replicas of this object.
54+
/// Format: `hostname:port` or `hostname:port/svXX` for subvolumes.
55+
pub volumes: Vec<String>,
56+
57+
/// Deletion state.
58+
pub deleted: Deleted,
59+
60+
/// BLAKE3-256 hex digest of the object body (64 hex chars), or `None`
61+
/// if checksum was disabled at write time or not yet computed.
62+
pub hash: Option<String>,
63+
64+
/// MIME type of the stored object (e.g. `"image/jpeg"`), or `None`
65+
/// if the client did not supply a `Content-Type` header on PUT.
66+
///
67+
/// Stored in the wire format as `TYPE<mimetype>|`.
68+
/// Used to set the `Content-Type` response header on GET/HEAD so that
69+
/// nginx X-Accel-Redirect responses carry the correct type.
70+
pub content_type: Option<String>,
71+
}
72+
73+
impl Record {
74+
/// The hash field length we expect: BLAKE3-256 = 32 bytes = 64 hex chars.
75+
pub const HASH_HEX_LEN: usize = 64;
76+
77+
/// Construct the "hard-deleted / not found" sentinel record.
78+
/// This is never written to the database.
79+
pub fn not_found() -> Self {
80+
Record {
81+
volumes: Vec::new(),
82+
deleted: Deleted::Hard,
83+
hash: None,
84+
content_type: None,
85+
}
86+
}
87+
88+
/// Decode a raw LevelDB value into a `Record`.
89+
///
90+
/// Returns `Err(Error::RecordDecode)` on malformed input.
91+
pub fn decode(bytes: &[u8]) -> Result<Self, Error> {
92+
let mut s = std::str::from_utf8(bytes)
93+
.map_err(|e| Error::RecordDecode(format!("non-UTF-8 record: {e}")))?;
94+
95+
let mut deleted = Deleted::No;
96+
if let Some(rest) = s.strip_prefix("DELETED") {
97+
deleted = Deleted::Soft;
98+
s = rest;
99+
}
100+
101+
let mut hash: Option<String> = None;
102+
if let Some(rest) = s.strip_prefix("HASH") {
103+
if rest.len() < Self::HASH_HEX_LEN {
104+
return Err(Error::RecordDecode(format!(
105+
"HASH prefix present but only {} hex chars follow (expected {})",
106+
rest.len(),
107+
Self::HASH_HEX_LEN
108+
)));
109+
}
110+
let (hex_part, remainder) = rest.split_at(Self::HASH_HEX_LEN);
111+
// Validate it really is lowercase hex.
112+
if !hex_part.chars().all(|c| c.is_ascii_hexdigit()) {
113+
return Err(Error::RecordDecode(format!(
114+
"HASH field contains non-hex characters: {hex_part:?}"
115+
)));
116+
}
117+
hash = Some(hex_part.to_string());
118+
s = remainder;
119+
}
120+
121+
// Optional TYPE field: TYPE<mimetype>|
122+
// The pipe terminator makes parsing unambiguous without a length prefix.
123+
// MIME types (e.g. "image/jpeg", "application/json") never contain '|'.
124+
let mut content_type: Option<String> = None;
125+
if let Some(rest) = s.strip_prefix("TYPE") {
126+
match rest.find('|') {
127+
Some(pipe_pos) => {
128+
let mime = &rest[..pipe_pos];
129+
if mime.is_empty() {
130+
return Err(Error::RecordDecode("TYPE field is empty".to_string()));
131+
}
132+
content_type = Some(mime.to_string());
133+
s = &rest[pipe_pos + 1..];
134+
}
135+
None => {
136+
return Err(Error::RecordDecode(
137+
"TYPE field missing \'|\' terminator".to_string(),
138+
));
139+
}
140+
}
141+
}
142+
143+
// Remaining bytes are comma-separated volume addresses.
144+
let volumes: Vec<String> = s.split(',').map(str::to_string).collect();
145+
if volumes.is_empty() || (volumes.len() == 1 && volumes[0].is_empty()) {
146+
return Err(Error::RecordDecode(
147+
"record has no volume entries".to_string(),
148+
));
149+
}
150+
151+
Ok(Record {
152+
volumes,
153+
deleted,
154+
hash,
155+
content_type,
156+
})
157+
}
158+
159+
/// Encode a `Record` into its raw LevelDB byte representation.
160+
///
161+
/// Returns `Err(Error::HardDeleteEncode)` if `deleted == Hard`.
162+
/// Such records must never be written to the database.
163+
pub fn encode(&self) -> Result<Vec<u8>, Error> {
164+
if self.deleted == Deleted::Hard {
165+
return Err(Error::HardDeleteEncode);
166+
}
167+
168+
let mut s = String::new();
169+
170+
if self.deleted == Deleted::Soft {
171+
s.push_str("DELETED");
172+
}
173+
174+
if let Some(ref h) = self.hash {
175+
// Defensive check: only write well-formed hashes.
176+
if h.len() == Self::HASH_HEX_LEN && h.chars().all(|c| c.is_ascii_hexdigit()) {
177+
s.push_str("HASH");
178+
s.push_str(h);
179+
}
180+
}
181+
182+
s.push_str(&self.volumes.join(","));
183+
Ok(s.into_bytes())
184+
}
185+
}
186+
187+
#[cfg(test)]
188+
mod tests {
189+
use super::*;
190+
191+
/// Helper: encode then decode must be a perfect round-trip.
192+
fn round_trip(rec: &Record) {
193+
let encoded = rec.encode().expect("encode should succeed");
194+
let decoded = Record::decode(&encoded).expect("decode should succeed");
195+
assert_eq!(rec, &decoded, "round-trip failed for {rec:?}");
196+
}
197+
198+
#[test]
199+
fn encode_decode_soft_deleted_multi_volume() {
200+
let rec = Record {
201+
volumes: vec!["hello".into(), "world".into()],
202+
deleted: Deleted::Soft,
203+
hash: None,
204+
content_type: None,
205+
};
206+
let encoded = rec.encode().unwrap();
207+
assert_eq!(encoded, b"DELETEDhello,world");
208+
round_trip(&rec);
209+
}
210+
211+
#[test]
212+
fn encode_decode_not_deleted_multi_volume() {
213+
let rec = Record {
214+
volumes: vec!["hello".into(), "world".into()],
215+
deleted: Deleted::No,
216+
hash: None,
217+
content_type: None,
218+
};
219+
let encoded = rec.encode().unwrap();
220+
assert_eq!(encoded, b"hello,world");
221+
round_trip(&rec);
222+
}
223+
224+
#[test]
225+
fn encode_decode_single_volume_no_delete() {
226+
let rec = Record {
227+
volumes: vec!["hello".into()],
228+
deleted: Deleted::No,
229+
hash: None,
230+
content_type: None,
231+
};
232+
let encoded = rec.encode().unwrap();
233+
assert_eq!(encoded, b"hello");
234+
round_trip(&rec);
235+
}
236+
237+
#[test]
238+
fn encode_decode_single_volume_soft_deleted() {
239+
let rec = Record {
240+
volumes: vec!["hello".into()],
241+
deleted: Deleted::Soft,
242+
hash: None,
243+
content_type: None,
244+
};
245+
let encoded = rec.encode().unwrap();
246+
assert_eq!(encoded, b"DELETEDhello");
247+
round_trip(&rec);
248+
}
249+
250+
#[test]
251+
fn encode_decode_with_blake3_hash_soft_deleted() {
252+
// 64-char lowercase hex BLAKE3 digest
253+
let hash = "ea8f163db38682925e4491c5e58d4bb3506ef8c14eb78a86e908c5624a67200f";
254+
let rec = Record {
255+
volumes: vec!["hello".into()],
256+
deleted: Deleted::Soft,
257+
hash: Some(hash.to_string()),
258+
content_type: None,
259+
};
260+
let encoded = rec.encode().unwrap();
261+
let expected = format!("DELETEDHASH{hash}hello");
262+
assert_eq!(String::from_utf8(encoded).unwrap(), expected);
263+
round_trip(&rec);
264+
}
265+
266+
#[test]
267+
fn encode_decode_with_blake3_hash_not_deleted() {
268+
let hash = "ea8f163db38682925e4491c5e58d4bb3506ef8c14eb78a86e908c5624a67200f";
269+
let rec = Record {
270+
volumes: vec!["hello".into()],
271+
deleted: Deleted::No,
272+
hash: Some(hash.to_string()),
273+
content_type: None,
274+
};
275+
let encoded = rec.encode().unwrap();
276+
let expected = format!("HASH{hash}hello");
277+
assert_eq!(String::from_utf8(encoded).unwrap(), expected);
278+
round_trip(&rec);
279+
}
280+
281+
#[test]
282+
fn hard_delete_encode_is_error() {
283+
let rec = Record::not_found();
284+
assert!(rec.encode().is_err());
285+
}
286+
287+
#[test]
288+
fn decode_rejects_short_hash() {
289+
// HASH prefix followed by only 10 hex chars — must fail
290+
let bad = b"HASH0123456789hello";
291+
assert!(Record::decode(bad).is_err());
292+
}
293+
294+
#[test]
295+
fn decode_rejects_non_hex_hash() {
296+
let bad = format!(
297+
"HASH{}hello",
298+
"ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ"
299+
);
300+
assert!(Record::decode(bad.as_bytes()).is_err());
301+
}
302+
#[test]
303+
fn encode_decode_with_content_type() {
304+
let rec = Record {
305+
volumes: vec!["hello".into()],
306+
deleted: Deleted::No,
307+
hash: None,
308+
content_type: Some("image/jpeg".to_string()),
309+
};
310+
let encoded = rec.encode().unwrap();
311+
assert_eq!(String::from_utf8(encoded).unwrap(), "TYPEimage/jpeg|hello");
312+
round_trip(&rec);
313+
}
314+
315+
#[test]
316+
fn encode_decode_with_hash_and_content_type() {
317+
let hash = "ea8f163db38682925e4491c5e58d4bb3506ef8c14eb78a86e908c5624a67200f";
318+
let rec = Record {
319+
volumes: vec!["vol1".into(), "vol2".into()],
320+
deleted: Deleted::No,
321+
hash: Some(hash.to_string()),
322+
content_type: Some("application/json".to_string()),
323+
};
324+
let encoded = rec.encode().unwrap();
325+
let expected = format!("HASH{hash}TYPEapplication/json|vol1,vol2");
326+
assert_eq!(String::from_utf8(encoded).unwrap(), expected);
327+
round_trip(&rec);
328+
}
329+
330+
#[test]
331+
fn encode_decode_deleted_with_content_type() {
332+
let rec = Record {
333+
volumes: vec!["vol1".into()],
334+
deleted: Deleted::Soft,
335+
hash: None,
336+
content_type: Some("video/mp4".to_string()),
337+
};
338+
let encoded = rec.encode().unwrap();
339+
assert_eq!(
340+
String::from_utf8(encoded).unwrap(),
341+
"DELETEDTYPEvideo/mp4|vol1"
342+
);
343+
round_trip(&rec);
344+
}
345+
346+
#[test]
347+
fn decode_rejects_type_without_pipe_terminator() {
348+
// TYPE field with no | terminator must be rejected
349+
let bad = b"TYPEimage/jpegvolume1:8080";
350+
// This will be interpreted as no TYPE prefix found (no | found) -> error
351+
assert!(Record::decode(bad).is_err());
352+
}
353+
354+
#[test]
355+
fn decode_rejects_empty_type_field() {
356+
let bad = b"TYPE|volume1:8080";
357+
assert!(Record::decode(bad).is_err());
358+
}
359+
360+
#[test]
361+
fn content_type_none_round_trips_without_type_field() {
362+
let rec = Record {
363+
volumes: vec!["vol1".into()],
364+
deleted: Deleted::No,
365+
hash: None,
366+
content_type: None,
367+
};
368+
let encoded = rec.encode().unwrap();
369+
// Must not contain TYPE at all
370+
assert!(!String::from_utf8_lossy(&encoded).contains("TYPE"));
371+
round_trip(&rec);
372+
}
373+
}

0 commit comments

Comments
 (0)