Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion book/src/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ The directory where Bender stores cloned and checked-out dependencies.
- **Example:** `database: /var/cache/bender_dependencies`

### `db_dir`
Optional override for the directory that holds bare git repositories and their lock files (i.e. `<db_dir>/git/db/` and `<db_dir>/git/locks/`). When set, it takes precedence over `database` (whether explicitly configured or left at its default) for these two paths only; the working-tree checkouts continue to follow `database` (or [`workspace.checkout_dir`](./manifest.md) in the project manifest). This makes it possible to share the heavy git data across projects on a persistent runner without also relocating per-project checkouts. See [Continuous Integration › Sharing the Database](./workflow/ci.md#sharing-the-database-across-runs-and-projects) for the recommended setup.
Optional override for the directory that holds the bare git repositories and their lock files (both live under `<db_dir>/git/db/`, each lock file sitting next to the bare repo it guards). When set, it takes precedence over `database` (whether explicitly configured or left at its default) for this path only; the working-tree checkouts continue to follow `database` (or [`workspace.checkout_dir`](./manifest.md) in the project manifest). This makes it possible to share the heavy git data across projects on a persistent runner without also relocating per-project checkouts. See [Continuous Integration › Sharing the Database](./workflow/ci.md#sharing-the-database-across-runs-and-projects) for the recommended setup.
- **Config Key:** `db_dir`
- **Env Var:** `BENDER_DB_DIR` (used only when no configuration file sets `db_dir`; configuration files always take precedence).
- **Default:** unset (falls back to `database`).
Expand Down
2 changes: 1 addition & 1 deletion book/src/workflow/ci.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ Place a [`Bender.local`](../local.md) in a parent directory of your projects so
db_dir: /var/cache/bender_shared
```

Every job on the runner now reuses the already-fetched Git data and serializes safely against concurrent jobs via per-dependency filesystem locks living next to the bare repos (`<db_dir>/git/locks/`).
Every job on the runner now reuses the already-fetched Git data and serializes safely against concurrent jobs via per-dependency filesystem locks living next to the bare repos (`<db_dir>/git/db/`).

If you'd rather not place a `Bender.local` on the runner at all, exporting `BENDER_DB_DIR=/var/cache/bender_shared` in the job environment has the same effect — any project that explicitly sets `db_dir` in its own configuration overrides the env var.

Expand Down
103 changes: 67 additions & 36 deletions src/lock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,36 +2,63 @@

//! Cross-process filesystem advisory locks.
//!
//! Bender uses these to serialize concurrent invocations against the same git
//! database and checkout. The lock is taken on a sentinel file in
//! `<database>/git/locks/<name>-<hash>.lock` and released automatically when
//! the [`FsLock`] guard is dropped.
//! Bender uses these to coordinate concurrent invocations against the same git
//! database. The lock is taken on a sentinel file kept as a sibling of the
//! resource it guards -- `<database>/git/db/<name>-<hash>.lock` for a bare
//! database, or `<checkout>.lock` for a working-tree checkout: writers
//! (database initialization and fetches) acquire it exclusively, while readers
//! (version resolution and checkouts, which never mutate the database) acquire
//! it shared so they can proceed in parallel. The lock is released
//! automatically when the [`FsLockGuard`] is dropped.

#![deny(missing_docs)]

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

use miette::{Context as _, IntoDiagnostic as _};

use crate::Result;

/// An exclusive, cross-process advisory lock held on a sentinel file.
///
/// The lock is released when this guard is dropped (or when the process exits).
/// A cross-process advisory lock identified by a sentinel file path.
pub struct FsLock {
file: Option<File>,
path: PathBuf,
}

impl FsLock {
/// Acquire an exclusive lock on `path`, creating the file if missing.
/// Create a lock on the sentinel file at `path`.
///
/// If the lock is contended, an info message is logged so the user can see
/// why bender is waiting, and the call then blocks until the lock is
/// available. The actual lock acquisition runs on a blocking worker so it
/// does not stall the tokio runtime.
pub async fn acquire_exclusive(path: PathBuf) -> Result<Self> {
/// This does not touch the filesystem; the file is created and locked when
/// [`acquire_exclusive`](Self::acquire_exclusive) or
/// [`acquire_shared`](Self::acquire_shared) is called.
pub fn new(path: PathBuf) -> Self {
FsLock { path }
}

/// Acquire an exclusive (writer) lock, creating the file if missing.
///
/// Use this for operations that mutate the git database (initialization or
/// fetching). See [`acquire`](Self::acquire) for the blocking behavior.
pub async fn acquire_exclusive(&self) -> Result<FsLockGuard> {
self.acquire(true).await
}

/// Acquire a shared (reader) lock, creating the file if missing.
///
/// Multiple processes may hold a shared lock simultaneously; a shared lock
/// only excludes exclusive lockers. Use this for read-only access to the
/// git database, such as resolving versions or creating a checkout. This
/// is what lets concurrent bender invocations check out in parallel against
/// a shared, pre-populated database.
pub async fn acquire_shared(&self) -> Result<FsLockGuard> {
self.acquire(false).await
}

/// Acquire the lock, creating the file if missing.
///
/// If the lock is contended, the call blocks until the lock is available.
async fn acquire(&self, exclusive: bool) -> Result<FsLockGuard> {
let path = self.path.clone();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.into_diagnostic()
Expand All @@ -46,44 +73,48 @@ impl FsLock {
.into_diagnostic()
.wrap_err_with(|| format!("Failed to open lock file {:?}.", path))?;

let path_for_blocking = path.clone();
let file = tokio::task::spawn_blocking(move || -> Result<File> {
match file.try_lock() {
let try_lock = if exclusive {
file.try_lock()
} else {
file.try_lock_shared()
};
match try_lock {
Ok(()) => Ok(file),
Err(TryLockError::WouldBlock) => {
log::info!("waiting for lock on {:?}", path_for_blocking);
file.lock().into_diagnostic().wrap_err_with(|| {
format!("Failed to acquire lock on {:?}.", path_for_blocking)
})?;
log::info!("waiting for lock on {:?}", path);
let blocking_lock = if exclusive {
file.lock()
} else {
file.lock_shared()
};
blocking_lock
.into_diagnostic()
.wrap_err_with(|| format!("Failed to acquire lock on {:?}.", path))?;
Ok(file)
}
Err(TryLockError::Error(e)) => Err(e)
.into_diagnostic()
.wrap_err_with(|| format!("Failed to try-lock {:?}.", path_for_blocking)),
.wrap_err_with(|| format!("Failed to try-lock {:?}.", path)),
}
})
.await
.into_diagnostic()
.wrap_err("Lock acquisition task panicked.")??;

Ok(FsLock {
file: Some(file),
path,
})
Ok(FsLockGuard { file })
}
}

/// The path of the lock file.
pub fn path(&self) -> &Path {
&self.path
}
/// A held [`FsLock`], released when this guard is dropped (or the process exits).
pub struct FsLockGuard {
file: File,
}

impl Drop for FsLock {
impl Drop for FsLockGuard {
fn drop(&mut self) {
if let Some(file) = self.file.take() {
// Best-effort: errors here are not actionable, and the OS releases
// the lock automatically when the file handle closes.
let _ = file.unlock();
}
// Best-effort: errors here are not actionable, and the OS releases the
// lock automatically when the file handle closes.
let _ = self.file.unlock();
}
}
Loading