Implement Week 6 Origin Federation with Oracle fixes

New files:
- musicfs-core/src/config.rs: Config, OriginConfig, HealthConfig
- musicfs-origins/src/registry.rs: OriginRegistry with watch cleanup
- musicfs-origins/src/router.rs: Priority router with (priority, latency) ordering
- musicfs-origins/src/health.rs: HealthMonitor with per-origin-type thresholds
- musicfs-origins/src/failover.rs: FailoverExecutor with NFR-7.3 backoff

Oracle fixes applied:
- Per-OriginType threshold: Local=1, Remote=3 (check_one uses threshold_for)
- AllOriginsUnhealthy event: Added to events.rs, emitted in select_with_fallback
- Unified OriginType: Removed duplicate from traits.rs, use musicfs_core::OriginType
- Watch handle cleanup: Tracked and dropped on unregister()
- Retry delays: 100ms, 500ms, 2000ms (NFR-7.3 compliant)

Tests: 91 pass (+20 new)
This commit is contained in:
Alexander
2026-05-12 20:15:56 +02:00
parent 32c96701c8
commit d5ef68c9c9
15 changed files with 1321 additions and 14 deletions
+4
View File
@@ -6,6 +6,10 @@ edition.workspace = true
[dependencies]
thiserror.workspace = true
serde.workspace = true
toml.workspace = true
tokio = { workspace = true, features = ["sync"] }
xxhash-rust.workspace = true
hex.workspace = true
[dev-dependencies]
tempfile.workspace = true
+190
View File
@@ -0,0 +1,190 @@
use crate::OriginId;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
pub mount_point: PathBuf,
pub cache_dir: PathBuf,
pub origins: Vec<OriginConfig>,
#[serde(default)]
pub cache: CacheConfig,
#[serde(default)]
pub health: HealthConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OriginConfig {
pub id: String,
pub origin_type: OriginType,
pub priority: u8,
#[serde(default = "default_enabled")]
pub enabled: bool,
#[serde(flatten)]
pub settings: HashMap<String, toml::Value>,
}
fn default_enabled() -> bool {
true
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "lowercase")]
pub enum OriginType {
Local,
Nfs,
Smb,
S3,
Sftp,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CacheConfig {
#[serde(default = "default_metadata_cache_mb")]
pub metadata_cache_mb: u64,
#[serde(default = "default_content_cache_gb")]
pub content_cache_gb: u64,
}
impl Default for CacheConfig {
fn default() -> Self {
Self {
metadata_cache_mb: default_metadata_cache_mb(),
content_cache_gb: default_content_cache_gb(),
}
}
}
fn default_metadata_cache_mb() -> u64 {
100
}
fn default_content_cache_gb() -> u64 {
10
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HealthConfig {
#[serde(default = "default_check_interval_secs")]
pub check_interval_secs: u64,
#[serde(default = "default_timeout_ms")]
pub timeout_ms: u64,
#[serde(default = "default_unhealthy_threshold")]
pub unhealthy_threshold: u32,
#[serde(default)]
pub per_origin_thresholds: HashMap<OriginType, u32>,
}
impl Default for HealthConfig {
fn default() -> Self {
let mut per_origin = HashMap::new();
per_origin.insert(OriginType::Local, 1);
per_origin.insert(OriginType::Nfs, 3);
per_origin.insert(OriginType::Smb, 3);
per_origin.insert(OriginType::S3, 3);
per_origin.insert(OriginType::Sftp, 3);
Self {
check_interval_secs: default_check_interval_secs(),
timeout_ms: default_timeout_ms(),
unhealthy_threshold: default_unhealthy_threshold(),
per_origin_thresholds: per_origin,
}
}
}
impl HealthConfig {
pub fn threshold_for(&self, origin_type: OriginType) -> u32 {
self.per_origin_thresholds
.get(&origin_type)
.copied()
.unwrap_or(self.unhealthy_threshold)
}
}
fn default_check_interval_secs() -> u64 {
30
}
fn default_timeout_ms() -> u64 {
5000
}
fn default_unhealthy_threshold() -> u32 {
3
}
impl Config {
pub fn from_file(path: &std::path::Path) -> Result<Self, ConfigError> {
let content =
std::fs::read_to_string(path).map_err(|e| ConfigError::Read(e.to_string()))?;
toml::from_str(&content).map_err(|e| ConfigError::Parse(e.to_string()))
}
pub fn origin_id(&self, id: &str) -> Option<OriginId> {
self.origins
.iter()
.find(|o| o.id == id)
.map(|_| OriginId::from(id))
}
}
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("Failed to read config: {0}")]
Read(String),
#[error("Failed to parse config: {0}")]
Parse(String),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_config() {
let toml = r#"
mount_point = "/mnt/music"
cache_dir = "/home/user/.cache/musicfs"
[[origins]]
id = "local"
origin_type = "local"
priority = 1
path = "/mnt/nas/music"
[[origins]]
id = "backup"
origin_type = "s3"
priority = 2
bucket = "music-backup"
region = "us-east-1"
"#;
let config: Config = toml::from_str(toml).unwrap();
assert_eq!(config.origins.len(), 2);
assert_eq!(config.origins[0].priority, 1);
assert_eq!(config.origins[1].origin_type, OriginType::S3);
}
#[test]
fn test_default_health_thresholds() {
let health = HealthConfig::default();
assert_eq!(health.threshold_for(OriginType::Local), 1);
assert_eq!(health.threshold_for(OriginType::Sftp), 3);
}
#[test]
fn test_cache_defaults() {
let cache = CacheConfig::default();
assert_eq!(cache.metadata_cache_mb, 100);
assert_eq!(cache.content_cache_gb, 10);
}
}
+9
View File
@@ -28,6 +28,15 @@ pub enum Error {
#[error("Operation not permitted (read-only filesystem)")]
ReadOnly,
#[error("No origin available to serve request")]
NoOriginAvailable,
#[error("Maximum retries exceeded")]
MaxRetriesExceeded,
#[error("Origin error: {0}")]
Origin(String),
}
pub type Result<T> = std::result::Result<T, Error>;
@@ -60,6 +60,13 @@ pub enum Event {
CacheEviction {
bytes_freed: u64,
},
AllOriginsUnhealthy {
candidate_count: usize,
},
OriginHealthChanged {
origin_id: OriginId,
healthy: bool,
},
}
#[cfg(test)]
+2
View File
@@ -1,8 +1,10 @@
pub mod config;
pub mod error;
pub mod events;
pub mod resolver;
pub mod types;
pub use config::{CacheConfig, Config, ConfigError, HealthConfig, OriginConfig, OriginType};
pub use error::{Error, Result};
pub use events::{Event, EventBus};
pub use resolver::{PathResolver, PathTemplate};