Move the files around

This commit is contained in:
Alexander
2026-05-13 20:34:14 +02:00
parent 90e9683076
commit 305d027c8b
113 changed files with 650 additions and 3569 deletions
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "musicfs-core"
version.workspace = true
edition.workspace = true
[dependencies]
thiserror.workspace = true
serde.workspace = true
serde_json.workspace = true
toml.workspace = true
tokio = { workspace = true, features = ["sync"] }
tracing.workspace = true
xxhash-rust.workspace = true
hex.workspace = true
parking_lot.workspace = true
[dev-dependencies]
tempfile.workspace = true
+239
View File
@@ -0,0 +1,239 @@
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,
#[serde(default)]
pub logging: LoggingConfig,
}
#[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
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoggingConfig {
#[serde(default = "default_log_dir")]
pub log_dir: PathBuf,
#[serde(default)]
pub json_output: bool,
#[serde(default = "default_true")]
pub journald: bool,
#[serde(default = "default_log_level")]
pub level: String,
#[serde(default = "default_sample_rate")]
pub trace_sample_rate: f32,
}
impl Default for LoggingConfig {
fn default() -> Self {
Self {
log_dir: default_log_dir(),
json_output: false,
journald: true,
level: default_log_level(),
trace_sample_rate: default_sample_rate(),
}
}
}
fn default_log_dir() -> PathBuf {
PathBuf::from("/var/log/musicfs")
}
fn default_log_level() -> String {
"musicfs=info,warn".to_string()
}
fn default_true() -> bool {
true
}
fn default_sample_rate() -> f32 {
1.0
}
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);
}
}
+284
View File
@@ -0,0 +1,284 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use thiserror::Error;
use tracing::{debug, info, trace, warn};
#[derive(Clone)]
pub struct CredentialStore {
cache: HashMap<String, Credential>,
}
impl std::fmt::Debug for CredentialStore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CredentialStore")
.field("cache_keys", &self.cache.keys().collect::<Vec<_>>())
.finish()
}
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum Credential {
Basic {
username: String,
#[serde(skip_serializing)]
password: String,
},
AwsKey {
access_key_id: String,
#[serde(skip_serializing)]
secret_access_key: String,
session_token: Option<String>,
region: String,
},
SshKey {
username: String,
private_key_path: PathBuf,
#[serde(skip_serializing)]
passphrase: Option<String>,
},
EnvVar {
var_name: String,
},
}
impl std::fmt::Debug for Credential {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Basic { username, .. } => f
.debug_struct("Basic")
.field("username", username)
.field("password", &"[REDACTED]")
.finish(),
Self::AwsKey {
access_key_id,
session_token,
region,
..
} => {
let key_preview = if access_key_id.len() > 4 {
format!("{}...", &access_key_id[..4])
} else {
"****".to_string()
};
let token_display = if session_token.is_some() {
"[REDACTED]"
} else {
"None"
};
f.debug_struct("AwsKey")
.field("access_key_id", &key_preview)
.field("secret_access_key", &"[REDACTED]")
.field("session_token", &token_display)
.field("region", region)
.finish()
}
Self::SshKey {
username,
private_key_path,
..
} => f
.debug_struct("SshKey")
.field("username", username)
.field("private_key_path", private_key_path)
.field("passphrase", &"[REDACTED]")
.finish(),
Self::EnvVar { var_name } => f
.debug_struct("EnvVar")
.field("var_name", var_name)
.finish(),
}
}
}
impl CredentialStore {
pub fn new() -> Self {
Self {
cache: HashMap::new(),
}
}
pub fn load(
&mut self,
origin_id: &str,
config: &CredentialConfig,
) -> Result<Credential, CredentialError> {
debug!(origin_id = %origin_id, "Loading credentials");
if let Some(cred) = self.cache.get(origin_id) {
trace!(origin_id = %origin_id, "Credential cache hit");
return Ok(cred.clone());
}
let cred = match config {
CredentialConfig::Environment { prefix } => {
trace!(origin_id = %origin_id, prefix = %prefix, "Loading from environment");
self.load_from_env(prefix)?
}
CredentialConfig::File { path } => {
trace!(origin_id = %origin_id, path = ?path, "Loading from file");
self.load_from_file(path)?
}
CredentialConfig::Inline(cred) => {
trace!(origin_id = %origin_id, "Using inline credential");
cred.clone()
}
};
let cred_type = match &cred {
Credential::Basic { .. } => "Basic",
Credential::AwsKey { .. } => "AwsKey",
Credential::SshKey { .. } => "SshKey",
Credential::EnvVar { .. } => "EnvVar",
};
info!(origin_id = %origin_id, cred_type = %cred_type, "Credential loaded");
self.cache.insert(origin_id.to_string(), cred.clone());
Ok(cred)
}
fn load_from_env(&self, prefix: &str) -> Result<Credential, CredentialError> {
if let (Ok(key), Ok(secret)) = (
std::env::var(format!("{}_ACCESS_KEY_ID", prefix)),
std::env::var(format!("{}_SECRET_ACCESS_KEY", prefix)),
) {
return Ok(Credential::AwsKey {
access_key_id: key,
secret_access_key: secret,
session_token: std::env::var(format!("{}_SESSION_TOKEN", prefix)).ok(),
region: std::env::var(format!("{}_REGION", prefix))
.unwrap_or_else(|_| "us-east-1".to_string()),
});
}
if let (Ok(user), Ok(pass)) = (
std::env::var(format!("{}_USERNAME", prefix)),
std::env::var(format!("{}_PASSWORD", prefix)),
) {
return Ok(Credential::Basic {
username: user,
password: pass,
});
}
warn!(prefix = %prefix, "No credentials found in environment");
Err(CredentialError::NotFound(format!(
"No credentials found with prefix {}",
prefix
)))
}
fn load_from_file(&self, path: &PathBuf) -> Result<Credential, CredentialError> {
let content =
std::fs::read_to_string(path).map_err(|e| CredentialError::FileRead(e.to_string()))?;
if path.extension().map(|e| e == "json").unwrap_or(false) {
serde_json::from_str(&content).map_err(|e| CredentialError::Parse(e.to_string()))
} else {
toml::from_str(&content).map_err(|e| CredentialError::Parse(e.to_string()))
}
}
pub fn clear(&mut self) {
self.cache.clear();
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "source")]
pub enum CredentialConfig {
Environment { prefix: String },
File { path: PathBuf },
Inline(Credential),
}
#[derive(Debug, Error)]
pub enum CredentialError {
#[error("Credential not found: {0}")]
NotFound(String),
#[error("Failed to read credential file: {0}")]
FileRead(String),
#[error("Failed to parse credential: {0}")]
Parse(String),
}
impl Default for CredentialStore {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_credential_debug_redacted() {
let cred = Credential::Basic {
username: "user".to_string(),
password: "secret123".to_string(),
};
let debug_output = format!("{:?}", cred);
assert!(debug_output.contains("user"));
assert!(!debug_output.contains("secret123"));
assert!(debug_output.contains("[REDACTED]"));
}
#[test]
fn test_aws_credential_debug_redacted() {
let cred = Credential::AwsKey {
access_key_id: "AKIAIOSFODNN7EXAMPLE".to_string(),
secret_access_key: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".to_string(),
session_token: None,
region: "us-east-1".to_string(),
};
let debug_output = format!("{:?}", cred);
assert!(debug_output.contains("AKIA..."));
assert!(!debug_output.contains("wJalrXUtnFEMI"));
assert!(debug_output.contains("[REDACTED]"));
}
#[test]
fn test_credential_store_debug() {
let mut store = CredentialStore::new();
store.cache.insert(
"test".to_string(),
Credential::Basic {
username: "user".to_string(),
password: "secret".to_string(),
},
);
let debug_output = format!("{:?}", store);
assert!(debug_output.contains("test"));
assert!(!debug_output.contains("secret"));
}
#[test]
fn test_load_from_env() {
std::env::set_var("TEST_ORIGIN_USERNAME", "testuser");
std::env::set_var("TEST_ORIGIN_PASSWORD", "testpass");
let store = CredentialStore::new();
let cred = store.load_from_env("TEST_ORIGIN").unwrap();
match cred {
Credential::Basic { username, password } => {
assert_eq!(username, "testuser");
assert_eq!(password, "testpass");
}
_ => panic!("Expected Basic credential"),
}
std::env::remove_var("TEST_ORIGIN_USERNAME");
std::env::remove_var("TEST_ORIGIN_PASSWORD");
}
}
+70
View File
@@ -0,0 +1,70 @@
use thiserror::Error;
#[derive(Error, Debug)]
pub enum Error {
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("Origin not found: {0}")]
OriginNotFound(String),
#[error("File not found: {0}")]
FileNotFound(String),
#[error("Path resolution failed: {0}")]
PathResolution(String),
#[error("Cache error: {0}")]
Cache(String),
#[error("Metadata extraction error: {0}")]
Metadata(String),
#[error("Database error: {0}")]
Database(String),
#[error("Database corrupted: {0}")]
DatabaseCorrupted(String),
#[error("NFS stale file handle")]
NfsStaleHandle,
#[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),
#[error("S3 error: {0}")]
S3(String),
#[error("SFTP error: {0}")]
Sftp(String),
#[error("Operation timed out: {0}")]
Timeout(String),
#[error("Credential error: {0}")]
Credential(String),
}
pub type Result<T> = std::result::Result<T, Error>;
impl Error {
pub fn is_not_found(&self) -> bool {
matches!(self, Error::FileNotFound(_))
}
pub fn downcast_io(&self) -> Option<&std::io::Error> {
match self {
Error::Io(e) => Some(e),
_ => None,
}
}
}
+113
View File
@@ -0,0 +1,113 @@
use crate::types::{FileId, OriginId, VirtualPath};
use tokio::sync::broadcast;
use tracing::{debug, trace};
pub struct EventBus {
sender: broadcast::Sender<Event>,
}
impl EventBus {
pub fn new(capacity: usize) -> Self {
let (sender, _) = broadcast::channel(capacity);
Self { sender }
}
pub fn publish(&self, event: Event) {
trace!(event = ?event, "Publishing event");
let receiver_count = self.sender.receiver_count();
if self.sender.send(event).is_err() && receiver_count > 0 {
debug!(
receiver_count = receiver_count,
"Event dropped, no active receivers"
);
}
}
pub fn subscribe(&self) -> broadcast::Receiver<Event> {
self.sender.subscribe()
}
}
impl Default for EventBus {
fn default() -> Self {
Self::new(1024)
}
}
#[derive(Clone, Debug)]
pub enum Event {
FileAdded {
path: VirtualPath,
origin_id: OriginId,
},
FileRemoved {
path: VirtualPath,
file_id: Option<FileId>,
},
FileModified {
path: VirtualPath,
},
FileAccessed {
file_id: FileId,
path: VirtualPath,
origin_id: OriginId,
offset: u64,
size: u32,
},
OriginConnected {
origin_id: OriginId,
},
OriginDisconnected {
origin_id: OriginId,
},
SyncStarted {
origin_id: OriginId,
},
SyncCompleted {
origin_id: OriginId,
files_changed: u64,
},
CacheEviction {
bytes_freed: u64,
},
AllOriginsUnhealthy {
candidate_count: usize,
},
OriginHealthChanged {
origin_id: OriginId,
healthy: bool,
},
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_event_bus() {
let bus = EventBus::new(16);
let mut rx = bus.subscribe();
bus.publish(Event::SyncStarted {
origin_id: OriginId::from("test"),
});
let event = rx.recv().await.unwrap();
assert!(matches!(event, Event::SyncStarted { .. }));
}
#[tokio::test]
async fn test_event_bus_multiple_subscribers() {
let bus = EventBus::new(16);
let mut rx1 = bus.subscribe();
let mut rx2 = bus.subscribe();
bus.publish(Event::CacheEviction { bytes_freed: 1024 });
let e1 = rx1.recv().await.unwrap();
let e2 = rx2.recv().await.unwrap();
assert!(matches!(e1, Event::CacheEviction { bytes_freed: 1024 }));
assert!(matches!(e2, Event::CacheEviction { bytes_freed: 1024 }));
}
}
+60
View File
@@ -0,0 +1,60 @@
pub mod config;
pub mod credentials;
pub mod error;
pub mod events;
pub mod metrics;
pub mod resolver;
pub mod supervisor;
pub mod types;
pub use config::{
CacheConfig, Config, ConfigError, HealthConfig, LoggingConfig, OriginConfig, OriginType,
};
use std::path::Path;
pub fn sanitize_path(path: &Path) -> String {
if let Ok(home) = std::env::var("HOME") {
path.to_string_lossy().replace(&home, "~")
} else {
path.to_string_lossy().to_string()
}
}
/// Install a custom panic hook that logs panics via tracing before the default behavior.
/// This ensures panics are captured in log files and journald.
pub fn install_panic_hook() {
let default_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let thread = std::thread::current();
let thread_name = thread.name().unwrap_or("<unnamed>");
let message = if let Some(s) = info.payload().downcast_ref::<&str>() {
(*s).to_string()
} else if let Some(s) = info.payload().downcast_ref::<String>() {
s.clone()
} else {
"unknown panic".to_string()
};
let location = info
.location()
.map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column()))
.unwrap_or_else(|| "unknown location".to_string());
tracing::error!(
thread = thread_name,
location = %location,
"PANIC: {}",
message
);
default_hook(info);
}));
}
pub use credentials::{Credential, CredentialConfig, CredentialError, CredentialStore};
pub use error::{Error, Result};
pub use events::{Event, EventBus};
pub use metrics::{CacheMetrics, FuseOpsMetrics, Metrics, OriginsMetrics};
pub use resolver::{PathResolver, PathTemplate};
pub use types::*;
+322
View File
@@ -0,0 +1,322 @@
use parking_lot::RwLock;
use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Instant;
#[derive(Default)]
pub struct Metrics {
pub fuse_ops: FuseOpsMetrics,
pub fuse_latency: FuseLatencyMetrics,
pub cache: CacheMetrics,
pub origins: OriginsMetrics,
pub origin_health: OriginHealthMetrics,
start_time: Option<Instant>,
}
impl Metrics {
pub fn new() -> Self {
Self {
start_time: Some(Instant::now()),
..Default::default()
}
}
pub fn uptime_secs(&self) -> u64 {
self.start_time.map(|t| t.elapsed().as_secs()).unwrap_or(0)
}
pub fn to_prometheus(&self) -> String {
let mut output = String::new();
output.push_str(&format!(
"# HELP musicfs_fuse_ops_total Total FUSE operations\n\
# TYPE musicfs_fuse_ops_total counter\n\
musicfs_fuse_ops_total{{op=\"lookup\"}} {}\n\
musicfs_fuse_ops_total{{op=\"getattr\"}} {}\n\
musicfs_fuse_ops_total{{op=\"read\"}} {}\n\
musicfs_fuse_ops_total{{op=\"readdir\"}} {}\n\
musicfs_fuse_ops_total{{op=\"open\"}} {}\n",
self.fuse_ops.lookup.load(Ordering::Relaxed),
self.fuse_ops.getattr.load(Ordering::Relaxed),
self.fuse_ops.read.load(Ordering::Relaxed),
self.fuse_ops.readdir.load(Ordering::Relaxed),
self.fuse_ops.open.load(Ordering::Relaxed),
));
for (op, histogram) in self.fuse_latency.histograms.read().iter() {
let quantiles = histogram.quantiles();
output.push_str(&format!(
"# HELP musicfs_fuse_latency_seconds FUSE operation latency\n\
# TYPE musicfs_fuse_latency_seconds summary\n\
musicfs_fuse_latency_seconds{{op=\"{}\",quantile=\"0.5\"}} {:.6}\n\
musicfs_fuse_latency_seconds{{op=\"{}\",quantile=\"0.95\"}} {:.6}\n\
musicfs_fuse_latency_seconds{{op=\"{}\",quantile=\"0.99\"}} {:.6}\n\
musicfs_fuse_latency_seconds_sum{{op=\"{}\"}} {:.6}\n\
musicfs_fuse_latency_seconds_count{{op=\"{}\"}} {}\n",
op,
quantiles.p50,
op,
quantiles.p95,
op,
quantiles.p99,
op,
histogram.sum_secs(),
op,
histogram.count(),
));
}
output.push_str(&format!(
"# HELP musicfs_cache_hits_total Cache hits\n\
# TYPE musicfs_cache_hits_total counter\n\
musicfs_cache_hits_total {}\n",
self.cache.hits.load(Ordering::Relaxed),
));
output.push_str(&format!(
"# HELP musicfs_cache_misses_total Cache misses\n\
# TYPE musicfs_cache_misses_total counter\n\
musicfs_cache_misses_total {}\n",
self.cache.misses.load(Ordering::Relaxed),
));
output.push_str(&format!(
"# HELP musicfs_cache_size_bytes Current cache size in bytes\n\
# TYPE musicfs_cache_size_bytes gauge\n\
musicfs_cache_size_bytes {}\n",
self.cache.size_bytes.load(Ordering::Relaxed),
));
output.push_str(&format!(
"# HELP musicfs_cache_chunks_total Number of cached chunks\n\
# TYPE musicfs_cache_chunks_total gauge\n\
musicfs_cache_chunks_total {}\n",
self.cache.chunk_count.load(Ordering::Relaxed),
));
output.push_str(
"# HELP musicfs_origin_health Origin health status (1=healthy, 0=unhealthy)\n\
# TYPE musicfs_origin_health gauge\n",
);
for (origin_id, healthy) in self.origin_health.status.read().iter() {
output.push_str(&format!(
"musicfs_origin_health{{origin=\"{}\"}} {}\n",
origin_id,
if *healthy { 1 } else { 0 }
));
}
output.push_str(&format!(
"# HELP musicfs_uptime_seconds Daemon uptime in seconds\n\
# TYPE musicfs_uptime_seconds gauge\n\
musicfs_uptime_seconds {}\n",
self.uptime_secs(),
));
output
}
pub fn hit_ratio(&self) -> f64 {
let hits = self.cache.hits.load(Ordering::Relaxed) as f64;
let misses = self.cache.misses.load(Ordering::Relaxed) as f64;
let total = hits + misses;
if total == 0.0 {
0.0
} else {
hits / total
}
}
}
#[derive(Default)]
pub struct FuseOpsMetrics {
pub lookup: AtomicU64,
pub getattr: AtomicU64,
pub read: AtomicU64,
pub readdir: AtomicU64,
pub open: AtomicU64,
}
impl FuseOpsMetrics {
pub fn record_lookup(&self) {
self.lookup.fetch_add(1, Ordering::Relaxed);
}
pub fn record_getattr(&self) {
self.getattr.fetch_add(1, Ordering::Relaxed);
}
pub fn record_read(&self) {
self.read.fetch_add(1, Ordering::Relaxed);
}
pub fn record_readdir(&self) {
self.readdir.fetch_add(1, Ordering::Relaxed);
}
pub fn record_open(&self) {
self.open.fetch_add(1, Ordering::Relaxed);
}
}
#[derive(Default)]
pub struct CacheMetrics {
pub hits: AtomicU64,
pub misses: AtomicU64,
pub size_bytes: AtomicU64,
pub chunk_count: AtomicU64,
}
impl CacheMetrics {
pub fn record_hit(&self) {
self.hits.fetch_add(1, Ordering::Relaxed);
}
pub fn record_miss(&self) {
self.misses.fetch_add(1, Ordering::Relaxed);
}
pub fn update_size(&self, size: u64) {
self.size_bytes.store(size, Ordering::Relaxed);
}
pub fn update_chunk_count(&self, count: u64) {
self.chunk_count.store(count, Ordering::Relaxed);
}
}
#[derive(Default)]
pub struct OriginsMetrics {
pub healthy_count: AtomicU64,
pub total_count: AtomicU64,
}
impl OriginsMetrics {
pub fn update(&self, healthy: u64, total: u64) {
self.healthy_count.store(healthy, Ordering::Relaxed);
self.total_count.store(total, Ordering::Relaxed);
}
}
#[derive(Default)]
pub struct FuseLatencyMetrics {
pub histograms: RwLock<HashMap<String, LatencyHistogram>>,
}
impl FuseLatencyMetrics {
pub fn record(&self, op: &str, latency_secs: f64) {
let mut histograms = self.histograms.write();
histograms
.entry(op.to_string())
.or_default()
.record(latency_secs);
}
}
#[derive(Default)]
pub struct LatencyHistogram {
samples: Vec<f64>,
sum: f64,
}
impl LatencyHistogram {
pub fn record(&mut self, latency_secs: f64) {
self.samples.push(latency_secs);
self.sum += latency_secs;
if self.samples.len() > 10000 {
self.samples.drain(..5000);
}
}
pub fn quantiles(&self) -> Quantiles {
if self.samples.is_empty() {
return Quantiles::default();
}
let mut sorted = self.samples.clone();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let len = sorted.len();
Quantiles {
p50: sorted[len / 2],
p95: sorted[(len as f64 * 0.95) as usize],
p99: sorted[(len as f64 * 0.99) as usize],
}
}
pub fn sum_secs(&self) -> f64 {
self.sum
}
pub fn count(&self) -> u64 {
self.samples.len() as u64
}
}
#[derive(Default)]
pub struct Quantiles {
pub p50: f64,
pub p95: f64,
pub p99: f64,
}
#[derive(Default)]
pub struct OriginHealthMetrics {
pub status: RwLock<HashMap<String, bool>>,
}
impl OriginHealthMetrics {
pub fn set_health(&self, origin_id: &str, healthy: bool) {
self.status.write().insert(origin_id.to_string(), healthy);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_metrics_new() {
let metrics = Metrics::new();
assert!(metrics.uptime_secs() < 5);
}
#[test]
fn test_fuse_ops_recording() {
let metrics = Metrics::new();
metrics.fuse_ops.record_lookup();
metrics.fuse_ops.record_lookup();
metrics.fuse_ops.record_read();
assert_eq!(metrics.fuse_ops.lookup.load(Ordering::Relaxed), 2);
assert_eq!(metrics.fuse_ops.read.load(Ordering::Relaxed), 1);
}
#[test]
fn test_cache_hit_ratio() {
let metrics = Metrics::new();
metrics.cache.hits.store(8, Ordering::Relaxed);
metrics.cache.misses.store(2, Ordering::Relaxed);
assert!((metrics.hit_ratio() - 0.8).abs() < 0.001);
}
#[test]
fn test_cache_hit_ratio_zero() {
let metrics = Metrics::new();
assert_eq!(metrics.hit_ratio(), 0.0);
}
#[test]
fn test_prometheus_format() {
let metrics = Metrics::new();
metrics.fuse_ops.record_lookup();
metrics.cache.record_hit();
let output = metrics.to_prometheus();
assert!(output.contains("musicfs_fuse_ops_total"));
assert!(output.contains("musicfs_cache_hits_total"));
}
}
+174
View File
@@ -0,0 +1,174 @@
use crate::{AudioMeta, VirtualPath};
#[derive(Debug, Clone)]
pub struct PathTemplate {
pub pattern: String,
pub fallback_artist: String,
pub fallback_album: String,
pub fallback_title: String,
pub fallback_year: String,
}
impl Default for PathTemplate {
fn default() -> Self {
Self {
pattern: "$artist/$album ($year) [$format_upper]/$track - $title.$format".to_string(),
fallback_artist: "Unknown Artist".to_string(),
fallback_album: "Unknown Album".to_string(),
fallback_title: "Unknown Track".to_string(),
fallback_year: "Unknown".to_string(),
}
}
}
pub struct PathResolver {
template: PathTemplate,
}
impl PathResolver {
pub fn new(template: PathTemplate) -> Self {
Self { template }
}
pub fn resolve(&self, meta: &AudioMeta, extension: &str) -> VirtualPath {
let artist = meta
.artist
.as_deref()
.unwrap_or(&self.template.fallback_artist);
let album = meta
.album
.as_deref()
.unwrap_or(&self.template.fallback_album);
let title = meta
.title
.as_deref()
.unwrap_or(&self.template.fallback_title);
let year = meta
.year
.map(|y| y.to_string())
.unwrap_or_else(|| self.template.fallback_year.clone());
let track = meta.track.unwrap_or(0);
let disc = meta.disc.unwrap_or(1);
let genre = meta.genre.as_deref().unwrap_or("Unknown");
let format = extension.to_lowercase();
let format_upper = extension.to_uppercase();
let artist = sanitize_path_component(artist);
let album = sanitize_path_component(album);
let title = sanitize_path_component(title);
let genre = sanitize_path_component(genre);
let path = self
.template
.pattern
.replace("$artist", &artist)
.replace("$album", &album)
.replace("$title", &title)
.replace("$track", &format!("{:02}", track))
.replace("$disc", &disc.to_string())
.replace("$year", &year)
.replace("$genre", &genre)
.replace("$format_upper", &format_upper)
.replace("$format", &format);
VirtualPath::new(path)
}
}
impl Default for PathResolver {
fn default() -> Self {
Self::new(PathTemplate::default())
}
}
fn sanitize_path_component(s: &str) -> String {
s.chars()
.map(|c| match c {
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' | '\0' => '_',
c => c,
})
.collect::<String>()
.trim()
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::AudioFormat;
#[test]
fn test_resolve_complete_metadata() {
let resolver = PathResolver::default();
let meta = AudioMeta {
artist: Some("Metallica".to_string()),
album: Some("Master of Puppets".to_string()),
title: Some("Battery".to_string()),
track: Some(1),
year: Some(1986),
format: AudioFormat::Flac,
..Default::default()
};
let path = resolver.resolve(&meta, "flac");
assert_eq!(
path.as_str(),
"Metallica/Master of Puppets (1986) [FLAC]/01 - Battery.flac"
);
}
#[test]
fn test_resolve_missing_album() {
let resolver = PathResolver::default();
let meta = AudioMeta {
artist: Some("Artist".to_string()),
title: Some("Track".to_string()),
track: Some(5),
..Default::default()
};
let path = resolver.resolve(&meta, "mp3");
assert_eq!(
path.as_str(),
"Artist/Unknown Album (Unknown) [MP3]/05 - Track.mp3"
);
}
#[test]
fn test_sanitize_special_chars() {
let resolver = PathResolver::default();
let meta = AudioMeta {
artist: Some("AC/DC".to_string()),
album: Some("Who Made Who?".to_string()),
title: Some("Test:Track".to_string()),
track: Some(1),
year: Some(1986),
..Default::default()
};
let path = resolver.resolve(&meta, "flac");
assert!(!path.as_str().contains(':'));
assert!(!path.as_str().contains('?'));
assert!(path.as_str().contains("AC_DC"));
}
#[test]
fn test_custom_template() {
let template = PathTemplate {
pattern: "$genre/$artist - $album/$track $title.$format".to_string(),
..Default::default()
};
let resolver = PathResolver::new(template);
let meta = AudioMeta {
artist: Some("Artist".to_string()),
album: Some("Album".to_string()),
title: Some("Song".to_string()),
genre: Some("Rock".to_string()),
track: Some(3),
..Default::default()
};
let path = resolver.resolve(&meta, "flac");
assert_eq!(path.as_str(), "Rock/Artist - Album/03 Song.flac");
}
}
+181
View File
@@ -0,0 +1,181 @@
use parking_lot::RwLock;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::task::JoinHandle;
use tracing::{error, warn};
pub struct TaskSupervisor {
tasks: Arc<RwLock<HashMap<String, TaskEntry>>>,
}
struct TaskEntry {
handle: JoinHandle<()>,
status: TaskStatus,
restart_count: u32,
last_restart: Option<Instant>,
}
#[derive(Debug, Clone)]
pub enum TaskStatus {
Running,
Failed { error: String, at: Instant },
Restarting { attempt: u32 },
Stopped,
}
impl Default for TaskSupervisor {
fn default() -> Self {
Self::new()
}
}
impl TaskSupervisor {
pub fn new() -> Self {
Self {
tasks: Arc::new(RwLock::new(HashMap::new())),
}
}
pub fn spawn_supervised<F>(&self, name: &str, future: F)
where
F: std::future::Future<Output = ()> + Send + 'static,
{
let name_owned = name.to_string();
let handle = tokio::spawn(async move {
future.await;
});
self.tasks.write().insert(
name_owned,
TaskEntry {
handle,
status: TaskStatus::Running,
restart_count: 0,
last_restart: None,
},
);
}
pub fn spawn_critical<F, Fut>(&self, name: &str, factory: F)
where
F: Fn() -> Fut + Send + Sync + 'static,
Fut: std::future::Future<Output = ()> + Send + 'static,
{
let tasks = self.tasks.clone();
let name_owned = name.to_string();
let monitor_handle = tokio::spawn(async move {
let mut restart_count = 0u32;
let max_restarts = 5u32;
let backoff_durations = [
Duration::from_secs(1),
Duration::from_secs(5),
Duration::from_secs(30),
];
loop {
let handle = tokio::spawn(factory());
{
let mut t = tasks.write();
if let Some(entry) = t.get_mut(&name_owned) {
entry.status = TaskStatus::Running;
}
}
match handle.await {
Ok(()) => {
let mut t = tasks.write();
if let Some(entry) = t.get_mut(&name_owned) {
entry.status = TaskStatus::Stopped;
}
break;
}
Err(e) => {
restart_count += 1;
if restart_count > max_restarts {
error!(task = %name_owned, "Task exceeded max restarts ({}), giving up", max_restarts);
let mut t = tasks.write();
if let Some(entry) = t.get_mut(&name_owned) {
entry.status = TaskStatus::Failed {
error: format!("Exceeded max restarts: {}", e),
at: Instant::now(),
};
}
break;
}
let backoff_idx =
(restart_count as usize - 1).min(backoff_durations.len() - 1);
let backoff = backoff_durations[backoff_idx];
warn!(
task = %name_owned,
error = %e,
attempt = restart_count,
backoff_ms = backoff.as_millis() as u64,
"Critical task failed, restarting with backoff"
);
{
let mut t = tasks.write();
if let Some(entry) = t.get_mut(&name_owned) {
entry.status = TaskStatus::Restarting {
attempt: restart_count,
};
entry.restart_count = restart_count;
entry.last_restart = Some(Instant::now());
}
}
tokio::time::sleep(backoff).await;
}
}
}
});
self.tasks.write().insert(
name.to_string(),
TaskEntry {
handle: monitor_handle,
status: TaskStatus::Running,
restart_count: 0,
last_restart: None,
},
);
}
pub fn task_status(&self, name: &str) -> TaskStatus {
let mut tasks = self.tasks.write();
if let Some(entry) = tasks.get_mut(name) {
if entry.handle.is_finished() {
entry.status = TaskStatus::Failed {
error: "Task exited".into(),
at: Instant::now(),
};
}
entry.status.clone()
} else {
TaskStatus::Stopped
}
}
pub fn check_all(&self) -> Vec<(String, TaskStatus)> {
let mut tasks = self.tasks.write();
tasks
.iter_mut()
.map(|(name, entry)| {
if entry.handle.is_finished() {
entry.status = TaskStatus::Failed {
error: "Task exited".into(),
at: Instant::now(),
};
}
(name.clone(), entry.status.clone())
})
.collect()
}
}
+199
View File
@@ -0,0 +1,199 @@
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::time::SystemTime;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct OriginId(pub String);
impl From<&str> for OriginId {
fn from(s: &str) -> Self {
Self(s.to_string())
}
}
impl std::fmt::Display for OriginId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct FileId(pub i64);
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct VirtualPath(pub PathBuf);
impl VirtualPath {
pub fn new(path: impl Into<PathBuf>) -> Self {
Self(path.into())
}
pub fn as_path(&self) -> &std::path::Path {
&self.0
}
pub fn as_str(&self) -> &str {
self.0.to_str().unwrap_or("")
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RealPath {
pub origin_id: OriginId,
pub path: PathBuf,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct ContentHash(pub [u8; 8]);
impl ContentHash {
pub fn from_bytes(data: &[u8]) -> Self {
use xxhash_rust::xxh64::xxh64;
Self(xxh64(data, 0).to_le_bytes())
}
pub fn to_hex(&self) -> String {
hex::encode(self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct ChunkHash(pub [u8; 8]);
impl ChunkHash {
pub fn from_bytes(data: &[u8]) -> Self {
use xxhash_rust::xxh64::xxh64;
Self(xxh64(data, 0).to_le_bytes())
}
pub fn as_hex(&self) -> String {
hex::encode(self.0)
}
pub fn to_hex(&self) -> String {
self.as_hex()
}
pub fn from_hex(s: &str) -> Option<Self> {
let bytes = hex::decode(s).ok()?;
if bytes.len() != 8 {
return None;
}
let mut arr = [0u8; 8];
arr.copy_from_slice(&bytes);
Some(Self(arr))
}
}
impl std::fmt::Display for ChunkHash {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_hex())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum AudioFormat {
Flac,
Mp3,
Opus,
Vorbis,
Aac,
Alac,
Wav,
#[default]
Unknown,
}
impl AudioFormat {
pub fn from_extension(ext: &str) -> Self {
match ext.to_lowercase().as_str() {
"flac" => Self::Flac,
"mp3" => Self::Mp3,
"opus" => Self::Opus,
"ogg" => Self::Vorbis,
"m4a" | "aac" => Self::Aac,
"wav" => Self::Wav,
_ => Self::Unknown,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AudioMeta {
pub title: Option<String>,
pub artist: Option<String>,
pub album: Option<String>,
pub album_artist: Option<String>,
pub genre: Option<String>,
pub year: Option<u32>,
pub track: Option<u32>,
pub disc: Option<u32>,
pub duration_ms: Option<u64>,
pub bitrate: Option<u32>,
pub sample_rate: Option<u32>,
pub format: AudioFormat,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileMeta {
pub id: FileId,
pub virtual_path: VirtualPath,
pub real_path: RealPath,
pub size: u64,
pub mtime: SystemTime,
pub content_hash: Option<ContentHash>,
pub audio: Option<AudioMeta>,
}
#[derive(Debug, Clone)]
pub struct DirEntry {
pub name: String,
pub is_dir: bool,
pub size: u64,
pub mtime: SystemTime,
}
#[derive(Debug, Clone)]
pub struct FileStat {
pub size: u64,
pub mtime: SystemTime,
pub is_dir: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum HealthStatus {
Healthy,
Degraded,
Unhealthy,
#[default]
Unknown,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_content_hash() {
let data = b"hello world";
let hash1 = ContentHash::from_bytes(data);
let hash2 = ContentHash::from_bytes(data);
assert_eq!(hash1, hash2);
let hash3 = ContentHash::from_bytes(b"different");
assert_ne!(hash1, hash3);
}
#[test]
fn test_audio_format_from_extension() {
assert_eq!(AudioFormat::from_extension("flac"), AudioFormat::Flac);
assert_eq!(AudioFormat::from_extension("MP3"), AudioFormat::Mp3);
assert_eq!(AudioFormat::from_extension("unknown"), AudioFormat::Unknown);
}
#[test]
fn test_virtual_path() {
let path = VirtualPath::new("/Artist/Album/Track.flac");
assert_eq!(path.as_str(), "/Artist/Album/Track.flac");
}
}