<?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>Cdc on MongrelDB</title><link>https://www.mongreldb.com/articles/tags/cdc/</link><description>Recent content in Cdc 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/cdc/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></channel></rss>