/// Advisory file-based locking for repository write operations. use std::path::Path; use serde::{Deserialize, Serialize}; use crate::error::ArchiveError; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct LockEntry { pub lock_id: String, pub pid: u32, pub hostname: String, pub created_at: String, pub operation: String, pub stale_after_seconds: u64, } /// Acquire a lock for the given operation. pub async fn acquire(repo_path: &str, operation: &str) -> Result { let locks_dir = Path::new(repo_path).join("locks"); tokio::fs::create_dir_all(&locks_dir).await?; // Check for existing locks if let Some(existing) = get_active_lock(repo_path).await? { return Err(ArchiveError::Locked(format!( "Repository locked by PID {} on {} for operation '{}' since {}", existing.pid, existing.hostname, existing.operation, existing.created_at ))); } let lock_id = uuid::Uuid::new_v4().to_string(); let hostname = std::env::var("HOSTNAME") .or_else(|_| std::env::var("HOST")) .unwrap_or_else(|_| "unknown".to_string()); let entry = LockEntry { lock_id: lock_id.clone(), pid: std::process::id(), hostname, created_at: chrono::Utc::now().to_rfc3339(), operation: operation.to_string(), stale_after_seconds: 21600, // 6 hours }; let lock_path = locks_dir.join(format!("{}.json", lock_id)); let json = serde_json::to_string_pretty(&entry)?; // Use create_new for atomic lock creation tokio::fs::write(&lock_path, json).await?; tracing::info!("Acquired lock {} for operation '{}'", lock_id, operation); Ok(entry) } /// Release a specific lock. pub async fn release(repo_path: &str, lock_id: &str) -> Result<(), ArchiveError> { let lock_path = Path::new(repo_path).join("locks").join(format!("{}.json", lock_id)); if lock_path.exists() { tokio::fs::remove_file(&lock_path).await?; tracing::info!("Released lock {}", lock_id); } Ok(()) } /// Check if the repository is locked. pub async fn is_locked(repo_path: &str) -> Result { Ok(get_active_lock(repo_path).await?.is_some()) } /// Get the active (non-stale) lock, if any. async fn get_active_lock(repo_path: &str) -> Result, ArchiveError> { let locks_dir = Path::new(repo_path).join("locks"); if !locks_dir.exists() { return Ok(None); } let mut dir = tokio::fs::read_dir(&locks_dir).await?; while let Some(entry) = dir.next_entry().await? { let path = entry.path(); if path.extension().and_then(|e| e.to_str()) != Some("json") { continue; } let data = tokio::fs::read_to_string(&path).await?; let lock: LockEntry = match serde_json::from_str(&data) { Ok(l) => l, Err(_) => { // Corrupted lock file — remove it let _ = tokio::fs::remove_file(&path).await; continue; } }; if is_stale(&lock) { tracing::warn!("Removing stale lock {} (from {})", lock.lock_id, lock.created_at); let _ = tokio::fs::remove_file(&path).await; continue; } return Ok(Some(lock)); } Ok(None) } /// Check and break all stale locks. Returns the number of locks removed. pub async fn check_and_break_stale(repo_path: &str) -> Result { let locks_dir = Path::new(repo_path).join("locks"); if !locks_dir.exists() { return Ok(0); } let mut removed = 0u32; let mut dir = tokio::fs::read_dir(&locks_dir).await?; while let Some(entry) = dir.next_entry().await? { let path = entry.path(); if path.extension().and_then(|e| e.to_str()) != Some("json") { continue; } let data = match tokio::fs::read_to_string(&path).await { Ok(d) => d, Err(_) => continue, }; let lock: LockEntry = match serde_json::from_str(&data) { Ok(l) => l, Err(_) => { let _ = tokio::fs::remove_file(&path).await; removed += 1; continue; } }; if is_stale(&lock) { tracing::warn!("Breaking stale lock {} (from {})", lock.lock_id, lock.created_at); let _ = tokio::fs::remove_file(&path).await; removed += 1; } } Ok(removed) } /// Break all locks (forced unlock). pub async fn break_all_locks(repo_path: &str, force: bool) -> Result { let locks_dir = Path::new(repo_path).join("locks"); if !locks_dir.exists() { return Ok(0); } let mut removed = 0u32; let mut dir = tokio::fs::read_dir(&locks_dir).await?; while let Some(entry) = dir.next_entry().await? { let path = entry.path(); if path.extension().and_then(|e| e.to_str()) != Some("json") { continue; } if force { let _ = tokio::fs::remove_file(&path).await; removed += 1; } else { // Only break stale locks let data = match tokio::fs::read_to_string(&path).await { Ok(d) => d, Err(_) => continue, }; let lock: LockEntry = match serde_json::from_str(&data) { Ok(l) => l, Err(_) => { let _ = tokio::fs::remove_file(&path).await; removed += 1; continue; } }; if is_stale(&lock) { let _ = tokio::fs::remove_file(&path).await; removed += 1; } } } Ok(removed) } fn is_stale(lock: &LockEntry) -> bool { if let Ok(created) = chrono::DateTime::parse_from_rfc3339(&lock.created_at) { let age = chrono::Utc::now().signed_duration_since(created); age.num_seconds() > lock.stale_after_seconds as i64 } else { true // Can't parse timestamp — treat as stale } }