|
| 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 | +} |
0 commit comments