Skip to content

Commit ba2725a

Browse files
authored
Support rebalancing objects to their ideal volume set (#9)
* Fix cargo clippy lint warnings * Support rebalancing objects to their ideal volume set
1 parent 6c37e56 commit ba2725a

4 files changed

Lines changed: 422 additions & 4 deletions

File tree

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

minikv-core/src/rebalance.rs

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
//! Rebalance objects to their deterministic ideal volume set.
2+
//!
3+
//! Rebalancing is required when:
4+
//! - Volume servers are added or removed.
5+
//! - The configured replica count changes.
6+
//! - Objects were written to non-ideal volumes (e.g. due to temporary outages).
7+
//!
8+
//! For each key, the ideal replica set is computed using `key_to_volume`.
9+
//! The object is then migrated so that the set of volumes physically
10+
//! storing it matches this ideal set.
11+
//!
12+
//! # Algorithm (per key)
13+
//!
14+
//! 1. Issue HEAD requests to all volumes recorded in the DB to determine
15+
//! which volumes actually contain the object.
16+
//! 2. If no volume is reachable → return `Ok(false)` (object unavailable).
17+
//! 3. If the reachable set already matches the ideal set → no-op.
18+
//! 4. GET the object from the first reachable volume.
19+
//! 5. PUT the object to any ideal volume that does not already contain it.
20+
//! 6. Update the DB record to the ideal volume list.
21+
//! 7. DELETE the object from volumes no longer in the ideal set.
22+
//!
23+
//! # Metadata Handling
24+
//!
25+
//! - `content_type` is preserved from the existing record.
26+
//! - `hash` is cleared during rebalance. The object body is copied between
27+
//! volumes and is not re-verified here.
28+
//! - `deleted` is always written as `Deleted::No`.
29+
//!
30+
//! # Concurrency
31+
//!
32+
//! `rebalance_all` limits concurrency using a semaphore with 16
33+
//! concurrent tasks.
34+
35+
use std::sync::Arc;
36+
use std::time::Duration;
37+
38+
use tokio::sync::Semaphore;
39+
use tracing::{error, info, warn};
40+
41+
use crate::error::Error;
42+
use crate::hashing::key_to_path;
43+
use crate::record::{Deleted, Record};
44+
use crate::replication::{remote_delete, remote_get, remote_head, remote_put};
45+
use crate::state::AppState;
46+
use crate::volumes::key_to_volume;
47+
48+
/// Rebalance a single key to its ideal volume set.
49+
///
50+
/// `volumes` are the volumes recorded in the DB.
51+
/// `kvolumes` are the ideal volumes computed by `key_to_volume`.
52+
///
53+
/// Returns:
54+
/// - `Ok(true)` if already balanced or successfully migrated.
55+
/// - `Ok(false)` if the object is missing or a migration step failed.
56+
/// - `Err(_)` for unexpected internal failures.
57+
pub async fn rebalance_key(
58+
state: &AppState,
59+
key: &[u8],
60+
volumes: &[String],
61+
kvolumes: &[String],
62+
) -> Result<bool, Error> {
63+
let kp = key_to_path(key);
64+
65+
// Step 1: find volumes that actually have the data.
66+
let mut reachable: Vec<String> = Vec::new();
67+
for vol in volumes {
68+
let url = format!("http://{vol}{kp}");
69+
match remote_head(&state.http_client, &url, Duration::from_secs(60)).await {
70+
Ok(true) => reachable.push(vol.clone()),
71+
Ok(false) => {}
72+
Err(e) => {
73+
warn!(?e, url, "rebalance HEAD error");
74+
return Ok(false);
75+
}
76+
}
77+
}
78+
79+
if reachable.is_empty() {
80+
warn!(
81+
key = ?String::from_utf8_lossy(key),
82+
"rebalance impossible. Object missing from all volumes"
83+
);
84+
return Ok(false);
85+
}
86+
87+
// Step 2: check if already in ideal position.
88+
if !crate::volumes::needs_rebalance(&reachable, kvolumes) {
89+
return Ok(true);
90+
}
91+
92+
info!(
93+
key = ?String::from_utf8_lossy(key),
94+
from = ?reachable,
95+
to = ?kvolumes,
96+
"rebalancing key"
97+
);
98+
99+
// Step 3: read object from first reachable volume.
100+
let mut body = None;
101+
for vol in &reachable {
102+
let url = format!("http://{vol}{kp}");
103+
match remote_get(&state.http_client, &url).await {
104+
Ok(b) => {
105+
body = Some(b);
106+
break;
107+
}
108+
Err(e) => warn!(?e, url, "rebalance GET error"),
109+
}
110+
}
111+
let body = match body {
112+
Some(b) => b,
113+
None => {
114+
error!(key = ?String::from_utf8_lossy(key), "rebalance: could not read from any reachable volume");
115+
return Ok(false);
116+
}
117+
};
118+
119+
// Step 4: PUT to volumes that need it (not already in reachable set).
120+
for vol in kvolumes {
121+
if reachable.contains(vol) {
122+
continue; // already there
123+
}
124+
let url = format!("http://{vol}{kp}");
125+
if let Err(e) = remote_put(&state.http_client, &url, body.clone()).await {
126+
warn!(?e, url, "rebalance PUT error");
127+
return Ok(false);
128+
}
129+
}
130+
131+
// Step 5: update DB
132+
// preserve content_type from the existing record.
133+
// Hash is intentionally cleared during rebalance: the body may have been
134+
// copied across volumes and we cannot re-verify it here without re-reading.
135+
// This matches the original Go behaviour.
136+
let existing = state.get_record(key).await;
137+
if !state
138+
.put_record(
139+
key,
140+
Record {
141+
volumes: kvolumes.to_vec(),
142+
deleted: Deleted::No,
143+
hash: None,
144+
content_type: existing.content_type,
145+
},
146+
)
147+
.await
148+
{
149+
error!("rebalance: DB put failed");
150+
return Ok(false);
151+
}
152+
153+
// Step 6: DELETE from volumes no longer needed.
154+
for vol in &reachable {
155+
if kvolumes.contains(vol) {
156+
continue; // still needed
157+
}
158+
let url = format!("http://{vol}{kp}");
159+
if let Err(e) = remote_delete(&state.http_client, &url).await {
160+
warn!(?e, url, "rebalance DELETE error");
161+
return Ok(false);
162+
}
163+
}
164+
165+
Ok(true)
166+
}
167+
168+
/// Rebalance all records currently stored in the DB.
169+
///
170+
/// Performs a full DB scan and spawns bounded async tasks to
171+
/// rebalance each key independently.
172+
///
173+
/// Corrupt records are skipped.
174+
pub async fn rebalance_all(state: Arc<AppState>) {
175+
info!("starting full rebalance to {:?}", state.volumes);
176+
177+
// Collect all entries (blocking LevelDB scan, run off the async executor).
178+
let db = Arc::clone(&state.db);
179+
let entries: Vec<(Vec<u8>, Vec<u8>)> =
180+
tokio::task::spawn_blocking(move || db.scan_all().unwrap_or_default())
181+
.await
182+
.unwrap_or_default();
183+
184+
info!("rebalance: scanning {} keys", entries.len());
185+
186+
let sem = Arc::new(Semaphore::new(16));
187+
let mut handles = Vec::new();
188+
189+
for (raw_key, raw_val) in entries {
190+
let rec = match Record::decode(&raw_val) {
191+
Ok(r) => r,
192+
Err(e) => {
193+
warn!(?e, "skipping corrupt record during rebalance");
194+
continue;
195+
}
196+
};
197+
198+
let kvolumes = key_to_volume(&raw_key, &state.volumes, state.replicas, state.subvolumes);
199+
let state = Arc::clone(&state);
200+
let sem = Arc::clone(&sem);
201+
202+
let handle = tokio::spawn(async move {
203+
let _permit = sem.acquire().await.expect("semaphore closed");
204+
let result = rebalance_key(&state, &raw_key, &rec.volumes, &kvolumes).await;
205+
if let Err(e) = result {
206+
warn!(?e, key = ?String::from_utf8_lossy(&raw_key), "rebalance error");
207+
}
208+
});
209+
handles.push(handle);
210+
}
211+
212+
for h in handles {
213+
let _ = h.await;
214+
}
215+
216+
info!("rebalance complete");
217+
}
218+
219+
#[cfg(test)]
220+
mod tests {
221+
use crate::volumes::needs_rebalance;
222+
223+
#[test]
224+
fn no_rebalance_when_already_ideal() {
225+
let current = vec!["a".into(), "b".into()];
226+
let ideal = vec!["a".into(), "b".into()];
227+
assert!(!needs_rebalance(&current, &ideal));
228+
}
229+
230+
#[test]
231+
fn rebalance_needed_when_different() {
232+
let current = vec!["a".into(), "b".into()];
233+
let ideal = vec!["a".into(), "c".into()];
234+
assert!(needs_rebalance(&current, &ideal));
235+
}
236+
}

minikv-core/src/rebuild.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
//!
77
//! The rebuild process is fully deterministic with respect to:
88
//! - key decoding
9-
//! - volume selection (`key2volume`)
9+
//! - volume selection (`key_to_volume`)
1010
//! - replica ordering
1111
//!
1212
//! It does not recover hash or content-type metadata, as that information
@@ -21,7 +21,7 @@
2121
//! 3. For each file entry:
2222
//! - Base64-decode the filename to obtain the raw key.
2323
//! - Merge the current volume into the key's record.
24-
//! - Reorder volumes according to `key2volume`, preserving unknown
24+
//! - Reorder volumes according to `key_to_volume`, preserving unknown
2525
//! volumes at the end.
2626
//! 4. Write the reconstructed record with:
2727
//! - `deleted = No`
@@ -99,7 +99,7 @@ fn is_subvolume_dir(entry: &AutoindexEntry) -> bool {
9999
///
100100
/// - Decodes the filename into raw key bytes.
101101
/// - Acquires a per-key lock to prevent concurrent modification.
102-
/// - Computes the ideal replica ordering via `key2volume`.
102+
/// - Computes the ideal replica ordering via `key_to_volume`.
103103
/// - Merges the current volume into the existing record.
104104
/// - Reorders volumes deterministically.
105105
/// - Writes a reconstructed `Record`.
@@ -255,7 +255,7 @@ pub async fn rebuild_all(state: Arc<AppState>) {
255255
};
256256

257257
// Check if volume uses subvolume directories.
258-
let has_subvolumes = top_listing.iter().any(|e| is_subvolume_dir(e));
258+
let has_subvolumes = top_listing.iter().any(is_subvolume_dir);
259259

260260
if has_subvolumes {
261261
for sv in top_listing.iter().filter(|e| is_subvolume_dir(e)) {

0 commit comments

Comments
 (0)