armdb 0.7.0

sharded bitcask key-value storage optimized for NVMe
Documentation
//! Process-level exclusive lock on a database directory.
//!
//! Every armdb directory holding state — the `Db` root and each collection
//! directory — is guarded by a `db.lock` sidecar carrying an exclusive advisory
//! lock. Without it two engines could run independent in-memory indexes over
//! one shard set and silently drop each other's writes.
//!
//! The lock lives exactly as long as the returned `File`, because the kernel
//! releases it when the fd closes. That holds through `SIGKILL`, panic and OOM.
//! The `db.lock` file itself persists between runs — it is the *lock* that never
//! goes stale, so nothing needs cleaning up at startup and a leftover file is
//! not evidence of a live holder.
//!
//! `flock` binds the lock to the *open file description*, not to the process,
//! so a second `open` inside this process is rejected too. Callers rely on
//! that: it is what makes a second `Db::open` on a live path an error.
//!
//! See `docs/superpowers/specs/26-07-15-db-directory-file-lock.md`.

use std::fs::{File, OpenOptions, TryLockError};
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::Path;

use crate::error::{DbError, DbResult};

/// Name of the lock sidecar inside a database directory. Reserved as a
/// collection name (`Db::RESERVED_COLLECTION_NAMES`) because `tree_path` would
/// otherwise map a collection onto the root's own lock file.
pub(crate) const LOCK_FILE_NAME: &str = "db.lock";

/// Take the exclusive lease on `<dir>/db.lock`. `dir` must already exist.
///
/// Hold the returned `File` for as long as anything can touch the directory's
/// files, and drop it only after they are flushed and closed — releasing it
/// early lets the next process in while this one is still writing.
pub(crate) fn acquire(dir: &Path) -> DbResult<File> {
    let path = dir.join(LOCK_FILE_NAME);

    // No `truncate`: the holder's pid must survive our failed attempt.
    let mut file = OpenOptions::new()
        .create(true)
        .read(true)
        .write(true)
        .truncate(false)
        .open(&path)?;

    match file.try_lock() {
        Ok(()) => {}
        Err(TryLockError::WouldBlock) => {
            return Err(DbError::Locked {
                path: dir.to_path_buf(),
                pid: read_pid(&mut file),
            });
        }
        Err(TryLockError::Error(e)) => return Err(DbError::Io(e)),
    }

    // The lease is ours now, so this write cannot race another holder. The pid
    // is only a diagnostic, so failing to record it must not fail the open.
    if let Err(e) = write_pid(&mut file) {
        tracing::warn!(?path, error = %e, "could not record pid in the lock file");
    }
    Ok(file)
}

/// Best-effort read of the holder's pid. Unreadable, empty, torn or malformed
/// content all mean "no hint" — never an error, since the lock state is already
/// known and the pid only sharpens the message.
fn read_pid(file: &mut File) -> Option<u32> {
    let mut buf = String::new();
    file.seek(SeekFrom::Start(0)).ok()?;
    // A pid is at most 10 digits; cap the read so a corrupted or replaced
    // lock file can't drive an unbounded allocation. The pid is only a hint.
    file.take(20).read_to_string(&mut buf).ok()?;
    buf.trim().parse().ok()
}

fn write_pid(file: &mut File) -> std::io::Result<()> {
    file.set_len(0)?;
    file.seek(SeekFrom::Start(0))?;
    writeln!(file, "{}", std::process::id())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::error::DbError;

    #[test]
    fn second_acquire_reports_locked_with_the_holder_pid() {
        let dir = tempfile::tempdir().unwrap();
        let _held = acquire(dir.path()).expect("first acquire must succeed");

        let err = acquire(dir.path()).expect_err("second acquire must fail");
        match err {
            DbError::Locked { path, pid } => {
                assert_eq!(path, dir.path());
                assert_eq!(pid, Some(std::process::id()), "holder pid must be recorded");
            }
            other => panic!("expected DbError::Locked, got {other:?}"),
        }
    }

    #[test]
    fn lock_is_released_when_the_file_is_dropped() {
        let dir = tempfile::tempdir().unwrap();
        let held = acquire(dir.path()).expect("first acquire");
        drop(held);
        acquire(dir.path()).expect("reacquire after drop must succeed");
    }

    #[test]
    fn unreadable_pid_yields_no_hint_rather_than_an_error() {
        let dir = tempfile::tempdir().unwrap();
        let _held = acquire(dir.path()).expect("first acquire");
        // Clobber the pid the holder wrote; the lock itself is unaffected.
        std::fs::write(dir.path().join(LOCK_FILE_NAME), b"not-a-pid").unwrap();

        let err = acquire(dir.path()).expect_err("second acquire must still fail");
        match err {
            DbError::Locked { pid, .. } => {
                assert_eq!(pid, None, "garbage pid must degrade to None")
            }
            other => panic!("expected DbError::Locked, got {other:?}"),
        }
    }
}