databuild/databuild/server_lock.rs

308 lines
10 KiB
Rust

//! Server lock file management for CLI-server automation.
//!
//! This module handles the `.databuild/${graph_label}/server.lock` file which:
//! 1. Acts as a file lock to prevent multiple servers for the same graph
//! 2. Stores runtime state (PID, port, start time, config hash)
use crate::util::DatabuildError;
use fs2::FileExt;
use sha2::{Digest, Sha256};
use std::fs::{self, File, OpenOptions};
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
/// Runtime state stored in the server.lock file
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ServerLockState {
/// Server process ID
pub pid: u32,
/// HTTP port the server is listening on
pub port: u16,
/// Unix timestamp (milliseconds) when server started
pub started_at: u64,
/// SHA256 hash of config file contents (for detecting config changes)
pub config_hash: String,
}
/// Manages the server lock file and .databuild directory structure.
pub struct ServerLock {
/// Path to the .databuild/${graph_label}/ directory
graph_dir: PathBuf,
/// Path to the server.lock file
lock_path: PathBuf,
/// Open file handle (holds the lock while alive)
lock_file: Option<File>,
}
impl ServerLock {
/// Create a ServerLock for the given graph label, using the current directory.
/// Creates the .databuild/${graph_label}/ directory if it doesn't exist.
pub fn new(graph_label: &str) -> Result<Self, DatabuildError> {
Self::new_in_dir(Path::new("."), graph_label)
}
/// Create a ServerLock for the given graph label in the specified base directory.
/// Creates the .databuild/${graph_label}/ directory if it doesn't exist.
pub fn new_in_dir(base_dir: &Path, graph_label: &str) -> Result<Self, DatabuildError> {
let graph_dir = base_dir.join(".databuild").join(graph_label);
fs::create_dir_all(&graph_dir).map_err(|e| {
DatabuildError::from(format!("Failed to create graph directory: {}", e))
})?;
let lock_path = graph_dir.join("server.lock");
Ok(Self {
graph_dir,
lock_path,
lock_file: None,
})
}
/// Path to the graph directory (`.databuild/${graph_label}/`)
pub fn graph_dir(&self) -> &Path {
&self.graph_dir
}
/// Path to the server log file
pub fn log_path(&self) -> PathBuf {
self.graph_dir.join("server.log")
}
/// Try to acquire an exclusive lock on the server.lock file.
/// Returns Ok(true) if lock acquired (server not running).
/// Returns Ok(false) if lock is held by another process (server running).
pub fn try_lock(&mut self) -> Result<bool, DatabuildError> {
let file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&self.lock_path)
.map_err(|e| DatabuildError::from(format!("Failed to open lock file: {}", e)))?;
match file.try_lock_exclusive() {
Ok(()) => {
self.lock_file = Some(file);
Ok(true)
}
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
// Lock is held by another process
Ok(false)
}
Err(e) => Err(DatabuildError::from(format!(
"Failed to acquire lock: {}",
e
))),
}
}
/// Write the server state to the lock file.
/// Must have acquired the lock first via try_lock().
pub fn write_state(&mut self, state: &ServerLockState) -> Result<(), DatabuildError> {
let file = self
.lock_file
.as_mut()
.ok_or_else(|| DatabuildError::from("Lock not acquired"))?;
// Truncate and write new content
file.set_len(0)
.map_err(|e| DatabuildError::from(format!("Failed to truncate lock file: {}", e)))?;
let content = serde_json::to_string_pretty(state)
.map_err(|e| DatabuildError::from(format!("Failed to serialize state: {}", e)))?;
// Seek to beginning
use std::io::Seek;
file.seek(std::io::SeekFrom::Start(0))
.map_err(|e| DatabuildError::from(format!("Failed to seek: {}", e)))?;
file.write_all(content.as_bytes())
.map_err(|e| DatabuildError::from(format!("Failed to write state: {}", e)))?;
file.flush()
.map_err(|e| DatabuildError::from(format!("Failed to flush: {}", e)))?;
Ok(())
}
/// Read the server state from the lock file.
/// Can be called without holding the lock (to read port from running server).
pub fn read_state(&self) -> Result<Option<ServerLockState>, DatabuildError> {
if !self.lock_path.exists() {
return Ok(None);
}
let mut file = File::open(&self.lock_path)
.map_err(|e| DatabuildError::from(format!("Failed to open lock file: {}", e)))?;
let mut content = String::new();
file.read_to_string(&mut content)
.map_err(|e| DatabuildError::from(format!("Failed to read lock file: {}", e)))?;
if content.trim().is_empty() {
return Ok(None);
}
let state: ServerLockState = serde_json::from_str(&content)
.map_err(|e| DatabuildError::from(format!("Failed to parse lock file: {}", e)))?;
Ok(Some(state))
}
/// Check if the process with the given PID is still running.
pub fn is_process_running(pid: u32) -> bool {
// On Unix, sending signal 0 checks if process exists
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
unsafe { libc::kill(pid as i32, 0) == 0 }
}
#[cfg(not(unix))]
{
// On non-Unix, assume process is running if we can't check
true
}
}
/// Delete a stale lock file (when process has crashed).
pub fn remove_stale_lock(&self) -> Result<(), DatabuildError> {
if self.lock_path.exists() {
fs::remove_file(&self.lock_path)
.map_err(|e| DatabuildError::from(format!("Failed to remove stale lock: {}", e)))?;
}
Ok(())
}
/// Compute SHA256 hash of config file contents.
pub fn hash_config(config_path: &Path) -> Result<String, DatabuildError> {
let content = fs::read_to_string(config_path)
.map_err(|e| DatabuildError::from(format!("Failed to read config: {}", e)))?;
let mut hasher = Sha256::new();
hasher.update(content.as_bytes());
let result = hasher.finalize();
Ok(format!("sha256:{:x}", result))
}
/// Get current timestamp in milliseconds since Unix epoch.
pub fn now_millis() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as u64
}
}
impl Drop for ServerLock {
fn drop(&mut self) {
// Lock is automatically released when file is closed
if let Some(file) = self.lock_file.take() {
let _ = file.unlock();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn test_server_lock_acquire() {
let temp = tempdir().unwrap();
let mut lock = ServerLock::new_in_dir(temp.path(), "test_graph").unwrap();
assert!(lock.try_lock().unwrap(), "Lock should succeed");
}
#[test]
fn test_server_lock_state_read_write() {
let temp = tempdir().unwrap();
let mut lock = ServerLock::new_in_dir(temp.path(), "test_graph").unwrap();
assert!(lock.try_lock().unwrap());
let state = ServerLockState {
pid: 12345,
port: 8080,
started_at: 1701234567890,
config_hash: "sha256:abc123".to_string(),
};
lock.write_state(&state).unwrap();
// Read it back (via a fresh ServerLock to test read without lock)
let lock2 = ServerLock::new_in_dir(temp.path(), "test_graph").unwrap();
let read_state = lock2.read_state().unwrap().unwrap();
assert_eq!(read_state.pid, 12345);
assert_eq!(read_state.port, 8080);
assert_eq!(read_state.started_at, 1701234567890);
assert_eq!(read_state.config_hash, "sha256:abc123");
}
#[test]
fn test_server_lock_graph_dir() {
let temp = tempdir().unwrap();
let lock = ServerLock::new_in_dir(temp.path(), "my_graph").unwrap();
assert!(lock.graph_dir().ends_with(".databuild/my_graph"));
}
#[test]
fn test_server_lock_log_path() {
let temp = tempdir().unwrap();
let lock = ServerLock::new_in_dir(temp.path(), "my_graph").unwrap();
assert!(lock.log_path().ends_with(".databuild/my_graph/server.log"));
}
#[test]
fn test_hash_config() {
let temp = tempdir().unwrap();
let config_path = temp.path().join("config.json");
fs::write(&config_path, r#"{"graph_label": "test"}"#).unwrap();
let hash = ServerLock::hash_config(&config_path).unwrap();
assert!(hash.starts_with("sha256:"));
assert!(hash.len() > 10);
}
#[test]
fn test_hash_config_deterministic() {
let temp = tempdir().unwrap();
let config_path = temp.path().join("config.json");
fs::write(&config_path, r#"{"graph_label": "test"}"#).unwrap();
let hash1 = ServerLock::hash_config(&config_path).unwrap();
let hash2 = ServerLock::hash_config(&config_path).unwrap();
assert_eq!(hash1, hash2, "Same content should produce same hash");
}
#[test]
fn test_read_state_nonexistent() {
let temp = tempdir().unwrap();
let lock = ServerLock::new_in_dir(temp.path(), "nonexistent_graph").unwrap();
let state = lock.read_state().unwrap();
assert!(
state.is_none(),
"Reading nonexistent lock file should return None"
);
}
#[test]
fn test_now_millis() {
let before = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as u64;
let now = ServerLock::now_millis();
let after = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as u64;
assert!(
now >= before && now <= after,
"now_millis should be between before and after"
);
}
}