use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::erasure;
use crate::das;
use crate::store::{self, ChunkStore, FileEntry, GSet};
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub enum CachePolicy {
Lru,
Cold,
Hot,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub enum Tier {
Critical,
Active,
Archive,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct DiskConfig {
pub name: String,
pub redundancy: Redundancy,
pub tier: Tier,
pub cache_policy: CachePolicy,
pub shard_copies: usize,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub enum Redundancy {
Tolerate(usize),
Max,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct DeviceAttachment {
pub device_name: String,
pub disk_name: String,
pub capacity: u64,
}
#[derive(Clone, Debug)]
pub struct DiskStatus {
pub name: String,
pub devices: usize,
pub total_capacity: u64,
pub k: usize,
pub n: usize,
pub f: usize,
pub per_file_overhead: f64,
pub healthy: bool,
pub message: String,
}
pub struct VDiskManager {
disks: HashMap<String, DiskConfig>,
attachments: Vec<DeviceAttachment>,
stores: HashMap<String, ChunkStore>,
registry: GSet,
base_dir: PathBuf,
}
impl VDiskManager {
pub fn new(base_dir: &Path) -> std::io::Result<Self> {
std::fs::create_dir_all(base_dir)?;
Ok(Self {
disks: HashMap::new(),
attachments: Vec::new(),
stores: HashMap::new(),
registry: GSet::new(),
base_dir: base_dir.to_path_buf(),
})
}
pub fn create_disk(&mut self, config: DiskConfig) -> Result<(), String> {
if self.disks.contains_key(&config.name) {
return Err(format!("disk '{}' already exists", config.name));
}
self.disks.insert(config.name.clone(), config);
Ok(())
}
pub fn attach(
&mut self,
device_name: &str,
disk_name: &str,
capacity: u64,
) -> Result<(), String> {
if !self.disks.contains_key(disk_name) {
return Err(format!("disk '{}' does not exist", disk_name));
}
let attachment = DeviceAttachment {
device_name: device_name.to_string(),
disk_name: disk_name.to_string(),
capacity,
};
self.attachments.push(attachment);
if !self.stores.contains_key(device_name) {
let device_dir = self.base_dir.join(device_name);
let store = ChunkStore::new(&device_dir, capacity)
.map_err(|e| e.to_string())?;
self.stores.insert(device_name.to_string(), store);
}
Ok(())
}
pub fn status(&self, disk_name: &str) -> Result<DiskStatus, String> {
let config = self
.disks
.get(disk_name)
.ok_or_else(|| format!("disk '{}' not found", disk_name))?;
let devices: Vec<&DeviceAttachment> = self
.attachments
.iter()
.filter(|a| a.disk_name == disk_name)
.collect();
let n = devices.len();
let total_capacity: u64 = devices.iter().map(|a| a.capacity).sum();
let f = match &config.redundancy {
Redundancy::Max => if n > 0 { n - 1 } else { 0 },
Redundancy::Tolerate(f) => *f,
};
let k = if n > f { n - f } else { 1 };
let per_file_overhead = if k > 0 { n as f64 / k as f64 } else { 0.0 };
let healthy = n > f && f > 0;
let message = if n == 0 {
"no devices attached".to_string()
} else if n <= f {
format!("need {} more devices for f={}", f - n + 1, f)
} else if f == 0 {
"no redundancy (f=0)".to_string()
} else {
format!("healthy (f={})", f)
};
Ok(DiskStatus {
name: disk_name.to_string(),
devices: n,
total_capacity,
k,
n,
f,
per_file_overhead,
healthy,
message,
})
}
pub fn put_file(
&mut self,
disk_name: &str,
file_name: &str,
data: &[u8],
) -> Result<das::DasCommitment, String> {
let config = self
.disks
.get(disk_name)
.ok_or_else(|| format!("disk '{}' not found", disk_name))?
.clone();
let device_names: Vec<String> = self
.attachments
.iter()
.filter(|a| a.disk_name == disk_name)
.map(|a| a.device_name.clone())
.collect();
let n_devices = device_names.len();
if n_devices == 0 {
return Err("no devices attached to disk".to_string());
}
let f = match &config.redundancy {
Redundancy::Max => n_devices - 1,
Redundancy::Tolerate(f) => *f,
};
let k = n_devices - f;
let n_ntt = n_devices.next_power_of_two();
let shards = erasure::encode(data, k, n_ntt);
let commitment = das::commit(&shards, k, data.len());
let mut shard_hashes = Vec::with_capacity(n_ntt);
for shard in &shards {
let device_idx = shard.index % n_devices;
let device_name = &device_names[device_idx];
if let Some(store) = self.stores.get_mut(device_name) {
let hash = store.put(shard).map_err(|e| e.to_string())?;
shard_hashes.push(hash.to_hex());
}
}
let timestamp = store::now_ms();
let device_id = "vdisk".to_string();
let entry_hash = FileEntry::compute_hash(
file_name, &shard_hashes, timestamp, &device_id,
);
let entry = FileEntry {
name: file_name.to_string(),
original_len: data.len(),
k,
n: n_ntt,
shard_hashes,
timestamp,
entry_hash,
device_id,
das_root: format!("{:?}", commitment.root),
shard_copies: 1,
deleted: false,
};
self.registry.insert(entry);
Ok(commitment)
}
pub fn get_file(&self, file_name: &str) -> Result<Vec<u8>, String> {
let entry = self
.registry
.get(file_name)
.ok_or_else(|| format!("file '{}' not found", file_name))?;
let mut available_shards = Vec::new();
for (shard_idx, hash_hex) in entry.shard_hashes.iter().enumerate() {
let hash = hex_to_hash(hash_hex)?;
for store in self.stores.values() {
if let Ok(bytes) = store.get(&hash) {
let shard = store::bytes_to_shard(shard_idx, &bytes);
available_shards.push(shard);
break;
}
}
}
if available_shards.len() < entry.k {
return Err(format!(
"only {} of {} required shards available",
available_shards.len(),
entry.k
));
}
Ok(erasure::decode(
&available_shards,
entry.k,
entry.n,
entry.original_len,
))
}
pub fn list_files(&self) -> Vec<&str> {
self.registry.list()
}
pub fn list_disks(&self) -> Vec<&str> {
let mut names: Vec<&str> = self.disks.keys().map(|s| s.as_str()).collect();
names.sort();
names
}
pub fn registry(&self) -> &GSet {
&self.registry
}
pub fn merge_registry(&mut self, other: &GSet) {
self.registry.merge(other);
}
pub fn rechunk(&mut self, disk_name: &str) -> Result<(usize, usize), String> {
let config = self
.disks
.get(disk_name)
.ok_or_else(|| format!("disk '{}' not found", disk_name))?
.clone();
let device_names: Vec<String> = self
.attachments
.iter()
.filter(|a| a.disk_name == disk_name)
.map(|a| a.device_name.clone())
.collect();
let n_devices = device_names.len();
if n_devices < 2 {
return Ok((0, 0));
}
let new_f = match &config.redundancy {
Redundancy::Max => n_devices - 1,
Redundancy::Tolerate(f) => *f,
};
let new_k = n_devices - new_f;
let new_n = n_devices.next_power_of_two();
let files_to_rechunk: Vec<String> = self
.registry
.files
.values()
.filter(|e| e.k != new_k || e.n != new_n)
.map(|e| e.name.clone())
.collect();
let mut rechunked = 0;
let mut skipped = 0;
for file_name in &files_to_rechunk {
match self.get_file(file_name) {
Ok(data) => {
let shards = erasure::encode(&data, new_k, new_n);
let commitment = das::commit(&shards, new_k, data.len());
let mut shard_hashes = Vec::with_capacity(new_n);
for shard in &shards {
let device_idx = shard.index % n_devices;
let device_name = &device_names[device_idx];
if let Some(store) = self.stores.get_mut(device_name) {
let hash = store.put(shard).map_err(|e| e.to_string())?;
shard_hashes.push(hash.to_hex());
}
}
let timestamp = store::now_ms();
let device_id = "vdisk".to_string();
let entry_hash = FileEntry::compute_hash(
file_name,
&shard_hashes,
timestamp,
&device_id,
);
self.registry.insert(FileEntry {
name: file_name.clone(),
original_len: data.len(),
k: new_k,
n: new_n,
shard_hashes,
timestamp,
entry_hash,
device_id,
das_root: format!("{:?}", commitment.root),
shard_copies: config.shard_copies, deleted: false,
});
rechunked += 1;
}
Err(_) => {
skipped += 1;
}
}
}
Ok((rechunked, skipped))
}
pub fn rebalance(&mut self, disk_name: &str) -> Result<usize, String> {
let device_names: Vec<String> = self
.attachments
.iter()
.filter(|a| a.disk_name == disk_name)
.map(|a| a.device_name.clone())
.collect();
if device_names.len() < 2 {
return Ok(0);
}
let mut device_load: Vec<(String, u64, u64)> = Vec::new(); for name in &device_names {
let (used, cap) = if let Some(store) = self.stores.get(name) {
(store.used(), store.capacity())
} else {
(0, 0)
};
device_load.push((name.clone(), used, cap));
}
let total_cap: u64 = device_load.iter().map(|(_, _, c)| *c).sum();
let total_used: u64 = device_load.iter().map(|(_, u, _)| *u).sum();
if total_cap == 0 || total_used == 0 {
return Ok(0);
}
let mut targets: Vec<(String, u64, u64, i64)> = device_load
.iter()
.map(|(name, used, cap)| {
let target = if total_cap > 0 {
(total_used as f64 * *cap as f64 / total_cap as f64) as u64
} else {
0
};
let delta = *used as i64 - target as i64;
(name.clone(), *used, target, delta)
})
.collect();
targets.sort_by(|a, b| b.3.cmp(&a.3));
let mut migrated = 0;
let over: Vec<String> = targets
.iter()
.filter(|(_, _, _, d)| *d > 0)
.map(|(n, _, _, _)| n.clone())
.collect();
let under: Vec<String> = targets
.iter()
.filter(|(_, _, _, d)| *d < 0)
.map(|(n, _, _, _)| n.clone())
.collect();
let file_names: Vec<String> = self.registry.files.keys().cloned().collect();
for file_name in &file_names {
let entry = match self.registry.get(file_name) {
Some(e) => e.clone(),
None => continue,
};
for (shard_idx, hash_hex) in entry.shard_hashes.iter().enumerate() {
let hash = match hex_to_hash(hash_hex) {
Ok(h) => h,
Err(_) => continue,
};
let mut source = None;
for dev_name in &over {
if let Some(store) = self.stores.get(dev_name) {
if store.has(&hash) {
source = Some(dev_name.clone());
break;
}
}
}
if source.is_none() {
continue;
}
let source_name = source.unwrap();
for dest_name in &under {
if let Some(dest_store) = self.stores.get(dest_name) {
if !dest_store.has(&hash) {
let bytes = match self.stores.get(&source_name) {
Some(s) => match s.get(&hash) {
Ok(b) => b,
Err(_) => continue,
},
None => continue,
};
if let Some(dest) = self.stores.get_mut(dest_name) {
let shard = store::bytes_to_shard(shard_idx, &bytes);
if dest.put(&shard).is_ok() {
migrated += 1;
}
}
break;
}
}
}
}
}
Ok(migrated)
}
}
fn hex_to_hash(hex: &str) -> Result<cyber_hemera::Hash, String> {
if hex.len() != 64 {
return Err("invalid hash hex length".to_string());
}
let mut bytes = [0u8; 32];
for i in 0..32 {
bytes[i] = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16)
.map_err(|e| e.to_string())?;
}
Ok(cyber_hemera::Hash::from_bytes(bytes))
}
#[cfg(test)]
mod tests {
use super::*;
fn setup() -> (tempfile::TempDir, VDiskManager) {
let dir = tempfile::tempdir().unwrap();
let mgr = VDiskManager::new(dir.path()).unwrap();
(dir, mgr)
}
#[test]
fn create_disk_and_attach() {
let (_dir, mut mgr) = setup();
mgr.create_disk(DiskConfig {
name: "work".into(),
redundancy: Redundancy::Tolerate(1),
tier: Tier::Active,
cache_policy: CachePolicy::Lru,
shard_copies: 1,
})
.unwrap();
mgr.attach("laptop", "work", 1_000_000).unwrap();
mgr.attach("phone", "work", 500_000).unwrap();
let status = mgr.status("work").unwrap();
assert_eq!(status.devices, 2);
assert_eq!(status.k, 1);
assert_eq!(status.n, 2);
assert_eq!(status.f, 1);
}
#[test]
fn end_to_end_store_and_retrieve() {
let (_dir, mut mgr) = setup();
mgr.create_disk(DiskConfig {
name: "work".into(),
redundancy: Redundancy::Tolerate(1),
tier: Tier::Active,
cache_policy: CachePolicy::Lru,
shard_copies: 1,
})
.unwrap();
mgr.attach("device_a", "work", 10_000_000).unwrap();
mgr.attach("device_b", "work", 10_000_000).unwrap();
let data = b"hello virtual disk! this is an end-to-end test of erasure coded storage.";
mgr.put_file("work", "hello.txt", data).unwrap();
let recovered = mgr.get_file("hello.txt").unwrap();
assert_eq!(&recovered, &data[..]);
}
#[test]
fn survive_device_loss() {
let (_dir, mut mgr) = setup();
mgr.create_disk(DiskConfig {
name: "resilient".into(),
redundancy: Redundancy::Tolerate(1),
tier: Tier::Active,
cache_policy: CachePolicy::Lru,
shard_copies: 1,
})
.unwrap();
mgr.attach("dev_a", "resilient", 10_000_000).unwrap();
mgr.attach("dev_b", "resilient", 10_000_000).unwrap();
mgr.attach("dev_c", "resilient", 10_000_000).unwrap();
let data = b"this data must survive losing one device";
mgr.put_file("resilient", "important.txt", data).unwrap();
mgr.stores.remove("dev_a");
let recovered = mgr.get_file("important.txt").unwrap();
assert_eq!(&recovered, &data[..]);
}
#[test]
fn full_replication_tier0() {
let (_dir, mut mgr) = setup();
mgr.create_disk(DiskConfig {
name: "keys".into(),
redundancy: Redundancy::Max,
tier: Tier::Critical,
cache_policy: CachePolicy::Hot,
shard_copies: 1,
})
.unwrap();
mgr.attach("phone", "keys", 1_000_000).unwrap();
mgr.attach("laptop", "keys", 1_000_000).unwrap();
mgr.attach("server", "keys", 1_000_000).unwrap();
let status = mgr.status("keys").unwrap();
assert_eq!(status.f, 2); assert_eq!(status.k, 1); }
#[test]
fn file_registry_merge() {
let (_dir, mut mgr) = setup();
mgr.create_disk(DiskConfig {
name: "work".into(),
redundancy: Redundancy::Tolerate(1),
tier: Tier::Active,
cache_policy: CachePolicy::Lru,
shard_copies: 1,
})
.unwrap();
mgr.attach("dev_a", "work", 10_000_000).unwrap();
mgr.attach("dev_b", "work", 10_000_000).unwrap();
mgr.put_file("work", "a.txt", b"file from device A")
.unwrap();
let mut other = GSet::new();
other.insert(FileEntry {
name: "b.txt".into(),
original_len: 10,
k: 1,
n: 2,
shard_hashes: vec![],
timestamp: store::now_ms(),
entry_hash: "a".repeat(64),
device_id: "other".into(),
das_root: "0".repeat(64),
shard_copies: 1,
deleted: false,
});
mgr.merge_registry(&other);
assert_eq!(mgr.list_files().len(), 2);
}
#[test]
fn disk_status_messages() {
let (_dir, mut mgr) = setup();
mgr.create_disk(DiskConfig {
name: "empty".into(),
redundancy: Redundancy::Tolerate(1),
tier: Tier::Active,
cache_policy: CachePolicy::Lru,
shard_copies: 1,
})
.unwrap();
let status = mgr.status("empty").unwrap();
assert!(!status.healthy);
assert!(status.message.contains("no devices"));
}
#[test]
fn rechunk_on_device_join() {
let (_dir, mut mgr) = setup();
mgr.create_disk(DiskConfig {
name: "work".into(),
redundancy: Redundancy::Tolerate(1),
tier: Tier::Active,
cache_policy: CachePolicy::Lru,
shard_copies: 1,
})
.unwrap();
mgr.attach("dev_a", "work", 10_000_000).unwrap();
mgr.attach("dev_b", "work", 10_000_000).unwrap();
let data = b"file that will be rechunked when a new device joins";
mgr.put_file("work", "rechunk_me.txt", data).unwrap();
let entry_before = mgr.registry().get("rechunk_me.txt").unwrap().clone();
let old_k = entry_before.k;
let old_n = entry_before.n;
mgr.attach("dev_c", "work", 10_000_000).unwrap();
mgr.attach("dev_d", "work", 10_000_000).unwrap();
let (rechunked, skipped) = mgr.rechunk("work").unwrap();
assert_eq!(rechunked, 1);
assert_eq!(skipped, 0);
let entry_after = mgr.registry().get("rechunk_me.txt").unwrap();
assert!(entry_after.k > old_k || entry_after.n > old_n,
"rechunk didn't change parameters: old k={} n={}, new k={} n={}",
old_k, old_n, entry_after.k, entry_after.n);
let recovered = mgr.get_file("rechunk_me.txt").unwrap();
assert_eq!(&recovered, &data[..]);
}
#[test]
fn rechunk_preserves_data_integrity() {
let (_dir, mut mgr) = setup();
mgr.create_disk(DiskConfig {
name: "data".into(),
redundancy: Redundancy::Tolerate(1),
tier: Tier::Active,
cache_policy: CachePolicy::Lru,
shard_copies: 1,
})
.unwrap();
mgr.attach("a", "data", 10_000_000).unwrap();
mgr.attach("b", "data", 10_000_000).unwrap();
let files: Vec<(String, Vec<u8>)> = (0..10)
.map(|i| {
let name = format!("file_{}.dat", i);
let data: Vec<u8> = (0..100 + i * 50).map(|j| ((i + j) % 256) as u8).collect();
mgr.put_file("data", &name, &data).unwrap();
(name, data)
})
.collect();
mgr.attach("c", "data", 10_000_000).unwrap();
mgr.attach("d", "data", 10_000_000).unwrap();
let (rechunked, _) = mgr.rechunk("data").unwrap();
assert_eq!(rechunked, 10);
for (name, original) in &files {
let recovered = mgr.get_file(name).unwrap();
assert_eq!(&recovered, original, "file {} corrupted after rechunk", name);
}
}
#[test]
fn rebalance_moves_chunks() {
let (_dir, mut mgr) = setup();
mgr.create_disk(DiskConfig {
name: "uneven".into(),
redundancy: Redundancy::Tolerate(1),
tier: Tier::Active,
cache_policy: CachePolicy::Lru,
shard_copies: 1,
})
.unwrap();
mgr.attach("small", "uneven", 1_000_000).unwrap();
mgr.attach("large", "uneven", 10_000_000).unwrap();
for i in 0..5 {
let data: Vec<u8> = (0..200).map(|j| ((i + j) % 256) as u8).collect();
mgr.put_file("uneven", &format!("f_{}", i), &data).unwrap();
}
let _migrated = mgr.rebalance("uneven").unwrap();
for i in 0..5 {
let expected: Vec<u8> = (0..200).map(|j| ((i + j) % 256) as u8).collect();
let recovered = mgr.get_file(&format!("f_{}", i)).unwrap();
assert_eq!(recovered, expected, "file f_{} corrupted after rebalance", i);
}
}
}