<?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>Wal on MongrelDB</title><link>https://www.mongreldb.com/articles/tags/wal/</link><description>Recent content in Wal 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>Fri, 17 Jul 2026 09:30:00 -0500</lastBuildDate><atom:link href="https://www.mongreldb.com/articles/tags/wal/index.xml" rel="self" type="application/rss+xml"/><item><title>WAL Fast-Commit: One fsync, One Transaction</title><link>https://www.mongreldb.com/articles/2026/07/wal-fast-commit-one-fsync-one-transaction/</link><pubDate>Fri, 17 Jul 2026 09:30:00 -0500</pubDate><guid>https://www.mongreldb.com/articles/2026/07/wal-fast-commit-one-fsync-one-transaction/</guid><description>MongrelDB commits a transaction with a single fsync on the WAL, because the WAL is the only part of durable storage that has to be synchronous.</description><content:encoded><![CDATA[<p>Durability in a database usually looks like a wall of fsyncs, and every fsync is a synchronous disk round-trip that your application pays whether the row is large or small, whether the table is hot or cold, and whether the query touched one page or fifty. The observation that matters is that only one of those writes actually has to survive a crash for the whole transaction to be recoverable, and everything else can follow later; that is the entire idea behind write-ahead logging, and MongrelDB pushes it to the extreme by making the WAL append the only synchronous write in the commit path.</p>
<h2 id="what-one-fsync-buys-you">What one fsync buys you</h2>
<p>A transaction in MongrelDB commits when its records are appended to the WAL and that WAL segment is fsync&rsquo;d to disk, full stop. The data pages, index pages, and column metadata are updated in memory first and flushed to disk afterwards by a background writer, but they do not block the commit. If the process crashes right after the fsync, recovery replays the WAL from the last checkpoint and reconstructs the same pages; if the process crashes before the fsync, the transaction never happened. There is no intermediate state where the database is confused, because the WAL is the only source of truth that matters at crash time.</p>
<p>The latency win is straightforward. A single fsync on a sequential WAL append is one disk seek on a rotating drive or one flush on an SSD, and on modern NVMe it is typically sub-millisecond. Writing and fsyncing the actual data pages would mean one or more random writes per modified page, each with its own rotational latency or flash write-amplification cost, and those costs multiply quickly on a transaction that touches many rows or many indexes. By separating &ldquo;must survive crash&rdquo; from &ldquo;nice to have on disk,&rdquo; the commit path stays flat even when the transaction is large.</p>
<p>Throughput benefits too, because group commit can share one fsync across multiple transactions. When several transactions are ready to commit at the same time, MongrelDB batches their WAL records into the same segment and pays one fsync for the batch instead of one per transaction. The published <code>cargo bench -p mongreldb-core --bench write_path</code> numbers show group commit at roughly 686 µs for 1,000 rows, which is the fsync cost amortized across many writers; the single-row no-fsync <code>put</code> is around 618 ns, and the durable <code>commit</code> is around 6.79 µs, because that measurement is the fsync-bound path. The difference between a single-row commit and a group commit is how many transactions ride inside that single disk barrier.</p>
<h2 id="what-the-rest-of-the-storage-layer-does-asynchronously">What the rest of the storage layer does asynchronously</h2>
<p>Data pages are written out by a background thread that watches dirty-page counts and checkpoint intervals, and the rule it follows is simple: keep the WAL from growing forever, but never let a page reach disk before the WAL record that describes its change. That ordering is what makes recovery possible; if a data page were written before its WAL record, a crash between the two would leave an updated page with no log to explain it. The background writer respects the log-sequence-number ordering, so pages are flushed only after their covering WAL records are durable.</p>
<p>Checkpoints are the mechanism that bounds recovery time. A checkpoint marks a point in the WAL before which all dirty pages are known to be on disk, which means recovery only has to replay from the last checkpoint rather than from the beginning of time. MongrelDB checkpoints on a configurable interval and on dirty-page thresholds, and the checkpoint itself is a lightweight metadata write; the heavy work of flushing pages happens continuously in the background, so checkpoint stalls are small.</p>
<h2 id="the-tradeoffs-that-come-with-this-design">The tradeoffs that come with this design</h2>
<p>Recovery time is the obvious one. Because data pages are not necessarily on disk at commit time, a crash means replaying every WAL record since the last checkpoint, and the length of that replay is the product of your write rate and your checkpoint interval. On a write-heavy workload with a long checkpoint interval, recovery can take seconds or minutes, which is fine for most applications but unacceptable for a system that needs sub-second restart. The fix is shorter checkpoints, but shorter checkpoints mean more foreground disruption and more write amplification; that is the standard WAL checkpoint tradeoff and MongrelDB does not pretend to have escaped it.</p>
<p>The second tradeoff is that the single fsync is still the latency floor for synchronous commits, and on a filesystem or storage stack with slow fsync behavior, that floor is visible. Some filesystems stall fsyncs while other writes drain, some virtualized disks lie about fsync completion, and some network-attached storage introduces multi-millisecond latency on every barrier. MongrelDB cannot make a bad disk fast, but it can make sure you only pay that cost once per transaction rather than once per page.</p>
<p>The third tradeoff is that group commit adds jitter. A transaction that is ready to commit just after a group has been dispatched waits for the next fsync window, and that wait is bounded by the group-commit timeout. For latency-sensitive single-row writes, the default timeout is short enough that the jitter is small; for throughput-bound workloads, the batching is the whole point. The settings are exposed if you need to tune them, but the default is biased toward the common case where one slow fsync is worse than a few microseconds of scheduling delay.</p>
<h2 id="why-this-is-the-modern-equivalent-of-an-old-pattern">Why this is the modern equivalent of an old pattern</h2>
<p>In the simpler embedded engines of that era, the way to get durability was often to open the data file with <code>O_SYNC</code> or <code>O_DIRECT</code> and pay the synchronous write cost on every page, because the code path was simpler and the gap between WAL and direct-write was smaller on the hardware available then. Modern equivalent is WAL fast-commit: the same crash-recovery guarantee, but the synchronous work is concentrated in one sequential append instead of scattered across random writes, and the storage layer can schedule the rest of the work when the disk is least busy. The guarantee did not change; the shape of the work did.</p>
]]></content:encoded></item><item><title>FM-Index Full-Text Search in a Transactional Engine</title><link>https://www.mongreldb.com/articles/2026/07/fm-index-full-text-search-in-a-transactional-engine/</link><pubDate>Thu, 09 Jul 2026 12:30:00 -0500</pubDate><guid>https://www.mongreldb.com/articles/2026/07/fm-index-full-text-search-in-a-transactional-engine/</guid><description>MongrelDB&amp;#39;s full-text search uses an FM-index, not the inverted index you usually see, because the FM-index lives in the same WAL as the rows it covers.</description><content:encoded><![CDATA[<p>Inverted indexes and transactional writes do not coexist easily, and that seam is what pushes most database vendors toward eventually-consistent search sidecars, search daemons, or CDC pipelines that resync the index out of band. We wanted to skip the seam entirely, which meant picking a full-text index structure that the same WAL could cover at the same fsync as the rows it covered, and the FM-index (Ferragina-Manzini, 2000) was the data structure that made the cut.</p>
<h2 id="what-an-fm-index-is-briefly">What an FM-index is, briefly</h2>
<p>A Burrows-Wheeler transform turns text into something that compresses as well as the same text runs through gzip, because the BWT groups repeated substrings the same way. The FM-index adds enough suffix-sampling metadata on top of the BWT that you can test whether a pattern is a substring by walking the BWT&rsquo;s first-column matrix one character at a time, without scanning the original text at all. The asymptotic shape is <code>O(|pattern|)</code> per query against a corpus that is highly compressed on disk.</p>
<p>Inverted indexes, by contrast, store the original tokens uncompressed, hash each token to a postings list, and answer a query by intersecting those postings lists in constant time per term. They are the right choice when your data is largely read-only, your queries are repeated, and you can afford to rebuild the index out of band after writes. They are not the right choice for a transactional engine that wants the index to participate in the same <code>commit</code> as the rows.</p>
<h2 id="what-the-fm-index-bought-us-at-the-storage-layer">What the FM-index bought us at the storage layer</h2>
<p>Two things specifically.</p>
<p>The first is write-path simplicity. An inverted index wants to be immutable between commits, which is why transactional inverted indexes need separate WAL coordination, deferred rebuilds, or row-level deltas stitched onto an immutable postings structure. An FM-index in MongrelDB is materialized as a column-level rewrite, batched with the rows it covers, fsync&rsquo;d through the same WAL, and rolled back by not rewriting it; the index has no separate consistency model because there is no separate index state to coordinate.</p>
<p>The second is the readability of the data on disk. The same BWT that makes the FM-index queryable is the same representation that compresses text well, and MongrelDB&rsquo;s storage layer already pays for compressed column layouts in the rest of the engine; the search side gets that compression benefit alongside the rest of the engine&rsquo;s columnar storage without a separate index cost.</p>
<p>That is the trade we wanted: one source of truth, one consistency model, one wire format. The FM-index was the data structure that gave us all three; the inverted index would have given us one and a half and required a separate subsystem for the half.</p>
<h2 id="what-we-had-to-give-up-to-get-there">What we had to give up to get there</h2>
<p>Build cost. Inverted indexes get built incrementally as documents are inserted, and the index is correct after every <code>INSERT</code>. The FM-index is meaningful only over a corpus; rebuilding it across a single row is wasteful. MongrelDB batches FM-index rewrites across table-rewrite checkpoints, with the same <code>IndexBuildPolicy::Deferred</code> default the SQL indexes use; this means write amplification on full-text-heavy workloads is higher than on inverted-index engines, and first-query-after-batch latency can spike when a checkpoint has not been built yet. The benchmark numbers are honest about this; the architecture is honest about why.</p>
<p>Pure-pattern ranking. The FM-index gives you containment (does the pattern appear?), not relevance (how much does it matter?). BM25 ranking is available via the <code>mongreldb_fts_rank(text, query)</code> UDF, which computes simplified BM25 from per-row term frequency and approximated corpus statistics at query time; accurate BM25-with-IDF lives in the separate <code>fts_docs</code> virtual table, which is the inverted-index path. That is a hybrid shape, not pure FM-index purity, and we note it because a search-engine purist would.</p>
<h2 id="where-it-shows-up-in-the-wire-format">Where it shows up in the wire format</h2>
<p><code>WHERE body LIKE '%phrase%'</code> in raw SQL, <code>-&gt;whereFts(...)</code> in the Kit query builder, and <code>where('fm', ...)</code> in the PHP fluent builder all dispatch to the same FM-index path through <code>/kit/query</code>. There is no separate search endpoint to point your app at, no separate consistency model to reason about, and no eventual-index lag to defend against in application code. A search query joined against the same <code>WHERE</code> clauses you would write for a regular typed column looks like:</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-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">SELECT</span> id, title
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">FROM</span> articles
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">WHERE</span> body <span style="color:#66d9ef">LIKE</span> <span style="color:#e6db74">&#39;%rust bwt fts%&#39;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">AND</span> published_at <span style="color:#f92672">&gt;</span> <span style="color:#e6db74">&#39;2026-01-01&#39;</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">ORDER</span> <span style="color:#66d9ef">BY</span> mongreldb_fts_rank(body, <span style="color:#e6db74">&#39;rust bwt fts&#39;</span>) <span style="color:#66d9ef">DESC</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">LIMIT</span> <span style="color:#ae81ff">20</span>;
</span></span></code></pre></div><p>Same transactional commit at the end of the statement, same WAL coverage for the index update on any <code>INSERT</code> to <code>articles</code>, and the same single-engine consistency model for both the row and the search result.</p>
<h2 id="when-this-is-the-wrong-tradeoff">When this is the wrong tradeoff</h2>
<p>If you are running a pure search-engine workload with no transactional semantics on the indexed data, with hot-write patterns the FM-index chokes on, and with strict sub-millisecond p99 on very short queries, an inverted-index engine tuned for that workload is still faster than what an FM-index on a transactional engine gives you; we did not pretend otherwise when we built it. What we wanted was transactional full-text search on the same engine as the rest of your data, on the same wire format, and the FM-index was the right way to spend the storage budget to get it.</p>
]]></content:encoded></item><item><title>Sub-Millisecond HNSW at the Storage Layer</title><link>https://www.mongreldb.com/articles/2026/07/sub-millisecond-hnsw-at-the-storage-layer/</link><pubDate>Thu, 09 Jul 2026 12:30:00 -0500</pubDate><guid>https://www.mongreldb.com/articles/2026/07/sub-millisecond-hnsw-at-the-storage-layer/</guid><description>MongrelDB&amp;#39;s HNSW index lives in the same engine as the rows it covers, so vector search shares the same WAL, transaction, and /kit/query path as the rest of your data.</description><content:encoded><![CDATA[<p>Vector search usually means operating a second system, and that means writing the same row twice: once to the database, once to the vector service, and the two writes have to look like one through some pattern of outbox tables, CDC consumers, or dual-write transactions. We chose to skip the second system; the engine runs HNSW in-process, the same WAL that protects your rows protects your vector index, and the same transaction model that commits an <code>UPDATE</code> on a <code>documents</code> row commits the corresponding HNSW entry for that row.</p>
<h2 id="what-in-process-actually-buys-you">What &ldquo;in-process&rdquo; actually buys you</h2>
<p>Two things specifically, and they are the same two things every in-process database call gets when the network is gone.</p>
<p>The first is a round trip. A single HTTP request from any Tier 2 client to a <code>mongreldb-server</code> over loopback measures p50 ~0.22 ms / p99 ~0.62 ms at ~4,000 ops/s for <code>PUT /tables/{name}/put</code> and <code>POST /kit/txn</code> commit on the published <a href="https://github.com/visorcraft/MongrelDB/blob/main/BENCHMARKS.md"><code>BENCHMARKS.md</code></a>; the equivalent call into the engine through the in-process Tier 1 binding (Rust, PyO3 for Python, NAPI for TypeScript / Node) drops the per-call cost into single-digit microseconds, with the same <code>6.79 µs commit</code> (group-committed, fsync&rsquo;d) bound from the write-path numbers in the same file. The network is the dominant cost in the Tier 2 path; <code>axum</code> handler dispatch, JSON encode/decode, and the TCP round trip add up, while the engine work underneath is sub-10 µs.</p>
<p>The second is transactional consistency. The HNSW graph and the row it covers get the same atomic-write semantics; either both are durable, or both are not. There is no &ldquo;the database committed but the vector index lagged&rdquo; state for the engine to defend against because the engine is one engine. CDC pipelines, outbox consumers, and dual-write coordinators exist in application code only because two systems need to look like one; they stop existing when the two systems collapse back into one.</p>
<h2 id="the-actual-numbers">The actual numbers</h2>
<p>The HNSW graph is a column-level index in the same engine files as the rows it indexes. A vector insert inside a transaction rides the same single-digit-microsecond <code>put</code> path the published <code>cargo bench -p mongreldb-core --bench write_path</code> numbers report (<code>put</code> no-fsync at <code>618 ns</code>, <code>commit</code> fsync at <code>6.79 µs</code>, group commit at <code>686 µs</code> for 1,000 rows). A <code>k</code>-nearest-neighbour query in the same transaction rides the same sub-10 µs filter path the cold-SQL-filter number (<code>8.7 µs</code> at N=1M) tracks. The HTTP loopback path costs the same 0.22 ms p50 / 0.62 ms p99 as every other Tier 2 op, because the dominant cost in that path is the network, not the index; HNSW-specific benchmark numbers will land in <code>BENCHMARKS.md</code> when the HNSW build policy is committed, and we will not invent them in the meantime.</p>
<h2 id="what-this-looks-like-from-a-whereann--call">What this looks like from a <code>where('ann', ...)</code> call</h2>
<p>The fluent query builder in <code>mongreldb-php</code> pushes <code>where('ann', ...)</code> to the same HNSW index through the same <code>/kit/query</code> path as a regular typed column. A query for ten nearest neighbours of a 384-dimensional embedding against a <code>documents</code> table, filtered by <code>tags = 'engineering'</code>, looks like:</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-php" data-lang="php"><span style="display:flex;"><span>$rows <span style="color:#f92672">=</span> $db<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">table</span>(<span style="color:#e6db74">&#39;documents&#39;</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">where</span>(<span style="color:#e6db74">&#39;tags&#39;</span>, <span style="color:#e6db74">&#39;engineering&#39;</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">whereAnn</span>(<span style="color:#e6db74">&#39;embedding&#39;</span>, $vector, <span style="color:#a6e22e">k</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">10</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">get</span>();
</span></span></code></pre></div><p>That call, against a <code>mongreldb-server</code> running on the same host, is one HTTP round trip; against an in-process Tier 1 binding it is one function call into <code>mongreldb-core</code>. Both paths return the same rows with the same transaction isolation; the only difference is the round trip.</p>
<h2 id="when-this-is-the-wrong-shape">When this is the wrong shape</h2>
<p>If your workload has a single vector index that already exceeds what fits in the memory of one server, and you genuinely need sharded vector search across dozens of machines, an HNSW graph that lives inside one engine process is the wrong shape; you want a vector service that is built to shard, and we will not pretend otherwise. One engine process means one HNSW graph in memory, and that is the limit on a single-machine deployment. What you get by staying inside one process is that the row that gets updated with a new title is the same transaction that updates the row&rsquo;s vector, the same <code>WHERE</code> clause that filters by <code>tags = 'engineering'</code> also filters by approximate-nearest-neighbour distance, and the same <code>/healthz</code> endpoint that tells you the database is up also tells you the vector index is up; for most applications that is a much better set of defaults than another microservice to operate, and when a workload outgrows it the same client code paths through the same wire format keep working against a vector service with no application-side rewrite.</p>
]]></content:encoded></item></channel></rss>