The Quipu-Log Book
Part 4 · Re-creating what a DB gave you for free, on files

13 · Concurrency I: single-writer and the file lock

What happens when two programs write to the same file at the same time? A DB engine has a lock manager to prevent exactly that. Quipu-Log has no DB engine — and therefore no lock manager. Instead, a single OS file lock guarantees "this directory belongs to exactly one process." And that simple guarantee turns out to make tamper detection simpler too.

In one sentence

Quipu-Log places a LOCK file in the root directory and calls File::try_lock() on it. A second process that tries to open the same root fails immediately — single-writer is guaranteed.

First, what you already know: the DB's lock manager

A relational DB has a sophisticated lock manager that handles row-level, page-level, and table-level locks. It arbitrates conflicts when multiple transactions touch the same data, detects deadlocks, and manages wait queues. It's complex machinery — but it's what allows many concurrent writes to proceed safely.

Quipu-Log implements none of this. It chose a much simpler principle instead: "only one writer at a time."

DB ↔ Filesystem

In a DB, a row-level lock manager mediates between multiple concurrent write sessions. In Quipu-Log, a single OS file lock enforces "one process per root directory." Rather than arbitrating between two writers, it prevents a second writer from opening at all.

Advisory lock: the key the OS hands you

The OS provides a system call for placing an advisory lock on a file. "Advisory" means the OS doesn't enforce it mechanically — a process that opens the file directly without checking the lock won't be blocked by the OS. What the lock does is detect conflicts between processes that both try to acquire it. Think of it as a gentleman's agreement among cooperating processes.

Since Rust 1.89, std::fs::File ships try_lock() and try_lock_shared() as stable APIs. File locking now works with nothing but the standard library — no external crates needed.

Cargo.toml (workspace)# MSRV floor: the store guards its root with `std::fs::File::try_lock` and
# matches on `std::fs::TryLockError`, both stabilized in Rust 1.89.
rust-version = "1.89"

That's the reason the MSRV is 1.89: to use try_lock.

The root lock in code

The very first thing AuditStore::open() does is acquire the lock on the LOCK file.

crates/quipu-core/src/store.rs — AuditStore::open()let lock = std::fs::OpenOptions::new()
    .create(true)
    .truncate(false)
    .write(true)
    .open(cfg.root.join("LOCK"))?;

lock.try_lock().map_err(|e| match e {
    std::fs::TryLockError::WouldBlock =>
        Error::Locked(cfg.root.display().to_string()),
    std::fs::TryLockError::Error(e) => Error::Io(e),
})?;

On success the file handle is stored in the _lock: std::fs::File field and held for as long as AuditStore is alive. If a second process (or thread) calls AuditStore::open() on the same root, try_lock() returns WouldBlock, which the code converts to Error::Locked — meaning "another process is already writing to this root."

The lock releases automatically when the process exits or AuditStore is dropped. Rust's Drop guarantee means no explicit "release lock" code is ever needed.

Process A AuditStore::open() → OK root/LOCK locked by A try_lock() → OK Process B Error::Locked ← fails immediately try_lock() → WouldBlock root/ ├── LOCK ├── logs/ └── registry/
While Process A holds the LOCK file, Process B's try_lock() returns WouldBlock and fails immediately. It does not wait.

Why single-writer — complexity vs. simplicity

What would it actually take to allow multiple processes to write at the same time? You'd need per-segment locks so writes don't overlap at the file level; shared memory or message passing because multiple processes would all need access to the in-memory index; and extra coordination to stop separate BufWriter instances from interleaving their buffers. In other words, you'd be importing the complexity of a distributed system.

Quipu-Log doesn't want that complexity. Instead it adopts a pipeline model: multiple threads or requests emit events, those events flow through a channel to a single dedicated writer thread, and the channel serializes the writes. Multiple threads never touch the file simultaneously. Ch. 29, async pipeline

Why this design?

A single writer cuts down on debt. One file lock blocks two processes from opening at the same time; a channel blocks two threads from writing at the same time. No lock manager, no deadlock detection, no conflict resolution. This model fits an audit log perfectly — events flow in one direction.

Single-writer makes tamper detection simpler

single-writer isn't only about performance and simplicity. It matters for security too.

Quipu-Log commits every record to a Merkle tree Ch. 20, Merkle tree. The tree records the promise "record N has this hash." If that promise is broken — someone secretly edits a record on disk — the Merkle root changes and the tampering is detected.

Now imagine multiple writers. If several processes append simultaneously, the order in which leaves are added to the Merkle tree becomes ambiguous. There's no clear answer to "which process is responsible for leaf N?" Verification becomes hard. With a single writer guaranteed, "the Nth append = the Nth leaf" is unambiguous. Verification logic stays simple and ordering is straightforward.

Security note

An advisory lock only works among cooperating processes. An attacker with direct access to the root directory (a mounted disk, root privileges) can ignore the LOCK file and edit the files directly. But such an attacker could touch the filesystem itself anyway, so the lock's job is to prevent accidental or buggy concurrent opens, not to stop a malicious intruder. Detecting malicious tampering is the Merkle tree's job (Part 5).

Recap

  • Instead of a DB lock manager, Quipu-Log uses a single OS advisory lock (File::try_lock()) to enforce "one process per root directory."
  • try_lock() does not block. If the lock is already held it returns WouldBlock immediately, which is converted to Error::Locked.
  • File::try_lock() was stabilized in Rust 1.89 — that's why the MSRV is 1.89.
  • single-writer keeps the Merkle leaf order unambiguous, which keeps tamper-detection logic simple.
  • An advisory lock is a promise among cooperating processes. Defending against malicious intruders is the Merkle tree's responsibility.
Check yourself

① What's the difference between try_lock() and a blocking lock()? Why do you think Quipu-Log uses the non-blocking version?
② Explain why the single-writer principle simplifies Merkle tree verification.
③ If two processes were allowed to open the same root simultaneously, what problem would arise for the in-memory index?