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.
This commit is contained in:
194
rust/src/lock.rs
Normal file
194
rust/src/lock.rs
Normal file
@@ -0,0 +1,194 @@
|
||||
/// 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user