Files
containerarchive/rust/src/lock.rs
Juergen Kunz a5849791d2 feat: initial implementation of content-addressed incremental backup engine
Rust-centric architecture with TypeScript facade following smartproxy/smartstorage pattern.
Core engine in Rust (FastCDC chunking, SHA-256, gzip, AES-256-GCM + Argon2id, binary pack files,
global index, snapshots, locking, verification, pruning, repair). TypeScript provides npm interface
via @push.rocks/smartrust RustBridge IPC with Unix socket streaming for ingest/restore.
All 14 integration tests pass.
2026-03-21 23:30:17 +00:00

195 lines
6.0 KiB
Rust

/// 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<LockEntry, ArchiveError> {
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<bool, ArchiveError> {
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<Option<LockEntry>, 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<u32, ArchiveError> {
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<u32, ArchiveError> {
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
}
}