haematite 0.4.1

Content-addressed, branchable, actor-native storage engine
Documentation
//! A4: cross-process data-directory writer lock.
//!
//! One haematite data directory admits at most ONE live writer process; a
//! second writer running its own shard actors over the same WAL files would
//! corrupt them. [`DataDirLock`] closes that hole with an EXCLUSIVE advisory
//! OS lock (`flock` on Unix, `LockFileEx` on Windows, via the stable
//! `std::fs::File` locking API) over `<data_dir>/writer.lock`, acquired
//! non-blocking at `Database::create`/`Database::open` and held for the
//! lifetime of the `Database` handle.
//!
//! Semantics (see docs/design/LOCKFILE-SEMANTICS.md for the full note):
//!
//! - Contention never blocks and never corrupts: a second writer fails LOUDLY
//!   at open with the distinct [`super::DatabaseError::DataDirLocked`].
//! - There is no staleness story to manage: advisory locks belong to the open
//!   file description and the kernel releases them on process death (`kill -9`
//!   included). A crashed writer leaves only an inert, unlocked, content-free
//!   lockfile whose presence means nothing.
//! - The lockfile carries NO content (deliberately no PID — PID files lie
//!   after crashes and PID reuse; the OS lock state is the only truth).
//! - Read-only observers ([`super::ReadOnlyDatabase`]) take NO lock at all:
//!   they are safe by construction and must never be able to deny startup to
//!   the legitimate writer.
//! - Drop does NOT issue an explicit unlock: release happens when the last
//!   handle to the open file description closes. This keeps the planned haem
//!   double-fork daemon sound — a parent's fd close never yanks the lock out
//!   from under a forked child, whereas an explicit `LOCK_UN` would.

use std::fmt;
use std::fs::{File, OpenOptions, TryLockError};
use std::io;
use std::path::{Path, PathBuf};

use super::DatabaseError;

/// The writer lockfile name inside a database data directory.
const LOCK_FILE: &str = "writer.lock";

/// Exclusive cross-process writer lock over one database data directory.
///
/// Constructed by [`DataDirLock::acquire`]; the OS lock is held exactly as
/// long as this value (and any file descriptions it forked to) lives. Dropping
/// it — or the process dying — releases the lock; the lockfile itself is left
/// behind as an inert, content-free anchor.
pub struct DataDirLock {
    /// The open, locked lockfile handle. Held, never read: closing it is what
    /// releases the advisory lock.
    file: File,
    /// The lockfile path, kept for diagnostics.
    lock_path: PathBuf,
}

impl fmt::Debug for DataDirLock {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter
            .debug_struct("DataDirLock")
            .field("lock_path", &self.lock_path)
            .field("file", &self.file)
            .finish()
    }
}

impl DataDirLock {
    /// Acquire the exclusive writer lock for `data_dir`, without blocking.
    ///
    /// Opens (creating if absent, never truncating) `<data_dir>/writer.lock`
    /// and takes an exclusive advisory lock on it. A lock already held by any
    /// live process — including this one, through a different `Database`
    /// handle — fails immediately with [`LockError::AlreadyLocked`].
    pub fn acquire(data_dir: &Path) -> Result<Self, LockError> {
        let lock_path = data_dir.join(LOCK_FILE);
        let file = OpenOptions::new()
            .read(true)
            .write(true)
            .create(true)
            .truncate(false)
            .open(&lock_path)
            .map_err(|error| LockError::Io {
                lock_path: lock_path.clone(),
                error,
            })?;
        match file.try_lock() {
            Ok(()) => Ok(Self { file, lock_path }),
            Err(TryLockError::WouldBlock) => Err(LockError::AlreadyLocked { lock_path }),
            Err(error) => Err(LockError::Io {
                lock_path,
                error: error.into(),
            }),
        }
    }
}

/// Errors raised while acquiring the data-dir writer lock.
#[derive(Debug)]
pub enum LockError {
    /// Another live writer (process or in-process `Database` handle) holds the
    /// exclusive lock. The loud second-writer failure.
    AlreadyLocked { lock_path: PathBuf },
    /// The lockfile could not be opened/created, or the lock syscall failed
    /// for an I/O reason distinct from contention.
    Io {
        lock_path: PathBuf,
        error: io::Error,
    },
}

impl fmt::Display for LockError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::AlreadyLocked { lock_path } => write!(
                formatter,
                "data dir writer lock at {} is held by another live writer",
                lock_path.display()
            ),
            Self::Io { lock_path, error } => write!(
                formatter,
                "failed to acquire data dir writer lock at {}: {error}",
                lock_path.display()
            ),
        }
    }
}

impl std::error::Error for LockError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            Self::Io { error, .. } => Some(error),
            Self::AlreadyLocked { .. } => None,
        }
    }
}

impl From<LockError> for DatabaseError {
    fn from(error: LockError) -> Self {
        match error {
            LockError::AlreadyLocked { lock_path } => Self::DataDirLocked { lock_path },
            LockError::Io { lock_path, error } => Self::LockFileIo { lock_path, error },
        }
    }
}