<?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>Architecture on MongrelDB</title><link>https://www.mongreldb.com/articles/tags/architecture/</link><description>Recent content in Architecture 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>Wed, 15 Jul 2026 09:00:00 -0500</lastBuildDate><atom:link href="https://www.mongreldb.com/articles/tags/architecture/index.xml" rel="self" type="application/rss+xml"/><item><title>Embedded and Server from the Same Code Paths</title><link>https://www.mongreldb.com/articles/2026/07/embedded-and-server-from-the-same-code-paths/</link><pubDate>Wed, 15 Jul 2026 09:00:00 -0500</pubDate><guid>https://www.mongreldb.com/articles/2026/07/embedded-and-server-from-the-same-code-paths/</guid><description>One Rust crate runs both the embedded library and the server daemon, and the seam between them is narrower than you would guess, which is the whole point.</description><content:encoded><![CDATA[<p>Most databases pick a lane early, and the lane is usually visible in the crate structure: you either ship an embedded engine that runs in-process with the application, or you ship a server that listens on a socket and speaks a wire protocol, and once that decision is made the two codebases diverge fast because the constraints are genuinely different. The embedded engine worries about memory boundaries, crash semantics when the host process dies, and file locking against other processes that might touch the same database file. The server worries about connection handling, authentication, concurrent sessions, and wire serialization. These look like the same problem from a distance, which is &ldquo;store and retrieve data,&rdquo; but the engineering pressures pull in opposite directions, and most projects resolve the tension by forking the codebase or by building one mode as a thin wrapper around the other. We did neither, and the reason is that the wrapper approach leaks abstraction at exactly the points where you need it least.</p>
<h2 id="why-the-split-usually-happens">Why the split usually happens</h2>
<p>The conventional wisdom, which is correct for most projects, is that an embedded database and a server database serve different enough audiences that splitting the crate reduces complexity for each audience. SQLite is embedded-only; it runs in your process, touches your memory space, and the only IPC is the filesystem. PostgreSQL is server-only; it runs as a daemon, manages its own process tree, and the only way to talk to it is through the wire protocol. Both are extremely good at what they do, and part of the reason is that neither one carries the baggage of the other mode.</p>
<p>When you try to do both in one codebase, the pressure to split shows up in the storage layer first, because an embedded engine wants direct memory access to pages and a server engine wants a page cache that is isolated from client memory, and those are different interfaces even if the underlying page format is the same. Then the pressure moves to transactions: an embedded engine can use thread-local state for the current transaction, while a server needs a session-scoped transaction context that survives across requests. By the time you reach the query executor, you are passing around enough context objects that the embedded path is paying a tax it does not need, and the server path is calling through indirections that an embedded call would skip.</p>
<h2 id="the-seam-we-picked-instead">The seam we picked instead</h2>
<p>The decision we made early was that the engine crate, the thing that owns pages, WAL, transactions, indexes, and the query executor, is mode-agnostic, and the mode is applied at the boundary by the caller. When you load MongrelDB as an embedded library, you call the engine directly through a Rust function interface, and the transaction context lives in your thread. When you run <code>mongreldb-server</code>, the same engine crate is linked into the daemon binary, but there is a session layer between the HTTP handler and the engine call, and that session layer is the only code that knows about connections, authentication, and wire serialization.</p>
<p>The seam is narrow on purpose. It is roughly 800 lines of Rust that translate between &ldquo;an HTTP request arrived with a SQL body and a session cookie&rdquo; and &ldquo;call <code>engine::execute(sql, tx_context)</code> with the right transaction handle.&rdquo; That translation layer is the entire server-specific code path; everything below it, from the parser down to the page cache, is shared without branching on mode.</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:#75715e">// The engine trait that both modes call. No mode flag, no feature gate.
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">pub</span> <span style="color:#66d9ef">trait</span> Engine {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">fn</span> <span style="color:#a6e22e">execute</span>(<span style="color:#f92672">&amp;</span>self, sql: <span style="color:#66d9ef">&amp;</span><span style="color:#66d9ef">str</span>, ctx: <span style="color:#66d9ef">&amp;</span><span style="color:#a6e22e">TxContext</span>) -&gt; Result<span style="color:#f92672">&lt;</span>ResultSet<span style="color:#f92672">&gt;</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">fn</span> <span style="color:#a6e22e">begin</span>(<span style="color:#f92672">&amp;</span>self, opts: <span style="color:#a6e22e">TxOptions</span>) -&gt; Result<span style="color:#f92672">&lt;</span>TxContext<span style="color:#f92672">&gt;</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">fn</span> <span style="color:#a6e22e">commit</span>(<span style="color:#f92672">&amp;</span>self, ctx: <span style="color:#a6e22e">TxContext</span>) -&gt; Result<span style="color:#f92672">&lt;</span>()<span style="color:#f92672">&gt;</span>;
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// Embedded: call directly on the concrete type. Monomorphized, no vtable.
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">let</span> engine: <span style="color:#a6e22e">MongrelDB</span> <span style="color:#f92672">=</span> MongrelDB::open(path)<span style="color:#f92672">?</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">let</span> tx <span style="color:#f92672">=</span> engine.begin(TxOptions::default())<span style="color:#f92672">?</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">let</span> result <span style="color:#f92672">=</span> engine.execute(<span style="color:#e6db74">&#34;SELECT * FROM users WHERE id = 1&#34;</span>, <span style="color:#f92672">&amp;</span>tx)<span style="color:#f92672">?</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// Server: the session layer holds the engine as dyn Engine behind a
</span></span></span><span style="display:flex;"><span><span style="color:#75715e">// trait object, because the session does not know the concrete type;
</span></span></span><span style="display:flex;"><span><span style="color:#75715e">// it only knows it has something that implements Engine.
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">async</span> <span style="color:#66d9ef">fn</span> <span style="color:#a6e22e">handle_sql</span>(req: <span style="color:#a6e22e">HttpRequest</span>, engine: <span style="color:#66d9ef">&amp;</span><span style="color:#a6e22e">dyn</span> Engine) -&gt; <span style="color:#a6e22e">HttpResponse</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">let</span> session <span style="color:#f92672">=</span> req.session();
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">let</span> tx <span style="color:#f92672">=</span> session.current_tx_or_begin(engine)<span style="color:#f92672">?</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// execute is blocking; the server wraps it in spawn_blocking so the
</span></span></span><span style="display:flex;"><span>    <span style="color:#75715e">// async runtime is not stalled while the query runs.
</span></span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">let</span> result <span style="color:#f92672">=</span> tokio::task::spawn_blocking(<span style="color:#66d9ef">move</span> <span style="color:#f92672">||</span> {
</span></span><span style="display:flex;"><span>        engine.execute(<span style="color:#f92672">&amp;</span>req.body(), <span style="color:#f92672">&amp;</span>tx)
</span></span><span style="display:flex;"><span>    }).<span style="color:#66d9ef">await</span><span style="color:#f92672">??</span>;
</span></span><span style="display:flex;"><span>    session.maybe_commit(tx)<span style="color:#f92672">?</span>;
</span></span><span style="display:flex;"><span>    HttpResponse::json(result)
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>The embedded caller never touches the session layer, the session layer never touches the filesystem directly, and neither side has a feature flag that conditionally compiles the other side out. Both paths exist in the same binary, and the optimizer handles dead-code elimination if you link the crate as a library and never spin up the HTTP listener.</p>
<h2 id="what-this-buys-you-in-practice">What this buys you in practice</h2>
<p>The first thing it buys you is testability, because every test you write against the engine trait runs identically in embedded mode and in server mode, and the only difference is whether there is an HTTP round-trip in the middle. Cross-language conformance tests for Kit, the SDK layer, all hit the server endpoint, but the expected results are generated by the embedded engine running in the test harness, which means a mismatch between the two is a real bug, not a test artifact. This is the kind of thing that sounds like a nice-to-have until you have shipped a database where the embedded path and the server path disagree about, say, how <code>NULL</code> sorts in a composite index, and then it becomes the thing you wish you had built from day one.</p>
<p>The second thing it buys you is deployment flexibility, because the engine crate that the daemon binary links can also be linked as a library into a CLI tool, a test runner, or an edge function, with no recompilation and no feature flags. The MongrelDB CLI is the server binary with a different entry point; it loads the engine, runs a query, prints the result, and exits, without ever binding a socket. The migration runner in Kit does the same thing, because it needs to apply migrations to a database file that might be local or remote, and the difference is just whether the engine handle came from <code>MongrelDB::open(path)</code> or from <code>MongrelDB::connect(url)</code>.</p>
<h2 id="the-cost-which-is-real">The cost, which is real</h2>
<p>The cost is that the server path routes every engine call through a trait object, which means a vtable lookup on every query, while the embedded path calls the concrete type directly and monomorphizes to static dispatch with no indirection. In the server path, where you are doing thousands of point lookups per second across concurrent sessions, that indirection is measurable at roughly 2 to 3 nanoseconds per call on modern hardware, which is below the noise floor for anything except a synthetic benchmark but is there, and a server that inlined its dispatch would not pay it. We judged this acceptable because the query parser, planner, and executor dominate the cost of any non-trivial query, and the vtable indirection vanishes into the overhead of actual work; the embedded path, meanwhile, pays nothing because the compiler sees the concrete type and devirtualizes the call.</p>
<p>The second cost is that the engine crate has to be disciplined about not reaching for session-level concepts, because once the storage layer knows about sessions, the embedded path starts carrying server baggage. This is a code review constraint, not a runtime cost, but it is the kind of architectural discipline that erodes if you are not watching for it; every PR that tries to sneak a session reference into the engine module is a PR that needs to be pushed back on, and the pushback is worth it because the seam is the entire reason the design works.</p>
<h2 id="the-modern-equivalent">The modern equivalent</h2>
<p>In 2008 the equivalent decision was choosing between linking against <code>libsqlite3</code> and running <code>mysqld</code>, and the two worlds did not overlap because the engineering cost of supporting both modes in one codebase was higher than the benefit, given the hardware and the languages available at the time. Rust changes the math, because trait objects, zero-cost abstractions, and a borrow checker that enforces lifetime discipline make it possible to share code across modes without the runtime cost that a C or C++ codebase would pay, and the result is a database that can be embedded in your application today and deployed as a server tomorrow without changing a line of application code. That is the goal, and so far the seam is holding.</p>
]]></content:encoded></item></channel></rss>