<?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>Embedded on MongrelDB</title><link>https://www.mongreldb.com/articles/tags/embedded/</link><description>Recent content in Embedded 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, 22 Jul 2026 09:00:00 -0500</lastBuildDate><atom:link href="https://www.mongreldb.com/articles/tags/embedded/index.xml" rel="self" type="application/rss+xml"/><item><title>CDC and Replication in an Embedded Database</title><link>https://www.mongreldb.com/articles/2026/07/cdc-and-replication-in-an-embedded-database/</link><pubDate>Wed, 22 Jul 2026 09:00:00 -0500</pubDate><guid>https://www.mongreldb.com/articles/2026/07/cdc-and-replication-in-an-embedded-database/</guid><description>MongrelDB reconstructs change events from its own WAL and streams committed records to follower copies, so an embedded database can participate in replication and change data capture without bolting on a separate toolchain.</description><content:encoded><![CDATA[<p>Every embedded database story eventually runs into the same wall, because you start with a single process reading and writing a local file, and that works beautifully until the application grows a second process, a second machine, a read replica, or an analytics pipeline that wants to know what changed, and now you are shopping for Debezium, or standing up a Kafka cluster, or writing a polling loop that <code>SELECT *</code> every thirty seconds and diffs the results in application code, and the database that was supposed to be simple has become a distributed system assembled from parts that were never designed to work together. The reason that story repeats itself is not that embedded databases are bad at replication; it is that most of them were never built with replication in mind, so the change stream gets bolted on as an afterthought, reading the binlog or the WAL out of band, reverse-engineering the semantics of committed versus uncommitted state, and hoping the external tool gets the ordering right. MongrelDB takes the position that if you are going to ship a WAL anyway, and you are going to checkpoint it, and you are going to recover from it on restart, then you already have the raw material for both change data capture and replication, and the question is not whether to build them but whether to derive them from the same commit log or to maintain a separate one, and deriving is cheaper, more correct, and harder to get wrong.</p>
<h2 id="why-most-embedded-engines-stop-at-single-process">Why most embedded engines stop at single-process</h2>
<p>SQLite has WAL mode and it is genuinely good at what it does, but if you want change data capture you reach for the SQLite Update Hook or you parse the WAL file yourself, and neither of those gives you a durable, resumable stream with stable event identifiers that survive a restart; LevelDB and RocksDB expose iterators and you can tail the log, but the log is an internal artifact subject to compaction and the ordering guarantees are the iterator&rsquo;s problem, not yours, and the moment you want transactional semantics, where a batch of writes commits atomically and your downstream consumer sees the whole batch or nothing, you are back to application-level bookkeeping. The pattern across the embedded-database landscape is the same: the engine optimizes for local read and write throughput, the replication story is delegated to external tooling, and the application developer who needed a simple embedded database ends up running a three-component pipeline to get changes out of it.</p>
<h2 id="cdc-derived-from-the-wal-not-a-second-log">CDC derived from the WAL, not a second log</h2>
<p>MongrelDB&rsquo;s change data capture is not a separate log that the engine maintains alongside its data; it is a reconstruction of committed transactions from the same WAL that the engine uses for crash recovery, which means there is exactly one source of truth for what committed and in what order, and the CDC stream cannot diverge from the data because it is the data, just read differently. The API surface is a single function, <code>change_events_since(last_event_id)</code>, which replays the WAL records, correlates each <code>Op::TxnCommit</code> with its staged operations, groups them by transaction, and returns a <code>CdcBatch</code> containing one <code>ChangeEvent</code> per committed transaction with a stable ID shaped as <code>&lt;commit_epoch&gt;:&lt;operation_index&gt;</code>, so a consumer can persist the last ID it processed and resume from exactly there on the next poll without missing or duplicating a transaction.</p>
<p>The design choice that matters here is that aborted transactions never appear in the CDC stream, because the reconstruction logic keys on <code>Op::TxnCommit</code> records, and a transaction that rolled back never wrote one, which means your downstream consumer never sees a write that did not durably commit; this is the guarantee that external WAL tailers have to reconstruct for themselves, and they get it wrong in the gap between the WAL entry and the commit marker, where a half-applied transaction looks like data until you cross-reference the commit log and discover it was never finalized. The engine test suite exercises this by staging a put, rolling it back, and confirming the CDC stream contains no put events for that transaction:</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">let</span> <span style="color:#66d9ef">mut</span> transaction <span style="color:#f92672">=</span> db.begin();
</span></span><span style="display:flex;"><span>transaction.put(<span style="color:#e6db74">&#34;items&#34;</span>, <span style="color:#a6e22e">vec!</span>[(<span style="color:#ae81ff">1</span>, Value::Int64(<span style="color:#ae81ff">1</span>))]).unwrap();
</span></span><span style="display:flex;"><span>transaction.rollback();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">assert!</span>(db
</span></span><span style="display:flex;"><span>    .change_events_since(None)
</span></span><span style="display:flex;"><span>    .unwrap()
</span></span><span style="display:flex;"><span>    .events
</span></span><span style="display:flex;"><span>    .iter()
</span></span><span style="display:flex;"><span>    .all(<span style="color:#f92672">|</span>event<span style="color:#f92672">|</span> event.op <span style="color:#f92672">!=</span> <span style="color:#e6db74">&#34;put&#34;</span>));
</span></span></code></pre></div><p>A rolled-back put produces no CDC event, full stop, and that is not a filter applied after the fact but a consequence of the reconstruction logic skipping any WAL records that lack a matching commit.</p>
<h2 id="the-gap-signal-and-why-it-matters">The gap signal, and why it matters</h2>
<p>A derived CDC stream has one honest limitation that a separate log does not: the WAL segments are finite, and once the engine checkpoints and purges old segments to reclaim space, the records needed to reconstruct older events are gone. MongrelDB handles this by returning <code>gap = true</code> in the <code>CdcBatch</code> when the consumer&rsquo;s resume point falls before the oldest retained commit, which is the engine saying &ldquo;I cannot give you a continuous stream from where you left off, so do not pretend you have one&rdquo;; the correct consumer response is to rebootstrap, not to silently skip ahead, because a gap in a change stream is a data integrity problem, not a performance optimization. The retention is tunable via <code>set_replication_wal_retention_segments</code>, and the test suite verifies the gap behavior by writing data, checkpointing to purge segments, then resuming from a now-evicted position:</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>db.set_replication_wal_retention_segments(<span style="color:#ae81ff">1</span>);
</span></span><span style="display:flex;"><span><span style="color:#75715e">// ... write and checkpoint multiple transactions ...
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">let</span> resumed <span style="color:#f92672">=</span> db.change_events_since(Some(<span style="color:#f92672">&amp;</span>first_id)).unwrap();
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">assert!</span>(resumed.gap);
</span></span></code></pre></div><p>The bounds are generous but real: at most one million WAL records replayed, 256 MiB of replay bytes, 100,000 events, one million rows, and 256 MiB of total retained bytes, after which the engine fails closed rather than silently truncating, because a change stream that drops events without telling you is worse than no stream at all.</p>
<h2 id="replication-as-wal-streaming">Replication as WAL streaming</h2>
<p>The replication surface is the same idea at a different granularity, because instead of reconstructing logical change events, the follower receives raw committed WAL records over HTTP and replays them into its own local database directory, which means the follower&rsquo;s logical state at any given commit epoch matches the leader&rsquo;s, not a best-effort approximation; the physical bytes on disk may diverge once the follower independently checkpoints and compacts on its own schedule, but the data you can read at a shared epoch is identical. The daemon exposes <code>GET /wal/stream?since=&lt;seq&gt;</code>, the follower polls it, and the <code>ReplicationFollower</code> client in <code>mongreldb-client</code> wraps the whole loop:</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_client::ReplicationFollower;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">let</span> <span style="color:#66d9ef">mut</span> follower <span style="color:#f92672">=</span> ReplicationFollower::new(<span style="color:#e6db74">&#34;http://leader:8453&#34;</span>, <span style="color:#e6db74">&#34;/local/copy&#34;</span>)<span style="color:#f92672">?</span>;
</span></span><span style="display:flex;"><span>follower.sync()<span style="color:#f92672">?</span>;  <span style="color:#75715e">// fetch + apply all new records since last sync
</span></span></span><span style="display:flex;"><span><span style="color:#a6e22e">println!</span>(<span style="color:#e6db74">&#34;caught up to epoch </span><span style="color:#e6db74">{}</span><span style="color:#e6db74">&#34;</span>, follower.last_epoch());
</span></span></code></pre></div><p>The follower bootstraps from a snapshot if the local copy does not exist yet, refuses to overwrite a non-replica database directory, and advances its watermark epoch only after the records are durably appended and the local database has recovered, so a crash mid-sync does not corrupt the follower or leave it in a half-applied state; it simply resumes from the last confirmed epoch on the next <code>sync()</code> call. This is asynchronous leader-to-follower replication designed for read scaling and for warm standby copies, distinct from the synchronous Raft consensus replication that MongrelDB also supports in cluster mode, and the two coexist because they solve different problems: Raft gives you strong consistency across a quorum for write availability and zero-data-loss failover, while WAL streaming gives you a geographically distant read copy or an analytics replica without paying the latency of a cross-region quorum ack. The honest tradeoff is that async streaming has an RPO greater than zero, because writes the leader committed but had not yet streamed are lost if the leader dies before the next poll, and if you need RPO 0 that is what the Raft quorum path is for; the async follower is the right tool for read scaling and for a recovery target that tolerates a small amount of tail loss, not a substitute for consensus-backed durability.</p>
<h2 id="what-changes-and-what-stays-the-same">What changes and what stays the same</h2>
<p>The thing that changes when an embedded engine is also a replicating engine is that the WAL stops being a purely internal recovery artifact and becomes a public contract, because external consumers depend on its ordering, its commit semantics, and its retention window, and those properties now have to hold across versions and across restarts. The thing that stays the same is the commit path itself, because the engine does not do extra work on the write side to produce the CDC stream or feed the replication follower; it writes the same WAL records it would write anyway, and the consumers read them after the fact, which means there is no throughput tax on writes for the benefit of change data capture until somebody actually calls <code>change_events_since</code> or polls <code>/wal/stream</code>. The cost moves to the read side, where WAL reconstruction and network transfer do real work, and to the storage side, where WAL retention has to be wide enough to cover your consumers&rsquo; polling interval, and those are knobs you turn based on your actual workload rather than overhead you pay whether you use the feature or not.</p>
<h2 id="the-modern-equivalent-of-an-old-pattern">The modern equivalent of an old pattern</h2>
<p>In the MySQL world, if you wanted change data capture in 2010, you parsed the binlog, and if you wanted replication, you configured a replica and hoped the binlog position tracking held up across schema changes and version upgrades, and the binlog was a separate log from the InnoDB redo log, which meant there were two sources of truth for what committed and they could disagree in edge cases around crash recovery. MongrelDB collapses that to one log: the WAL is the redo log and the replication log and the CDC source, and there is no second log to keep in sync, no binlog position to lose, and no external connector to reverse-engineer your commit semantics, because the engine that writes the data is the same engine that tells you what changed.</p>
]]></content:encoded></item><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>