Skip to content

Commit 6c37e56

Browse files
authored
Support rebuild of leveldb metadata (#8)
* Fix incorrect assertion in replication test * Support rebuild of leveldb metadata
1 parent baf5044 commit 6c37e56

7 files changed

Lines changed: 289 additions & 1 deletion

File tree

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ categories = ["database", "network-programming"]
1717
thiserror = "2"
1818
tracing = "0.1"
1919

20+
serde = "1"
21+
serde_json = "1"
2022
base64 = "0.22"
2123
blake3 = "1.8"
2224
hex = "0.4"

minikv-core/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,17 @@ categories.workspace = true
1313
[dependencies]
1414
thiserror.workspace = true
1515
tracing.workspace = true
16+
serde = { workspace = true, features = ["derive"] }
17+
serde_json = { workspace = true }
1618
base64 = { workspace = true }
1719
blake3 = { workspace = true }
1820
hex = { workspace = true }
1921
dashmap = { workspace = true }
2022
rusty-leveldb = { workspace = true }
2123
reqwest = { workspace = true, features = ["rustls", "stream", "json"] }
2224
bytes = { workspace = true }
25+
tokio = { workspace = true, features = ["full"] }
26+
2327

2428
[dev-dependencies]
2529
tokio = { workspace = true, features = ["full"] }

minikv-core/src/error.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,13 @@ pub enum Error {
2020
/// A remote volume server returned an unexpected HTTP status code.
2121
#[error("Remote volume returned {status} for {url}")]
2222
RemoteStatus { status: u16, url: String },
23+
24+
/// Rebuild encountered a volume that returned malformed autoindex JSON.
25+
#[error("Autoindex JSON parse error for {url}: {source}")]
26+
AutoindexParse {
27+
url: String,
28+
source: serde_json::Error,
29+
},
2330
}
2431

2532
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 rebuild;
45
pub mod record;
56
pub mod replication;
67
pub mod state;

minikv-core/src/rebuild.rs

Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
//! Rebuild LevelDB metadata by scanning all configured volume servers.
2+
//!
3+
//! This operation is intended for disaster recovery when the metadata
4+
//! database is lost or corrupted but object files remain intact on the
5+
//! volume servers.
6+
//!
7+
//! The rebuild process is fully deterministic with respect to:
8+
//! - key decoding
9+
//! - volume selection (`key2volume`)
10+
//! - replica ordering
11+
//!
12+
//! It does not recover hash or content-type metadata, as that information
13+
//! exists only in LevelDB and is not stored on volume servers.
14+
//!
15+
//! # Algorithm
16+
//!
17+
//! 1. Delete all existing records from LevelDB (destructive).
18+
//! 2. For each configured volume:
19+
//! - Detect whether subvolumes (`svXX/`) are used.
20+
//! - Traverse the two-level hex directory structure.
21+
//! 3. For each file entry:
22+
//! - Base64-decode the filename to obtain the raw key.
23+
//! - Merge the current volume into the key's record.
24+
//! - Reorder volumes according to `key2volume`, preserving unknown
25+
//! volumes at the end.
26+
//! 4. Write the reconstructed record with:
27+
//! - `deleted = No`
28+
//! - `hash = None`
29+
//! - `content_type = None`
30+
//!
31+
//! # Expected Directory Layout
32+
//!
33+
//! ```text
34+
//! http://vol/ ← either hex dirs or svXX/
35+
//! http://vol/svXX/ ← optional subvolume
36+
//! http://vol/XX/ ← first-level hex dir
37+
//! http://vol/XX/YY/ ← second-level hex dir
38+
//! http://vol/XX/YY/<b64> ← base64-encoded key filename
39+
//! ```
40+
//!
41+
//! # Concurrency
42+
//!
43+
//! Leaf directories are processed in parallel, limited by a semaphore
44+
//! of 128 concurrent tasks.
45+
//!
46+
//! # Safety
47+
//!
48+
//! This operation permanently clears the metadata database before scanning.
49+
//! It must not be executed against a healthy database.
50+
51+
use std::sync::Arc;
52+
53+
use serde::Deserialize;
54+
use tokio::sync::Semaphore;
55+
use tracing::{error, info, warn};
56+
57+
use crate::error::Error;
58+
use crate::record::{Deleted, Record};
59+
use crate::replication::remote_get;
60+
use crate::state::AppState;
61+
use crate::volumes::key_to_volume;
62+
63+
/// A single entry in an nginx autoindex JSON response.
64+
#[derive(Debug, Deserialize)]
65+
pub struct AutoindexEntry {
66+
pub name: String,
67+
#[serde(rename = "type")]
68+
pub entry_type: String,
69+
pub mtime: String,
70+
}
71+
72+
/// Fetch and parse an nginx autoindex JSON listing from `url`.
73+
async fn get_listing(client: &reqwest::Client, url: &str) -> Result<Vec<AutoindexEntry>, Error> {
74+
let body = remote_get(client, url).await?;
75+
let entries: Vec<AutoindexEntry> =
76+
serde_json::from_slice(&body).map_err(|e| Error::AutoindexParse {
77+
url: url.to_string(),
78+
source: e,
79+
})?;
80+
Ok(entries)
81+
}
82+
83+
/// Return `true` if `entry` looks like a valid 2-char hex directory.
84+
fn is_hex_dir(entry: &AutoindexEntry) -> bool {
85+
entry.entry_type == "directory"
86+
&& entry.name.len() == 2
87+
&& entry.name.chars().all(|c| c.is_ascii_hexdigit())
88+
}
89+
90+
/// Return `true` if `entry` looks like a subvolume directory (`svXX`).
91+
fn is_subvolume_dir(entry: &AutoindexEntry) -> bool {
92+
entry.entry_type == "directory"
93+
&& entry.name.len() == 4
94+
&& entry.name.starts_with("sv")
95+
&& entry.name[2..].chars().all(|c| c.is_ascii_hexdigit())
96+
}
97+
98+
/// Merge a single object (identified by its base64 filename) into the DB.
99+
///
100+
/// - Decodes the filename into raw key bytes.
101+
/// - Acquires a per-key lock to prevent concurrent modification.
102+
/// - Computes the ideal replica ordering via `key2volume`.
103+
/// - Merges the current volume into the existing record.
104+
/// - Reorders volumes deterministically.
105+
/// - Writes a reconstructed `Record`.
106+
///
107+
/// Hard-deleted records are treated as non-existent during rebuild.
108+
/// Hash and content-type metadata cannot be restored.
109+
async fn rebuild_entry(state: &AppState, vol: &str, b64name: &str) -> bool {
110+
// Decode base64 filename → raw key bytes.
111+
use base64::{Engine as _, engine::general_purpose::STANDARD as B64};
112+
let key = match B64.decode(b64name) {
113+
Ok(k) => k,
114+
Err(e) => {
115+
warn!(?e, b64name, "rebuild: base64 decode error");
116+
return false;
117+
}
118+
};
119+
120+
// Acquire per-key lock (non-blocking; if already held, skip this entry).
121+
let _guard = match state.key_lock.try_lock(&String::from_utf8_lossy(&key)) {
122+
Some(g) => g,
123+
None => {
124+
warn!(key = ?String::from_utf8_lossy(&key), "rebuild: key locked, skipping");
125+
return false;
126+
}
127+
};
128+
129+
// Compute ideal volume ordering.
130+
let kvolumes = key_to_volume(&key, &state.volumes, state.replicas, state.subvolumes);
131+
132+
// Read existing record (if any) and merge `vol` into it.
133+
let existing = state.get_record(&key).await;
134+
let merged_volumes = if existing.deleted == Deleted::Hard {
135+
vec![vol.to_string()]
136+
} else {
137+
let mut v = existing.volumes.clone();
138+
if !v.contains(&vol.to_string()) {
139+
v.push(vol.to_string());
140+
}
141+
v
142+
};
143+
144+
// Re-order: prefer kvolumes order, append unknowns at the end.
145+
let mut ordered: Vec<String> = Vec::new();
146+
for kv in &kvolumes {
147+
if merged_volumes.contains(kv) {
148+
ordered.push(kv.clone());
149+
}
150+
}
151+
for mv in &merged_volumes {
152+
if !kvolumes.contains(mv) {
153+
ordered.push(mv.clone());
154+
}
155+
}
156+
157+
if !state
158+
.put_record(
159+
&key,
160+
Record {
161+
volumes: ordered,
162+
deleted: Deleted::No,
163+
hash: None,
164+
// content_type cannot be recovered during rebuild: MIME metadata
165+
// is stored only in LevelDB, never on the volume servers.
166+
// Objects will need to be re-PUT (or manually patched) to
167+
// restore Content-Type after a full DB rebuild.
168+
content_type: None,
169+
},
170+
)
171+
.await
172+
{
173+
error!(key = ?String::from_utf8_lossy(&key), "rebuild: DB put error");
174+
return false;
175+
}
176+
177+
true
178+
}
179+
180+
/// Scan all leaf directories under `base_url` and dispatch rebuild tasks.
181+
async fn scan_volume(state: Arc<AppState>, vol: String, base_url: String, sem: Arc<Semaphore>) {
182+
let listing = match get_listing(&state.http_client, &base_url).await {
183+
Ok(l) => l,
184+
Err(e) => {
185+
warn!(?e, base_url, "rebuild: failed to list volume root");
186+
return;
187+
}
188+
};
189+
190+
let mut handles = Vec::new();
191+
192+
for first in listing.iter().filter(|e| is_hex_dir(e)) {
193+
let url1 = format!("{}{}/", base_url, first.name);
194+
let second_listing = match get_listing(&state.http_client, &url1).await {
195+
Ok(l) => l,
196+
Err(e) => {
197+
warn!(?e, url1, "rebuild: failed to list first-level dir");
198+
continue;
199+
}
200+
};
201+
202+
for second in second_listing.iter().filter(|e| is_hex_dir(e)) {
203+
let leaf_url = format!("{}{}/", url1, second.name);
204+
let state = Arc::clone(&state);
205+
let vol = vol.clone();
206+
let sem = Arc::clone(&sem);
207+
208+
let handle = tokio::spawn(async move {
209+
let _permit = sem.acquire().await.expect("semaphore closed");
210+
let files = match get_listing(&state.http_client, &leaf_url).await {
211+
Ok(f) => f,
212+
Err(e) => {
213+
warn!(?e, leaf_url, "rebuild: leaf listing failed");
214+
return;
215+
}
216+
};
217+
for file in files.iter().filter(|e| e.entry_type == "file") {
218+
rebuild_entry(&state, &vol, &file.name).await;
219+
}
220+
});
221+
handles.push(handle);
222+
}
223+
}
224+
225+
for h in handles {
226+
let _ = h.await;
227+
}
228+
}
229+
230+
/// Rebuild the entire metadata database from all configured volumes.
231+
///
232+
/// ## Warning
233+
///
234+
/// **This is a destructive operation** because it clears the database
235+
/// and reconstructs records solely from object presence on volume servers.
236+
pub async fn rebuild_all(state: Arc<AppState>) {
237+
info!("starting rebuild on {:?}", state.volumes);
238+
239+
// Clear all existing records.
240+
if let Err(e) = state.db.delete_all() {
241+
error!(?e, "rebuild: failed to clear DB");
242+
return;
243+
}
244+
245+
let sem = Arc::new(Semaphore::new(128));
246+
247+
for vol in &state.volumes {
248+
let root_url = format!("http://{vol}/");
249+
let top_listing = match get_listing(&state.http_client, &root_url).await {
250+
Ok(l) => l,
251+
Err(e) => {
252+
warn!(?e, vol, "rebuild: failed to list volume");
253+
continue;
254+
}
255+
};
256+
257+
// Check if volume uses subvolume directories.
258+
let has_subvolumes = top_listing.iter().any(|e| is_subvolume_dir(e));
259+
260+
if has_subvolumes {
261+
for sv in top_listing.iter().filter(|e| is_subvolume_dir(e)) {
262+
let sv_url = format!("{}{}/", root_url, sv.name);
263+
let sv_vol = format!("{vol}/{}", sv.name);
264+
scan_volume(Arc::clone(&state), sv_vol, sv_url, Arc::clone(&sem)).await;
265+
}
266+
} else {
267+
scan_volume(Arc::clone(&state), vol.clone(), root_url, Arc::clone(&sem)).await;
268+
}
269+
}
270+
271+
info!("rebuild complete");
272+
}

minikv-core/src/replication.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ mod tests {
168168

169169
let client = build_volume_client();
170170
let result = remote_head(&client, &server.uri(), Duration::from_secs(5)).await;
171-
assert!(result.unwrap());
171+
assert!(!result.unwrap());
172172
}
173173

174174
#[tokio::test]

0 commit comments

Comments
 (0)