<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Locking on MongrelDB</title><link>https://www.mongreldb.com/articles/tags/locking/</link><description>Recent content in Locking on MongrelDB</description><image><title>MongrelDB</title><url>https://www.mongreldb.com/logo.png</url><link>https://www.mongreldb.com/logo.png</link></image><generator>Hugo</generator><language>en-US</language><lastBuildDate>Mon, 27 Jul 2026 06:00:00 -0500</lastBuildDate><atom:link href="https://www.mongreldb.com/articles/tags/locking/index.xml" rel="self" type="application/rss+xml"/><item><title>Multi-Process File Locking for the Embedded Case</title><link>https://www.mongreldb.com/articles/2026/07/multi-process-file-locking-for-the-embedded-case/</link><pubDate>Mon, 27 Jul 2026 06:00:00 -0500</pubDate><guid>https://www.mongreldb.com/articles/2026/07/multi-process-file-locking-for-the-embedded-case/</guid><description>A pure embedded engine never has to think about other processes, but MongrelDB is also a server, so it takes an exclusive advisory lock at open, fails fast by default, waits politely when you ask it to, and pushes genuine multi-process access through the daemon instead of through the filesystem.</description><content:encoded><![CDATA[<p>Every embedded database makes a quiet promise the day you link it in, which is that the file on disk belongs to your process and your process alone, and the interesting question is what happens when that promise meets reality, because reality contains cron jobs, backup scripts, a second service someone stood up on the same host, and the developer who opens the production directory in a REPL to &ldquo;just check something.&rdquo; A genuinely single-opener store, think <code>gdbm</code> in its default mode or the hundred naive in-process key-value libraries that persist on a timer, gets to answer that question with a shrug, since it never claimed to support more than one opener and the incoherence that follows a double-open is filed under &ldquo;you were holding it wrong.&rdquo; MongrelDB does not get that luxury, because the same engine that embeds into your process also runs as <code>mongreldb-server</code>, and the moment one binary can be both a library and a daemon, &ldquo;who else has this directory open&rdquo; stops being a hypothetical and becomes a question the open path has to answer correctly every single time.</p>
<h2 id="what-a-pure-embedded-engine-gets-to-skip">What a pure embedded engine gets to skip</h2>
<p>The reason pure embedded engines are simple is not that their authors were lazy; it is that cross-process coordination is genuinely hard to get right, and skipping it buys you an enormous amount of freedom. If only one process ever opens the file, then the page cache is always coherent because nothing outside the process can invalidate it, the WAL append order is the commit order because nobody else is appending, and every lock the engine takes can be a normal in-process mutex, which is fast, debuggable, and impossible to leave dangling after a crash. LMDB pays real money to escape part of this, with a shared memory-mapped lock table so many reader processes can coexist with one writer, and SQLite&rsquo;s WAL mode does the same trick with its <code>-shm</code> shared-memory index, and both designs work, but both also inherit the failure modes of shared-memory concurrency: stale reader slots, processes that died holding a slot, recovery logic that has to run before anyone trusts the file again.</p>
<p>MongrelDB&rsquo;s writer path assumes in-process coordination all the way down, from the group-commit sequencer that batches transactions under one WAL lock, to the per-table guards that let independent readers skip the writer lock, to the lock manager that tracks key and predicate conflicts inside a commit. Making all of that safe across processes would mean rebuilding it on top of shared memory or a lock file protocol, and the result would be a slower, stranger engine whose main new feature is &ldquo;two processes can corrupt things in more interesting ways.&rdquo; So the design decision was to keep the engine single-opener and let the kernel arbitrate that rule between cooperating processes, which is a weaker guarantee than a hard barrier and a much stronger one than a comment in the README, and the next section is about exactly how weak and how strong.</p>
<h2 id="two-lock-files-and-one-syscall">Two lock files and one syscall</h2>
<p>When you call <code>Database::open</code>, the engine canonicalizes the path, hashes that canonical path, and opens a lock file named <code>.mongreldb-&lt;hash&gt;.lock</code> in the parent directory of the database root, so the lock sits next to the database directory rather than inside it and even <code>Database::create</code> on a path that does not exist yet can take the lock before writing a single byte, then takes an exclusive advisory lock on it through the <code>fs2</code> crate, which is <code>flock(2)</code> on Unix and <code>LockFileEx</code> on Windows, so the same code path holds on every supported platform. A second exclusive lock lands on <code>_meta/.lock</code> inside the database root itself, which is the legacy lock that protects the durable root descriptor, and between the two files the open is pinned against both &ldquo;another process opened the same canonical path&rdquo; and &ldquo;another process opened the same on-disk root through a different route.&rdquo; The locks are advisory, which means they only stop cooperating openers, and that is exactly the right threat model here, because the enemy is the accidental second open, the overlapping deploy, the cron job that started before the app finished shutting down, not an attacker who is already in a position to <code>flock</code> your files, since that attacker can just read the bytes directly. The locks are also only as good as the filesystem underneath them, and <code>flock</code> is a local-filesystem contract whose semantics over NFS have historically ranged from &ldquo;emulated through <code>fcntl</code>&rdquo; to &ldquo;silently per-client,&rdquo; so the honest deployment rule is to keep the database root on local disk and treat a network mount as a way to lose the guarantee without an error message.</p>
<p>The failure is a typed error, <code>MongrelError::DatabaseLocked</code>, carrying the path and the underlying message, so application code can pattern-match on it instead of scraping an errno string, and the default behavior is fail-fast: one <code>try_lock_exclusive</code> call, no retry, no sleep, you find out immediately that someone else owns the database, which is the same default SQLite ships with and the same default you want in a service that would rather crash-loop visibly than queue behind a lock holder.</p>
<h2 id="same-process-same-file-still-no">Same process, same file, still no</h2>
<p>The cross-process lock says nothing about the case where your own process opens the same database twice, which happens more often than anyone admits, usually when two subsystems each decide they own database initialization and nobody notices until the second open in a test. MongrelDB keeps a process-wide open registry keyed on two things at once: the canonical path you asked for, and the file identity of the durable root, which is the device-and-inode pair the filesystem reports, so opening <code>./data</code> and then opening a symlinked or bind-mounted alias that resolves to the same bytes still collides on the identity key even when the paths look different to string comparison. Either collision fails immediately with <code>DatabaseLocked</code> and a message that tells you the actual fix, which is to share the existing <code>Arc&lt;Database&gt;</code> instead of opening again, because the handle is designed to be shared and all of its internal concurrency is already sorted out.</p>
<p>This is one of those checks that costs nothing at runtime and saves an afternoon, because the alternative failure mode is two <code>Database</code> instances in one process, each with its own page cache and its own view of the WAL, discovering each other&rsquo;s writes only through the filesystem, which is the kind of bug that passes CI and eats a weekend in production.</p>
<h2 id="waiting-politely-when-you-actually-want-to-wait">Waiting politely when you actually want to wait</h2>
<p>Fail-fast is the right default for a service, but it is the wrong default for a batch job or a migration runner whose honest answer to &ldquo;the database is busy&rdquo; is &ldquo;then wait a moment,&rdquo; and for those callers the engine mirrors SQLite&rsquo;s <code>busy_timeout</code> with <code>OpenOptions::lock_timeout_ms</code>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-rust" data-lang="rust"><span style="display:flex;"><span><span style="color:#66d9ef">use</span> mongreldb_core::{Database, OpenOptions};
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// A deploy-time migration runner that should wait out a rolling restart
</span></span></span><span style="display:flex;"><span><span style="color:#75715e">// instead of dying the moment the old process still holds the lock.
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">let</span> db <span style="color:#f92672">=</span> Database::open_with_options(
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;./data&#34;</span>,
</span></span><span style="display:flex;"><span>    OpenOptions::default().with_lock_timeout_ms(<span style="color:#ae81ff">5_000</span>),
</span></span><span style="display:flex;"><span>)<span style="color:#f92672">?</span>;
</span></span></code></pre></div><p>With a non-zero timeout the open retries the exclusive lock on a backoff schedule that starts at 1ms, stretches to 10ms, then caps at 50ms per attempt, all bounded by a single deadline so the total elapsed time never exceeds your budget even if the lock holder releases at the tail of a sleep; the doc comment suggests 1,000 to 5,000ms for SQLite-style workloads, and that range is honest, because anything longer usually means the lock holder is stuck rather than busy. Two details make this nicer than the average retry loop: the engine counts lock waits and open failures in atomic counters that surface in the stats snapshot, so &ldquo;how often do opens contend&rdquo; is an observable number rather than a guess, and the cross-process test suite does not trust the engine to test itself, it spawns a subprocess that grabs a raw <code>flock(2)</code> on <code>_meta/.lock</code> while bypassing the engine entirely, which proves the behavior you see through <code>Database::open</code> is exactly the behavior the kernel enforces, with no bookkeeping in between that could drift.</p>
<h2 id="the-tradeoff-stated-plainly">The tradeoff, stated plainly</h2>
<p>What you give up with a single-opener engine is the SQLite-WAL party trick, where a reporting process and an application process read the same file at the same time without talking to each other, and if your architecture depends on that shape, MongrelDB is telling you something you should listen to rather than work around. What you get back is a writer whose page cache is never invalidated underneath it, a WAL whose append order is the commit order with no shared-memory recovery dance, and a clean answer to the legitimate multi-process case, which is that <code>mongreldb-server</code> holds the lock and speaks HTTP to everyone else, so &ldquo;two processes, one database&rdquo; becomes &ldquo;one process owns the storage, the rest are clients,&rdquo; which is the shape you would have ended up at anyway once the second process needed auth, auditing, or a schema migration. The modern equivalent of the old &ldquo;just open the file from both places&rdquo; instinct is running the daemon next to the app and letting the client libraries do what client libraries do, and the lock file is what keeps the old instinct from silently winning the day someone acts on it.</p>
]]></content:encoded></item></channel></rss>