omne-cli 0.2.1

CLI for managing omne volumes: init, upgrade, and validate kernel and distro releases
Documentation
#![allow(dead_code)]

//! Monotonic ULID allocator backed by an advisory file lock.
//!
//! `allocate(lock_path)` returns a `Ulid` strictly greater than every
//! prior ULID minted at the same lock path, across processes. The
//! `run_id = <pipe>-<lowercase-ulid>` composition (plan R4) lives at
//! the call site; this module returns the raw `Ulid`.
//!
//! Cross-process ordering uses `fs2` advisory exclusive locks on the
//! lock file itself. Default acquire budget is 5s; exceeding yields
//! `Error::LockTimeout { path }` (surfaced by `CliError::UlidLockTimeout`).
//!
//! Monotonicity under clock skew: if `Ulid::new()` produces a value not
//! strictly greater than the persisted ULID (host clock ran backward, or
//! two calls within the same millisecond happen to land with a smaller
//! random field), we bump the persisted value via `Ulid::increment()` and
//! return that instead.

use std::fs::{File, OpenOptions};
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::thread;
use std::time::{Duration, Instant};

use fs2::FileExt;
use thiserror::Error;
use ulid::Ulid;

/// Default budget for acquiring the advisory lock on the lock file.
pub const DEFAULT_LOCK_TIMEOUT: Duration = Duration::from_secs(5);

const LOCK_POLL_INTERVAL: Duration = Duration::from_millis(25);

#[derive(Debug, Error)]
pub enum Error {
    #[error("ULID lock {path} was not released within the acquire budget")]
    LockTimeout { path: PathBuf },

    #[error("ULID lock {path} I/O error: {source}")]
    Io {
        path: PathBuf,
        #[source]
        source: std::io::Error,
    },

    #[error("ULID lock {path} holds a malformed value: {value:?}")]
    MalformedState { path: PathBuf, value: String },
}

/// Allocate a strictly-monotonic ULID using the default 5s lock budget.
pub fn allocate(lock_path: &Path) -> Result<Ulid, Error> {
    allocate_with_timeout(lock_path, DEFAULT_LOCK_TIMEOUT)
}

/// Allocate with an explicit lock-acquisition timeout. Used by tests.
pub fn allocate_with_timeout(lock_path: &Path, timeout: Duration) -> Result<Ulid, Error> {
    if let Some(parent) = lock_path.parent() {
        if !parent.as_os_str().is_empty() {
            std::fs::create_dir_all(parent).map_err(|source| Error::Io {
                path: lock_path.to_path_buf(),
                source,
            })?;
        }
    }

    let file = OpenOptions::new()
        .create(true)
        .read(true)
        .write(true)
        .truncate(false)
        .open(lock_path)
        .map_err(|source| Error::Io {
            path: lock_path.to_path_buf(),
            source,
        })?;

    acquire_lock(&file, lock_path, timeout)?;

    let result = mint(&file, lock_path);

    // Release regardless of mint outcome; ignore unlock errors — a dropped
    // file handle releases the advisory lock anyway on every platform fs2
    // supports.
    let _ = FileExt::unlock(&file);

    result
}

fn acquire_lock(file: &File, lock_path: &Path, timeout: Duration) -> Result<(), Error> {
    // `WouldBlock` is the Unix mapping of EWOULDBLOCK; Windows instead
    // returns ERROR_LOCK_VIOLATION (OS code 33) which Rust classifies as
    // `Uncategorized`. `fs2::lock_contended_error()` is the canonical
    // "lock held" sentinel — we compare `raw_os_error()` against it to
    // stay portable.
    let contended_os = fs2::lock_contended_error().raw_os_error();
    let deadline = Instant::now() + timeout;
    loop {
        // Check the deadline before every lock attempt (and before any
        // sleep), so the worst-case overshoot is bounded by the syscall
        // duration of one `try_lock_exclusive`, not by an additional
        // `LOCK_POLL_INTERVAL` of sleep that fires after the budget
        // already expired.
        if Instant::now() >= deadline {
            return Err(Error::LockTimeout {
                path: lock_path.to_path_buf(),
            });
        }
        match FileExt::try_lock_exclusive(file) {
            Ok(()) => return Ok(()),
            Err(e)
                if e.kind() == std::io::ErrorKind::WouldBlock
                    || e.raw_os_error() == contended_os =>
            {
                // Sleep for the smaller of the poll interval and the
                // remaining budget so the next iteration's deadline
                // check fires close to the requested timeout.
                let remaining = deadline.saturating_duration_since(Instant::now());
                thread::sleep(LOCK_POLL_INTERVAL.min(remaining));
            }
            Err(source) => {
                return Err(Error::Io {
                    path: lock_path.to_path_buf(),
                    source,
                });
            }
        }
    }
}

fn mint(mut file: &File, lock_path: &Path) -> Result<Ulid, Error> {
    let prior = read_state(&mut file, lock_path)?;
    let candidate = Ulid::new();
    let next = match prior {
        Some(p) if candidate <= p => p.increment().ok_or_else(|| Error::MalformedState {
            path: lock_path.to_path_buf(),
            value: "ULID space exhausted at persisted value".to_string(),
        })?,
        _ => candidate,
    };
    write_state(&mut file, lock_path, next)?;
    Ok(next)
}

fn read_state(file: &mut &File, lock_path: &Path) -> Result<Option<Ulid>, Error> {
    let mut buf = String::new();
    (*file)
        .seek(SeekFrom::Start(0))
        .map_err(|source| Error::Io {
            path: lock_path.to_path_buf(),
            source,
        })?;
    (*file)
        .read_to_string(&mut buf)
        .map_err(|source| Error::Io {
            path: lock_path.to_path_buf(),
            source,
        })?;
    let trimmed = buf.trim();
    if trimmed.is_empty() {
        return Ok(None);
    }
    Ulid::from_str(trimmed)
        .map(Some)
        .map_err(|_| Error::MalformedState {
            path: lock_path.to_path_buf(),
            value: trimmed.to_string(),
        })
}

fn write_state(file: &mut &File, lock_path: &Path, ulid: Ulid) -> Result<(), Error> {
    // ULIDs serialize to a fixed 26-character ASCII string. We
    // overwrite in place from offset 0 without truncating first; that
    // means a torn write — kill mid-`write_all` — leaves the file
    // either with the prior 26-character ULID intact (if the write
    // started but no bytes hit disk) or with a partial ULID prefix +
    // tail of the prior ULID (`MalformedState` on next read,
    // recoverable by deleting `.ulid-last`). Truncating first would
    // turn the same crash into an empty file that the next caller
    // mis-reads as "first allocation" — silently losing monotonicity.
    // Constant-length overwrite keeps the failure mode loud.
    (*file)
        .seek(SeekFrom::Start(0))
        .map_err(|source| Error::Io {
            path: lock_path.to_path_buf(),
            source,
        })?;
    let bytes = ulid.to_string();
    debug_assert_eq!(bytes.len(), 26, "ULID string must be exactly 26 chars");
    (*file)
        .write_all(bytes.as_bytes())
        .map_err(|source| Error::Io {
            path: lock_path.to_path_buf(),
            source,
        })?;
    (*file).flush().map_err(|source| Error::Io {
        path: lock_path.to_path_buf(),
        source,
    })
}