<?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>Engineering on MongrelDB</title><link>https://www.mongreldb.com/articles/categories/engineering/</link><description>Recent content in Engineering 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, 24 Jul 2026 06:00:00 -0500</lastBuildDate><atom:link href="https://www.mongreldb.com/articles/categories/engineering/index.xml" rel="self" type="application/rss+xml"/><item><title>Argon2id Credentials at the Storage Layer</title><link>https://www.mongreldb.com/articles/2026/07/argon2id-credentials-at-the-storage-layer/</link><pubDate>Fri, 24 Jul 2026 06:00:00 -0500</pubDate><guid>https://www.mongreldb.com/articles/2026/07/argon2id-credentials-at-the-storage-layer/</guid><description>Most database auth lives in middleware, which means every code path that skips the middleware skips the auth. MongrelDB stores Argon2id-hashed users and roles in the catalog and can enforce permissions on every transaction at the storage layer itself.</description><content:encoded><![CDATA[<p>Every breach postmortem I have ever read contains some variation of the same sentence, which is that the authentication layer was working exactly as designed, it was just standing at a door the attacker never walked through. The middleware checked the token, the endpoint validated the session, and then some second process, some forgotten admin route, some debugging script with a direct connection string, walked around all of it and read the file anyway. That is the structural problem with middleware-only auth: it protects the paths you remembered to put middleware on, and it says nothing about the paths you did not, and in a database that can be opened embedded by any process that can read the directory, the set of paths you did not protect is larger than you think. MongrelDB&rsquo;s answer is to push the credential check down into the storage layer, so that opening the database without valid credentials fails no matter which door you came through, and that decision has some interesting consequences worth walking through.</p>
<h2 id="why-middleware-auth-is-the-wrong-layer-for-a-database-file">Why middleware auth is the wrong layer for a database file</h2>
<p>The classic shape, and I wrote this shape myself in the <code>mysql_*</code> era more times than I want to admit, is that the application holds one shared database password, every request funnels through application code that checks the user&rsquo;s session before it touches the connection, and the database itself trusts anyone who shows up with the one password it knows. That model survives contact with production exactly as long as every path to the data goes through the application, which is true right up until someone opens a REPL against the production file, or a second service gets added with its own connection logic, or a backup script starts reading raw pages, and at that point your permission model is a comment in a wiki page rather than a property of the system.</p>
<p>Middleware auth also has a layering problem that gets worse the more surfaces a database exposes, because MongrelDB is not just a daemon; it is an embedded library, a CLI, a set of SDKs in a dozen languages, and an HTTP server, all over the same on-disk format, and if auth lives in the HTTP layer then the embedded open path has no auth at all unless you build a second auth system for it, and now you have two systems to keep honest instead of one.</p>
<h2 id="users-and-roles-live-in-the-catalog">Users and roles live in the catalog</h2>
<p>MongrelDB stores users, roles, and grants inside the database catalog itself, the same <code>_meta</code> blob that holds table schemas, procedures, and triggers, so there is no separate auth file to back up, no external user database to keep in sync, and no drift between &ldquo;who the app thinks exists&rdquo; and &ldquo;who the engine thinks exists.&rdquo; Passwords are hashed with Argon2id at the OWASP-recommended parameters (19 MiB memory, time cost 2, parallelism 1), which is the same construction the engine uses to derive the key-encryption key for encrypted databases, and plaintext passwords are never stored or logged anywhere in the system.</p>
<p>The permission model is deliberately small: <code>All</code>, <code>Admin</code>, <code>Ddl</code>, and per-table <code>Select</code>, <code>Insert</code>, <code>Update</code>, and <code>Delete</code>, matched literally with no wildcard syntax, so <code>select:*</code> grants access to a table actually named <code>*</code> and nothing else. Two details are worth calling out because they are the ones people get wrong when they skim: a principal with the <code>is_admin</code> flag short-circuits every check, which is how the bootstrap admin works, and <code>Permission::All</code> explicitly does not imply <code>Admin</code>, so granting someone everything on every table still does not let them create users or hand out grants, which is the separation you want between &ldquo;can touch all the data&rdquo; and &ldquo;can mint new identities.&rdquo;</p>
<p>The SQL frontend speaks the standard vocabulary, so nothing here requires learning a bespoke control language:</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">CREATE</span> <span style="color:#66d9ef">USER</span> alice <span style="color:#66d9ef">WITH</span> PASSWORD <span style="color:#e6db74">&#39;s3cret-pw&#39;</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">CREATE</span> <span style="color:#66d9ef">ROLE</span> analyst;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">GRANT</span> <span style="color:#66d9ef">SELECT</span> <span style="color:#66d9ef">ON</span> orders <span style="color:#66d9ef">TO</span> analyst;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">GRANT</span> <span style="color:#66d9ef">INSERT</span> <span style="color:#66d9ef">ON</span> orders <span style="color:#66d9ef">TO</span> analyst;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">GRANT</span> analyst <span style="color:#66d9ef">TO</span> alice;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">SHOW</span> USERS;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">SHOW</span> ROLES;
</span></span></code></pre></div><h2 id="what-require_auth-actually-enforces">What require_auth actually enforces</h2>
<p>By default the catalog auth is advisory, meaning the engine stores users and permissions and your application can consult them with <code>check_permission</code>, but reads and writes are not gated, which keeps MongrelDB drop-in compatible with the SQLite-style &ldquo;open the file and go&rdquo; workflow. The interesting mode is opt-in per database: set <code>require_auth = true</code> in the catalog and the storage layer itself starts enforcing, which changes the failure semantics in a way middleware never can.</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;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// Create a credentialed database with a bootstrap admin.
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">let</span> db <span style="color:#f92672">=</span> Database::create_with_credentials(<span style="color:#e6db74">&#34;./secure_db&#34;</span>, <span style="color:#e6db74">&#34;admin&#34;</span>, <span style="color:#e6db74">&#34;s3cret-pw&#34;</span>)<span style="color:#f92672">?</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// Later: plain open fails with AuthRequired.
</span></span></span><span style="display:flex;"><span><span style="color:#75715e">// Database::open(&#34;./secure_db&#34;)?  -&gt; Err(AuthRequired)
</span></span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">let</span> db <span style="color:#f92672">=</span> Database::open_with_credentials(<span style="color:#e6db74">&#34;./secure_db&#34;</span>, <span style="color:#e6db74">&#34;alice&#34;</span>, <span style="color:#e6db74">&#34;alice-pw&#34;</span>)<span style="color:#f92672">?</span>;
</span></span><span style="display:flex;"><span><span style="color:#75715e">// Every Table, Transaction, and SQL operation on this handle is now
</span></span></span><span style="display:flex;"><span><span style="color:#75715e">// checked against alice&#39;s permissions. A missing grant returns
</span></span></span><span style="display:flex;"><span><span style="color:#75715e">// PermissionDenied from the engine, not from a wrapper.
</span></span></span></code></pre></div><p>With enforcement on, a <code>Table::put</code> requires <code>Insert</code> on that table, an SQL <code>UPDATE</code> requires both <code>Update</code> and <code>Select</code>, DDL and compaction require <code>Ddl</code>, user and role management requires <code>Admin</code>, and the daemon&rsquo;s HTTP auth middleware stops being the only line of defense, because the storage layer re-checks the resolved principal on every operation even after the middleware has already accepted the request. That is the defense-in-depth shape you actually want: the HTTP layer asserts who you are, and the engine enforces what that identity may do, so a bug in either layer alone does not silently open the whole file.</p>
<p>The error surface maps cleanly onto HTTP when the daemon is involved, which keeps client code boring: <code>AuthRequired</code> and <code>InvalidCredentials</code> come back as 401, <code>PermissionDenied</code> comes back as 403, and <code>AuthNotRequired</code> is a 400 for the case where you passed credentials to a database that does not want them.</p>
<h2 id="what-it-costs">What it costs</h2>
<p>The honest cost numbers are small and they sit in the right places. One Argon2id verification is tuned to land around 50 milliseconds on current server hardware, and you pay it once per open or once per authenticated HTTP request, after which the engine caches the resolved principal and every per-operation check is a cheap permission comparison against an <code>Arc</code>-cloned <code>AuthState</code> handle rather than another hash. The cached principal is worth one operational caveat: a revoked grant does not bite on an already-open handle until the next authentication, which is why the engine exposes <code>refresh_principal()</code> for the cases where you cannot wait for a reconnect, and why the daemon re-resolves the principal per request rather than per connection. The deliberate expense of the hash is the feature, not the overhead, because it caps online password guessing at roughly 20 attempts per second per core, though the v1 implementation does not add lockouts or throttling on top of that, so weak passwords remain weak passwords and no storage layer can fix that for you.</p>
<p>The composability story is the part I would have killed for in 2009: credential enforcement and page-level encryption are orthogonal, so <code>create_encrypted_with_credentials</code> gives you a database where the bytes on disk are AES-256-GCM ciphertext and the logical operations still require a catalog identity, and the two systems share nothing beyond the Argon2id primitive and the password that feeds it, which means a mistake in one does not weaken the other.</p>
<h2 id="what-it-does-not-stop">What it does not stop</h2>
<p>This is the section most vendor posts skip, and skipping it is how you end up with a CVE and a sheepish blog post. Credential enforcement is a logical access control, not a cryptographic one, so an attacker with raw disk access who parses the file format directly can read the data without ever calling the API, and an attacker who can write to the <code>_meta</code> directory can flip <code>require_auth</code> off or rewrite the catalog, which is why the docs tell you plainly that filesystem permissions on the database directory are the real boundary and why the recovery path for lost credentials is &ldquo;edit the catalog offline,&rdquo; a path that grants no power an attacker with disk access did not already have. If your threat model includes the disk getting pulled, you want the encryption layer too, and if your threat model includes the machine itself, you want full-disk encryption or hardware keys underneath all of it, because a database file, no matter how carefully it hashes its passwords, cannot defend the host it runs on.</p>
<p>The modern-equivalent close here is simple: we spent twenty years putting auth in front of databases because databases were big shared servers with one front door, and the embedded engine breaks that assumption by having as many doors as there are processes that can open the file, so the auth has to move to the one place every door leads through, which is the storage layer itself.</p>
]]></content:encoded></item><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>Idempotency Keys at the Storage Layer</title><link>https://www.mongreldb.com/articles/2026/07/idempotency-keys-at-the-storage-layer/</link><pubDate>Mon, 20 Jul 2026 09:00:00 -0500</pubDate><guid>https://www.mongreldb.com/articles/2026/07/idempotency-keys-at-the-storage-layer/</guid><description>MongrelDB binds idempotency keys into the commit log itself, so a retried write is safe across process crashes and restarts, not just across network drops.</description><content:encoded><![CDATA[<p>Every engineer who has run a payment form for more than a quarter knows the failure shape by heart, because the user double-clicks, the load balancer retries, the network stalls after the server committed but before the client saw the response, and now you have two rows where there should be one, and the only thing standing between you and a refund spreadsheet is whatever dedupe logic you remembered to bolt on in middleware. The reason that logic lives in middleware at all is a historical accident, not a design decision; databases were built under the assumption that the client would only ever send a write once, and the retry-safety problem got pushed upward into application code, message queues, and eventually HTTP itself, which is why RFC 9110 spends a page on idempotent methods and why Stripe-style <code>Idempotency-Key</code> headers became a de-facto standard for payment APIs while the storage engine underneath kept happily executing every duplicate it was handed. MongrelDB takes the opposite position: the key belongs at the storage layer, inside the commit path, where a retry is safe even if the process dies between the write and the response, because that is the only place where the dedupe record can share the same durability guarantee as the data it protects.</p>
<h2 id="why-middleware-idempotency-is-not-enough">Why middleware idempotency is not enough</h2>
<p>The middleware version of this pattern is well understood, and it is also where the pattern quietly breaks. A reverse proxy or an application-level interceptor can dedupe retries that pass through it, but it cannot dedupe a retry that arrives after the process crashed, because its dedupe table is usually in-memory or in a cache with weaker durability than the database itself, and a cache entry that vanishes on restart is a promise that expires at exactly the wrong moment. The worst case is the gap between commit and response, where the database has durably applied the write and the middleware never learned that fact; the client retries, the middleware has no record, the database executes again, and you are back to two rows. You can paper over this with a dedupe table inside the same database, and people do, but then you are hand-rolling transactional semantics around a unique constraint and hoping you got the race conditions right, which is precisely the kind of work a storage engine should be doing for you.</p>
<h2 id="what-mongreldb-does-instead">What MongrelDB does instead</h2>
<p>The core transaction API takes an optional idempotency request at commit time, carrying three things: the key itself, an owner string so two tenants can never collide on the same key, and a fingerprint of the request so the engine can tell a genuine retry apart from a key being reused for different work. When the transaction commits, the engine writes the idempotency record into a durable ledger that is anchored to the commit log, which means the key-to-receipt binding survives a restart under the same authority as the data pages, and an unkeyed commit never creates the ledger file at all, so you pay nothing for the feature when you do not use it.</p>
<p>The behavior on retry is the interesting part. The idempotency check runs inside the commit path before the transaction&rsquo;s writes are ever proposed to the log, so if a commit arrives with a key and fingerprint that already have a completed record, the engine does not re-apply the staged writes or append anything new; it replays the original commit receipt, the client sees the same transaction identity it would have seen the first time, and the row count stays exactly where the first commit left it. If a commit arrives with a key that exists but a different fingerprint, the engine aborts with a conflict error, because reusing a key for different work is a bug in the caller and the correct response is to refuse, not to guess. If the key has a time-to-live and the record has expired, the record is swept and the key becomes available again, which keeps the ledger bounded for workloads like checkout flows where the retry window is minutes, not days.</p>
<p>In the Rust core that looks like this, lifted from the engine&rsquo;s own test suite:</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> first <span style="color:#f92672">=</span> db.begin();
</span></span><span style="display:flex;"><span>first.put(<span style="color:#e6db74">&#34;t&#34;</span>, <span style="color:#a6e22e">vec!</span>[(<span style="color:#ae81ff">1</span>, Value::Int64(<span style="color:#ae81ff">1</span>))])<span style="color:#f92672">?</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">let</span> epoch <span style="color:#f92672">=</span> first.commit_idempotent(IdempotencyRequest {
</span></span><span style="display:flex;"><span>    key: <span style="color:#e6db74">&#34;checkout-42&#34;</span>.into(),
</span></span><span style="display:flex;"><span>    owner: <span style="color:#e6db74">&#34;store-api&#34;</span>.into(),
</span></span><span style="display:flex;"><span>    fingerprint: <span style="color:#a6e22e">request_fingerprint</span>,
</span></span><span style="display:flex;"><span>    ttl: None,
</span></span><span style="display:flex;"><span>})<span style="color:#f92672">?</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// Network drops, client retries the same logical request.
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">let</span> <span style="color:#66d9ef">mut</span> retry <span style="color:#f92672">=</span> db.begin();
</span></span><span style="display:flex;"><span>retry.put(<span style="color:#e6db74">&#34;t&#34;</span>, <span style="color:#a6e22e">vec!</span>[(<span style="color:#ae81ff">1</span>, Value::Int64(<span style="color:#ae81ff">2</span>))])<span style="color:#f92672">?</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">let</span> retry_epoch <span style="color:#f92672">=</span> retry.commit_idempotent(IdempotencyRequest {
</span></span><span style="display:flex;"><span>    key: <span style="color:#e6db74">&#34;checkout-42&#34;</span>.into(),
</span></span><span style="display:flex;"><span>    owner: <span style="color:#e6db74">&#34;store-api&#34;</span>.into(),
</span></span><span style="display:flex;"><span>    fingerprint: <span style="color:#a6e22e">request_fingerprint</span>,
</span></span><span style="display:flex;"><span>    ttl: None,
</span></span><span style="display:flex;"><span>})<span style="color:#f92672">?</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">assert_eq!</span>(retry_epoch, epoch);      <span style="color:#75715e">// same receipt, no re-execution
</span></span></span><span style="display:flex;"><span><span style="color:#a6e22e">assert_eq!</span>(row_count(<span style="color:#f92672">&amp;</span>db, <span style="color:#e6db74">&#34;t&#34;</span>), <span style="color:#ae81ff">1</span>);  <span style="color:#75715e">// one row, not two
</span></span></span></code></pre></div><p>The daemon layer builds the same guarantee into the HTTP surface, because a write that goes through <code>mongreldb-server</code> deserves the same crash safety as a write through the embedded API. Buffered SQL writes accept an <code>Idempotency-Key</code> header or an <code>idempotency_key</code> field in the JSON body, the daemon durably publishes an intent before it executes anything, and after a known terminal outcome it publishes a receipt, with a file fsync and atomic rename on Unix and write-through replacement on Windows. The awkward truth of that design is that the commit and the receipt cannot be one atomic operation across a filesystem and a database, so if the process dies in the narrow gap between them, the daemon refuses to guess; a retry with the same key gets a non-retryable <code>QUERY_OUTCOME_UNKNOWN</code>, the intent stays on disk as a tombstone, and an operator verifies the outcome before recovery, because auto-expiring that tombstone into a fresh execution is how you charge a credit card twice. Receipt and intent files hold HMAC-authenticated hashes, never the raw key or the SQL, and on encrypted databases the HMAC key is derived from the database&rsquo;s own key-encryption key, which means the idempotency store is authenticated by the same root of trust as the data itself and does not become a side channel.</p>
<h2 id="from-the-client-side">From the client side</h2>
<p>None of this matters if using it is painful, so the PHP client threads the key straight through the transaction surface, and the whole retry-safety story collapses into one extra argument at commit:</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>$txn <span style="color:#f92672">=</span> $db<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">beginTransaction</span>();
</span></span><span style="display:flex;"><span>$txn<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">put</span>(<span style="color:#e6db74">&#39;orders&#39;</span>, [<span style="color:#e6db74">&#39;id&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#ae81ff">1042</span>, <span style="color:#e6db74">&#39;total&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#ae81ff">9900</span>, <span style="color:#e6db74">&#39;status&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;paid&#39;</span>]);
</span></span><span style="display:flex;"><span>$txn<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">put</span>(<span style="color:#e6db74">&#39;inventory&#39;</span>, [<span style="color:#e6db74">&#39;sku&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;WIDGET-1&#39;</span>, <span style="color:#e6db74">&#39;on_hand&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#ae81ff">41</span>]);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// Safe to call again on timeout or connection drop:
</span></span></span><span style="display:flex;"><span>$results <span style="color:#f92672">=</span> $txn<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">commit</span>(<span style="color:#a6e22e">idempotencyKey</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;order-1042-payment&#39;</span>);
</span></span></code></pre></div><p>The <code>Transaction::commit()</code> signature takes the key as an optional parameter and drops it into the <code>idempotency_key</code> field of the batch payload, so the retry path in your application is just &ldquo;catch the connection exception and call the same code again,&rdquo; and the dedupe work happens where the fsyncs happen. The batch itself is atomic, so the order row and the inventory decrement commit together or not at all, and the idempotency record commits with them, which closes the gap that middleware cannot close.</p>
<h2 id="what-it-costs">What it costs</h2>
<p>The honest tradeoff is capacity and bookkeeping. The embedded engine&rsquo;s ledger is a single checksum-framed file, optionally sealed with the metadata key on encrypted databases, and it is bounded by two hard limits: at most 65,536 records, with the oldest committed records evicted first and in-flight reservations never evicted, and a 64 MiB cap on the file itself, beyond which the engine fails closed rather than risk a corrupted or replayed record. A key with no explicit TTL is kept until that bounded-size eviction takes it, so an embedded application that keys every write will eventually cycle the oldest records out, while a keyed write with a TTL sweeps on schedule. The daemon layer adds its own bounds on top, because one server may speak for many databases and many tenants: <code>MONGRELDB_SQL_IDEMPOTENCY_MAX_ENTRIES</code> (4,096 entries by default) caps the store, completed receipts expire after <code>MONGRELDB_SQL_IDEMPOTENCY_TTL_SECS</code> (24 hours by default, a safety bound rather than the expected retry window, which for a checkout flow is minutes), and each owner is capped at a quarter of the total capacity so one noisy tenant cannot starve the rest; when the store is full the daemon fails closed with a capacity error rather than evicting a receipt someone might still retry against. Crash-left intents consume capacity until an operator resolves them, which is deliberate, because an unresolved intent is an unresolved question about your data and the correct behavior is to keep asking it. There is also a small amount of extra fsync work per keyed write for the ledger mutation, which is real but bounded, and it is the price of making the retry guarantee survive power loss instead of merely surviving a TCP reset.</p>
<h2 id="the-modern-equivalent-of-an-old-habit">The modern equivalent of an old habit</h2>
<p>In the <code>mysql_*</code> era the standard advice was &ldquo;make your writes idempotent by design,&rdquo; which in practice meant a unique key on a natural column and an <code>INSERT ... ON DUPLICATE KEY UPDATE</code>, and it worked, but only for the shapes of write that fit a single-row upsert, and only if you remembered to design the table that way up front. Storage-layer idempotency keys are the same instinct generalized: instead of contorting your schema so duplicates are harmless, you name the logical request once and let the engine recognize the retry, whatever shape the write takes, whether it is one row or a forty-operation batch. The guarantee moved down the stack, which is where guarantees like this have always belonged.</p>
]]></content:encoded></item><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>PHP 8.4 Features We Use (and PHP 8.5 Features We Now Use)</title><link>https://www.mongreldb.com/articles/2026/07/php-8-4-features-we-use-and-php-8-5-features-we-look-forward-to/</link><pubDate>Thu, 16 Jul 2026 09:00:00 -0500</pubDate><guid>https://www.mongreldb.com/articles/2026/07/php-8-4-features-we-use-and-php-8-5-features-we-look-forward-to/</guid><description>PHP 8.4 is the floor of the pure PHP client, and PHP 8.5, released in November 2025, is where the language gives us enough to stop writing workarounds for cURL and readonly cloning.</description><content:encoded><![CDATA[<p>Every time a database client requires a C extension, a deployment story dies, because shared hosts disable <code>phpize</code>, CI images mismatch the ABI, and a developer somewhere is reading a PECL error at 11 PM instead of shipping a feature. That is the entire reason <code>visorcraft/mongreldb-php</code> is pure PHP, and the only way pure PHP works as a strategy is if the language gives you enough concrete features that you do not need to reach for native code to build a database client that feels modern. PHP 8.4 is the floor we ship on today, and PHP 8.5, released in November 2025, is the version that removes several of the remaining workarounds. This post is the tour of the features we use from 8.4, followed by the ones we are now adopting from 8.5, because the difference between a language version and a product decision is smaller than people pretend.</p>
<h2 id="asymmetric-visibility-and-the-shape-of-the-client">Asymmetric visibility and the shape of the client</h2>
<p>The <code>Database</code> class is the front door of the client, and it carries internal state that callers should read but never set: the base URL, the default transport, the current session token, and a few cached metadata objects. Before PHP 8.4, the honest choices were either public readonly properties, which expose the value for reading but still let anyone assign it during construction if the constructor is inside the class, or a pile of private properties with boilerplate getters that exist only to say &ldquo;this is not for you.&rdquo; Asymmetric visibility fixes this by letting the property be <code>public private(set)</code>, which means the world can read it and only the class can write it after construction, so internal mutation in methods like <code>withToken</code> still works while external callers are frozen out. This is exactly what a <code>Database</code> object needs.</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><span style="color:#66d9ef">final</span> <span style="color:#66d9ef">class</span> <span style="color:#a6e22e">Database</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">__construct</span>(
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">private</span>(<span style="color:#a6e22e">set</span>) <span style="color:#a6e22e">string</span> $baseUrl,
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">private</span>(<span style="color:#a6e22e">set</span>) <span style="color:#a6e22e">TransportInterface</span> $transport,
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">private</span> <span style="color:#f92672">?</span><span style="color:#a6e22e">string</span> $sessionToken <span style="color:#f92672">=</span> <span style="color:#66d9ef">null</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:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">withToken</span>(<span style="color:#a6e22e">string</span> $token)<span style="color:#f92672">:</span> <span style="color:#66d9ef">static</span>
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        $clone <span style="color:#f92672">=</span> <span style="color:#66d9ef">clone</span> $this;
</span></span><span style="display:flex;"><span>        $clone<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">sessionToken</span> <span style="color:#f92672">=</span> $token;       <span style="color:#75715e">// private property, fine here
</span></span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> $clone;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>This is not a cosmetic win; it is a stability win, because the public surface of the object now matches the contract we actually want, and there is no private getter a maintainer can forget to update when they rename a field. The client has a lot of value objects, <code>Column</code>, <code>Table</code>, <code>ResultSet</code>, <code>QueryPlan</code>, and asymmetric visibility keeps them honest without turning the source code into Java.</p>
<h2 id="readonly-properties-and-the-transport-configuration">Readonly properties and the transport configuration</h2>
<p>PHP 8.1 gave us readonly properties, PHP 8.2 added readonly classes, and by PHP 8.4 they are mature enough that we use them as the default for any object that is effectively a record. A transport configuration, for example, is a bundle of timeouts, retry counts, and header defaults that should be immutable once constructed, because a live request is already in flight and changing its timeout halfway through is a great way to get a bug nobody can reproduce. The combination of a <code>readonly</code> class and constructor property promotion means the entire configuration object collapses to a few lines.</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><span style="color:#a6e22e">readonly</span> <span style="color:#66d9ef">class</span> <span style="color:#a6e22e">TransportConfig</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">__construct</span>(
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">public</span> <span style="color:#a6e22e">int</span> $connectTimeoutMs <span style="color:#f92672">=</span> <span style="color:#ae81ff">5000</span>,
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">public</span> <span style="color:#a6e22e">int</span> $requestTimeoutMs <span style="color:#f92672">=</span> <span style="color:#ae81ff">30000</span>,
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">public</span> <span style="color:#a6e22e">int</span> $maxRetries <span style="color:#f92672">=</span> <span style="color:#ae81ff">2</span>,
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">array</span> $defaultHeaders <span style="color:#f92672">=</span> [],
</span></span><span style="display:flex;"><span>    ) {}
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Readonly classes are not a free lunch, because <code>clone</code> becomes awkward and you need a <code>with</code> method or <code>clone</code> plus reassignment if you want a modified copy, but for transport config that is exactly what we want: the object is a snapshot, not a living document, and the database client treats it that way.</p>
<h2 id="array-find-functions-and-query-plan-navigation">Array find functions and query-plan navigation</h2>
<p>PHP 8.4 adds <code>array_find</code>, <code>array_find_key</code>, <code>array_any</code>, and <code>array_all</code>, and these are the kind of functions that sound trivial until you write the code that used to do the same thing with a loop and a flag. We hit them constantly when walking query-plan metadata, because a plan node is a nested array of operators, and we need to know whether any operator touches a vector index, or whether all operators are point lookups, or which node is the first one that references a JSON column. The old way was <code>array_filter(...)[0] ?? null</code> for find, and a hand-rolled <code>foreach</code> with <code>return false</code> for any/all, which is fine once but becomes noise when it appears twenty times in a client.</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><span style="color:#75715e">// Before: array_filter gives you all matches, then you take the first.
</span></span></span><span style="display:flex;"><span>$vectorNode <span style="color:#f92672">=</span> <span style="color:#a6e22e">array_filter</span>($planNodes, <span style="color:#a6e22e">fn</span>($n) <span style="color:#f92672">=&gt;</span> $n[<span style="color:#e6db74">&#39;op&#39;</span>] <span style="color:#f92672">===</span> <span style="color:#e6db74">&#39;ann&#39;</span>)[<span style="color:#ae81ff">0</span>] <span style="color:#f92672">??</span> <span style="color:#66d9ef">null</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// After: intent is explicit, no allocation of the full filtered array.
</span></span></span><span style="display:flex;"><span>$vectorNode <span style="color:#f92672">=</span> <span style="color:#a6e22e">array_find</span>($planNodes, <span style="color:#a6e22e">fn</span>($n) <span style="color:#f92672">=&gt;</span> $n[<span style="color:#e6db74">&#39;op&#39;</span>] <span style="color:#f92672">===</span> <span style="color:#e6db74">&#39;ann&#39;</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>$hasVectorScan <span style="color:#f92672">=</span> <span style="color:#a6e22e">array_any</span>($planNodes, <span style="color:#a6e22e">fn</span>($n) <span style="color:#f92672">=&gt;</span> $n[<span style="color:#e6db74">&#39;op&#39;</span>] <span style="color:#f92672">===</span> <span style="color:#e6db74">&#39;ann&#39;</span>);
</span></span><span style="display:flex;"><span>$allPointLookups <span style="color:#f92672">=</span> <span style="color:#a6e22e">array_all</span>($planNodes, <span style="color:#a6e22e">fn</span>($n) <span style="color:#f92672">=&gt;</span> $n[<span style="color:#e6db74">&#39;op&#39;</span>] <span style="color:#f92672">===</span> <span style="color:#e6db74">&#39;point&#39;</span>);
</span></span></code></pre></div><p>The performance difference is small for most client workloads, but the readability difference is large, and a readable client is one that gets more contributions and fewer misuses.</p>
<h2 id="property-hooks-and-lazy-hydration">Property hooks and lazy hydration</h2>
<p>PHP 8.4 introduces property hooks, which let you attach <code>get</code> and <code>set</code> logic directly to a property declaration without writing a backing field and a separate accessor. We use them for lazy hydration on the <code>ResultSet</code> rows, where each row is a thin wrapper over a raw JSON array until the caller actually asks for a typed value. The hook is not magic; it is just a place to put the conversion logic so that the object remains a plain object while still doing the work on demand.</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><span style="color:#66d9ef">final</span> <span style="color:#66d9ef">class</span> <span style="color:#a6e22e">Row</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">__construct</span>(<span style="color:#66d9ef">private</span> <span style="color:#66d9ef">array</span> $data) {}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#a6e22e">string</span> $name {
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">get</span> <span style="color:#f92672">=&gt;</span> $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">data</span>[<span style="color:#e6db74">&#39;name&#39;</span>] <span style="color:#f92672">??</span> <span style="color:#e6db74">&#39;&#39;</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:#66d9ef">public</span> <span style="color:#a6e22e">int</span> $id {
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">get</span> <span style="color:#f92672">=&gt;</span> (<span style="color:#a6e22e">int</span>) ($this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">data</span>[<span style="color:#e6db74">&#39;id&#39;</span>] <span style="color:#f92672">??</span> <span style="color:#ae81ff">0</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:#66d9ef">public</span> <span style="color:#a6e22e">object</span> $document {
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">get</span> <span style="color:#f92672">=&gt;</span> <span style="color:#a6e22e">json_decode</span>($this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">data</span>[<span style="color:#e6db74">&#39;document&#39;</span>] <span style="color:#f92672">??</span> <span style="color:#e6db74">&#39;{}&#39;</span>)
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>The tradeoff is that property hooks can hide work, and a getter that does JSON decoding on every access is a trap if you call it in a loop. We keep the hooks thin and cache the heavier conversions in the constructor, so the hook is mostly a type-safe doorway, not a compute sink.</p>
<h2 id="ext-curl-in-php-84-the-http-foundation">ext-curl in PHP 8.4: the HTTP foundation</h2>
<p>The default transport is cURL, because cURL is the only HTTP client that is present on enough hosts that we can assume it exists, and PHP 8.0 gave us the OO <code>CurlHandle</code> and typed exceptions we use in the wrapper, with PHP 8.4 refinements to default encoding behavior keeping the implementation small. The client detects cURL at runtime, falls back to streams if it is missing, and lets you inject a PSR-18 or Symfony HttpClient adapter if you want something else, but the cURL path is the one we optimize for because it is the one that exists everywhere.</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><span style="color:#66d9ef">final</span> <span style="color:#66d9ef">class</span> <span style="color:#a6e22e">CurlTransport</span> <span style="color:#66d9ef">implements</span> <span style="color:#a6e22e">TransportInterface</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">private</span> <span style="color:#a6e22e">\CurlHandle</span> $handle;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">__construct</span>(<span style="color:#66d9ef">private</span> <span style="color:#a6e22e">TransportConfig</span> $config)
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">handle</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">curl_init</span>();
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">curl_setopt</span>($this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">handle</span>, <span style="color:#a6e22e">CURLOPT_RETURNTRANSFER</span>, <span style="color:#66d9ef">true</span>);
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">curl_setopt</span>($this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">handle</span>, <span style="color:#a6e22e">CURLOPT_FOLLOWLOCATION</span>, <span style="color:#66d9ef">false</span>);
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">curl_setopt</span>($this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">handle</span>, <span style="color:#a6e22e">CURLOPT_CONNECTTIMEOUT_MS</span>, $config<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">connectTimeoutMs</span>);
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">curl_setopt</span>($this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">handle</span>, <span style="color:#a6e22e">CURLOPT_TIMEOUT_MS</span>, $config<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">requestTimeoutMs</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:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">request</span>(<span style="color:#a6e22e">string</span> $method, <span style="color:#a6e22e">string</span> $url, <span style="color:#a6e22e">string</span> $body <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;&#39;</span>)<span style="color:#f92672">:</span> <span style="color:#a6e22e">Response</span>
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">curl_setopt</span>($this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">handle</span>, <span style="color:#a6e22e">CURLOPT_CUSTOMREQUEST</span>, $method);
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">curl_setopt</span>($this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">handle</span>, <span style="color:#a6e22e">CURLOPT_URL</span>, $url);
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">curl_setopt</span>($this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">handle</span>, <span style="color:#a6e22e">CURLOPT_POSTFIELDS</span>, $body);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        $raw <span style="color:#f92672">=</span> <span style="color:#a6e22e">curl_exec</span>($this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">handle</span>);
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> ($raw <span style="color:#f92672">===</span> <span style="color:#66d9ef">false</span>) {
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">throw</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">ConnectionException</span>(<span style="color:#a6e22e">curl_error</span>($this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">handle</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:#66d9ef">return</span> <span style="color:#a6e22e">Response</span><span style="color:#f92672">::</span><span style="color:#a6e22e">fromCurl</span>($this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">handle</span>, $raw);
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>The cURL handle is reused for the lifetime of the transport object, which is better than creating a new handle per request, but it is not yet true connection persistence across the whole process, which is where PHP 8.5 comes in.</p>
<h2 id="php-85-the-pipe-operator-and-persistent-curl-sharing">PHP 8.5: the pipe operator and persistent cURL sharing</h2>
<p>PHP 8.5 shipped in November 2025 with the pipe operator, persistent cURL share handles, and <code>clone</code> with modifications. These are not theoretical wins for the client; they are the features that let us simplify the code we already have.</p>
<p>The pipe operator lets you write <code>$value |&gt; $this-&gt;normalize(...) |&gt; $this-&gt;send(...)</code> instead of nesting calls right-to-left or assigning to a temporary variable. For a client that chains encoding, validation, and request building, the pipe operator makes the middle of the call stack readable without turning it into a fluent builder.</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><span style="color:#75715e">// PHP 8.5
</span></span></span><span style="display:flex;"><span>$response <span style="color:#f92672">=</span> $requestBody
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">|&gt;</span> <span style="color:#a6e22e">json_encode</span>(<span style="color:#f92672">...</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">|&gt;</span> $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">transport</span><span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">request</span>(<span style="color:#e6db74">&#39;POST&#39;</span>, $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">baseUrl</span> <span style="color:#f92672">.</span> <span style="color:#e6db74">&#39;/sql&#39;</span>, <span style="color:#f92672">...</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">|&gt;</span> <span style="color:#a6e22e">Response</span><span style="color:#f92672">::</span><span style="color:#a6e22e">fromRaw</span>(<span style="color:#f92672">...</span>);
</span></span></code></pre></div><p>Persistent cURL share handles are the bigger deal. In PHP 8.4, the <code>CurlTransport</code> reuses a single <code>CurlHandle</code> for the lifetime of the transport object, but each transport object still starts a fresh TCP handshake. PHP 8.5 lets share handles persist across multiple PHP requests, which means an FPM or FrankenPHP worker can keep a warm connection to the database server instead of reopening it. We are wiring this into the transport layer so that the same <code>CurlTransport</code> can attach to a process-wide share handle when the runtime offers one, and the fallback for PHP 8.4 stays exactly the same.</p>
<p><code>clone</code> with modifications is the last piece. Readonly classes are great for value objects, but changing one field used to mean either a constructor spread with <code>get_object_vars</code> or a hand-written <code>with</code> method. PHP 8.5&rsquo;s <code>clone($this, ['field' =&gt; $value])</code> collapses that to a single expression, so the <code>withAlpha</code> style of method becomes small enough that we no longer need helper traits to keep it readable.</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><span style="color:#75715e">// PHP 8.5
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">withAlpha</span>(<span style="color:#a6e22e">int</span> $alpha)<span style="color:#f92672">:</span> <span style="color:#a6e22e">self</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">clone</span>($this, [<span style="color:#e6db74">&#39;alpha&#39;</span> <span style="color:#f92672">=&gt;</span> $alpha]);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h2 id="the-backward-compatibility-tradeoff">The backward-compatibility tradeoff</h2>
<p>The client currently supports PHP 8.4 as its minimum, and PHP 8.5 is now stable and widely available in the distributions we track. Runtime features like persistent cURL share handles can be guarded with <code>function_exists</code> and <code>curl_share_init</code>, so the same package can run faster on PHP 8.5 without breaking PHP 8.4, but syntax features like the pipe operator cannot be runtime-guarded. That means the pipe operator and <code>clone</code> with modifications will arrive as part of a major version bump that raises the minimum to PHP 8.5, while the cURL sharing lands as a backward-compatible performance improvement for anyone already on 8.5.</p>
<h2 id="the-modern-equivalent">The modern equivalent</h2>
<p>In 2010, a PHP database client would have been a thin wrapper around <code>mysql_connect</code> and the main challenge was escaping strings correctly. In 2026, the challenge is building a client that is safe, typed, and deployable without native code, and PHP 8.4 is the first version where that feels natural rather than defensive. PHP 8.5 is now the version that removes the remaining reasons a database client would need to touch anything outside Composer, not by adding one big feature, but by making the small workarounds unnecessary. That is the goal, and the language is finally moving in the same direction.</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><item><title>Encryption at Rest Without Paying the SQLite SEE Tax</title><link>https://www.mongreldb.com/articles/2026/07/encryption-at-rest-without-paying-the-sqlite-see-tax/</link><pubDate>Mon, 13 Jul 2026 09:00:00 -0500</pubDate><guid>https://www.mongreldb.com/articles/2026/07/encryption-at-rest-without-paying-the-sqlite-see-tax/</guid><description>SQLite SEE is the paid benchmark for database encryption at rest. Here is what building your own encryption layer costs in throughput and what the tradeoffs look like in production.</description><content:encoded><![CDATA[<p>If you are shipping a database that handles anything adjacent to personal data, regulated data, or anything a compliance auditor will eventually ask about, encryption at rest stops being a feature and starts being a checkbox you fail without it. SQLite has an answer for this: SQLite SEE, the SQLite Encryption Extension, which is the reference implementation that every comparison in the space gets measured against. It is also a paid product with a perpetual source-code license, royalty-free for use in your own products, but with a closed-source distribution model that makes the source auditability story harder for a team shipping an open-source engine. We needed encryption at rest, we did not want to route money to a third party for a component we could build, and the honest story of what we built and what it costs is worth telling.</p>
<h2 id="the-shape-of-the-problem">The shape of the problem</h2>
<p>SQLite SEE works by presenting the database file as encrypted pages on disk and decrypting them into the page cache on read, which means the WAL, the shared memory region, and every tempfile that hits the filesystem are all covered by the same symmetric key. The encryption boundary is the entire file-level storage surface, and the key is held in memory for the lifetime of the process. That is the right model. Any encryption that does not cover the WAL is not encryption at rest, it is encryption at rest for the parts of the database that are not being actively written, and anyone who has watched a production incident knows that the interesting data is usually in the WAL during the incident.</p>
<p>The hard part is not the encryption itself; AES-256 in an appropriate mode is solved, and you reach for a library rather than rolling your own. The hard part is the key lifecycle, the page-level IO pattern that a database engine uses, and the throughput cost when every read and write hits the crypto layer before anything else.</p>
<h2 id="what-we-built-instead">What we built instead</h2>
<p>MongrelDB uses a two-layer encryption surface: the page layer and the WAL layer share a derived key that is derived from a master key via HKDF-SHA256 with a per-table salt, and the master key itself is injected at startup from an environment variable or a plugin hook rather than stored on disk anywhere. The page encryption operates on 4KB page boundaries with AES-256-GCM, which gives us authenticated encryption so that a corrupted or tampered page on disk is detectable rather than silently misread, and page-layer key rotation is handled by re-encrypting pages in-place on a background thread during a controlled maintenance window, with the next-write rewrite path covering any pages that were not caught by the re-encryption pass. The WAL gets its own per-segment key derivation so that a WAL segment written at time T is not decryptable with a key derived after time T if the master key has been rotated in the interim.</p>
<p>The reason for the plugin hook rather than a fixed key storage mechanism is that different deployment shapes have different constraints around secret distribution, and the plugin hook keeps the engine honest about the abstraction boundary rather than baking in assumptions about where keys come from.</p>
<h2 id="what-it-costs">What it costs</h2>
<p>The measured number is between 8 and 15 percent throughput reduction on write-heavy workloads at the storage layer, measured on the same hardware and same fsync profile as the unencrypted baseline. Read-heavy workloads see less, closer to 3 to 7 percent, because the page cache absorbs a portion of the read path and encrypted pages in cache cost nothing extra to serve. The GCM authentication tag adds 16 bytes per page, which increases WAL segment size by a small percentage on write-heavy workloads, and the per-segment WAL key derivation adds a small constant cost to each WAL segment open.</p>
<p>The number that matters more than the percentage is the shape of the cost curve. Encryption at rest is a constant factor on every IO operation, which means the overhead is proportionally largest on small transactions with many fsync calls and proportionally smallest on large sequential writes where the crypto cost is amortized over many rows per fsync. If your workload is dominated by bulk import, the measured overhead will be lower than if your workload is dominated by single-row commits with synchronous durability requirements.</p>
<h2 id="the-comparison-that-gets-asked-for">The comparison that gets asked for</h2>
<p>SQLite SEE uses a similar page-level encryption model with AES-128-CBC by default, and the performance profile is close enough that the real differentiator is not the crypto algorithm but the authentication property and the key management surface. CBC mode is unauthenticated, which means a bit-flip attack on an encrypted page produces modified plaintext with no checksum to catch it; GCM is authenticated, which means MongrelDB encrypted pages detect tampering rather than returning silently corrupted data, and that property is the engineering argument worth making explicitly rather than leaving it as an implicit difference. We did not build our own crypto to beat SEE on throughput, we built it because the closed-source distribution model does not fit a database that ships as a single open-source binary with no vendor relationship, and if your threat model includes the database vendor as a potential adversary, you should be using hardware-enforced encryption anyway, but if your threat model is &ldquo;the disk gets pulled or the machine gets stolen,&rdquo; a software encryption layer at the page level covers the practical attack surface for most threat models short of nation-state adversaries with physical disk access and a budget for hardware forensics.</p>
<p>The tradeoff is what it always is: you are trading a known-cost constant factor on every IO operation for protection against a class of incidents that are rare, expensive when they happen, and impossible to retrofit after the fact. Whether that tradeoff is worth it for your workload is not a question we can answer for you, but the numbers above are the inputs to that calculation.</p>
]]></content:encoded></item><item><title>Why We Shipped Learned-Range Indexes Before We Shipped B-Trees</title><link>https://www.mongreldb.com/articles/2026/07/why-we-shipped-learned-range-indexes-before-we-shipped-b-trees/</link><pubDate>Sat, 11 Jul 2026 11:22:00 -0500</pubDate><guid>https://www.mongreldb.com/articles/2026/07/why-we-shipped-learned-range-indexes-before-we-shipped-b-trees/</guid><description>Learned-range indexes beat B-trees on cold sorted range scans up to 3-4x on p99, but only when the workload is recency-heavy; here is when the tradeoff is right and when the B-tree still wins.</description><content:encoded><![CDATA[<p>Most range queries on modern OLTP data look like <code>WHERE created_at BETWEEN ? AND ?</code>, and for the first decade of a database&rsquo;s life the only answer that ever shipped was a B-tree, because B-trees were the structure everyone understood and they got within a small constant factor of optimal for sorted range scans on a workload that fit in cache. We shipped a learned-range index first, because the workload that had been hurting on B-trees was the one we kept seeing in customer traces, and that workload is not the one B-trees were tuned for.</p>
<h2 id="what-a-learned-range-index-is-briefly">What a learned-range index is, briefly</h2>
<p>A learned-range index is a small neural network, typically an MLP of one or two hidden layers, that learns the cumulative distribution function (CDF) of sorted keys and predicts the leaf-page offset of any given key from that CDF. The reference is Kraska et al. (2017), &ldquo;The Case for Learned Index Structures,&rdquo; and the headline result, measured on specific in-memory integer-key benchmarks with the paper&rsquo;s own caveats, was up to an order of magnitude faster than a B-tree on point lookups and up to 70% smaller in memory footprint; later work (Ferragina et al., 2020) showed those gains were workload-sensitive and that the win was real but smaller for general-purpose data, so we treat the headline numbers as upper bounds rather than the floor. The model replaced the tree with two or three multiply-adds per lookup; the B-tree existed only as a fallback when the model&rsquo;s error term exceeded the leaf size.</p>
<p>In MongrelDB the learned-range index is implemented as a recency-weighted CDF: incremental updates nudge the model on hot inserts, full retraining happens at segment and compaction boundaries rather than per row, and the loss is weighted toward recent keys so the high-traffic part of the keyspace stays accurately modelled even as the tail grows. The shape is the same MLP, the loss and the update cadence are what changed.</p>
<h2 id="what-it-bought-us-on-the-path-we-cared-about">What it bought us on the path we cared about</h2>
<p>The path that mattered was sorted range scans on data larger than memory, and the query shape that kept appearing in our traces was <code>WHERE event_at BETWEEN ? AND ?</code> against an events table that had outgrown the host&rsquo;s RAM. A B-tree over a 200 GB cold column on a 64 GB host pays roughly <code>log(N) / log(fanout)</code> random reads per query, where <code>N</code> is the leaf-page count, and that random-read pattern is what dominates p99 on the workloads we kept getting bug reports for, where the column was the company&rsquo;s events table and the query was &ldquo;everything between Tuesday and Wednesday last week.&rdquo; A learned-range index over the same data collapses most of the random reads into sequential reads, because the model&rsquo;s prediction lands the seek in the right column block on the first try more often than not, and the cold read degrades to &ldquo;load the leaf and scan&rdquo; rather than &ldquo;walk the tree and load the leaf.&rdquo;</p>
<p>We measured this on a customer trace with the kind of <code>WHERE event_at BETWEEN ? AND ?</code> query above against an 180 GB events table on a 64 GB host at roughly a 3-4x speedup on p99 for the cached-leaf case and a 1.5-2x speedup on p99 for the cold-leaf case; those numbers are honest, they hold over a multi-day window, and a benchmark fixture in the kit/tests directory reproduces them so anyone can re-run them on their own hardware without trusting ours.</p>
<p>The second thing the learned-range index bought us was a smaller working set, and that part mattered more than the latency number in the traces we cared about: a learned index holds each key distribution in a few kilobytes of model weights, where a B-tree holds the same key distribution in a few megabytes of pointers and page metadata, and the difference in cache pressure showed up as a higher hit rate on the rest of the workload, because there was more cache left over for the leaf pages the model was pointing at.</p>
<h2 id="what-we-had-to-give-up-to-get-there">What we had to give up to get there</h2>
<p>Unconditional correctness at the index boundary. A B-tree places every key at a known offset, so <code>WHERE k BETWEEN 100 AND 200</code> returns exactly the keys whose value is in <code>[100, 200]</code>, every time, with no error term to reason about. A learned-range index has a model error that defines a &ldquo;delta band&rdquo; around each prediction, and a query that lands in that band has to fall back to a leaf scan; on data with sharp CDF cliffs (compressed keys, monotonic timestamps) the delta band is narrow and the scan is short, but on data with heavy tail skew (event sources, sparse UUIDs) the delta band is wider and the scan turns into the worst case the model was supposed to prevent. The default <code>IndexBuildPolicy::Learned</code> configures the model for the common recency-heavy case; on data that is not recency-heavy, you fall back to <code>IndexBuildPolicy::BTree</code> and the B-tree beats the learned index. We chose that on purpose rather than papering over it.</p>
<p>Engineering cost. Training a model per index is not a one-line addition to a B-tree codepath; it is a separate subsystem with its own CI fixtures, its own drift detection (model error as measured against held-out data is a real signal we monitor in the catalog), its own rebuild policy on schema change, and its own failure mode when the loss function does not converge on a degenerate distribution (uniform UUIDs, for example). That cost is real, and it is the kind of cost that makes a database team ask whether the speedup justifies the cognitive load; we think it does, but we are not going to pretend that a B-tree was simpler.</p>
<h2 id="where-it-shows-up-in-the-wire-format">Where it shows up in the wire format</h2>
<p><code>WHERE k BETWEEN ? AND ?</code> against a typed numeric, decimal, timestamp, or date column dispatches through <code>index_plan = LearnedRange(model_id = X, delta = 8KB)</code> to <code>/kit/query</code>, and the fallback path is <code>index_plan = BTree</code>. The choice is per-index at build time, not per-query at runtime, which means a single table can hold a B-tree on its primary key and a learned-range index on its events column, and you pick the right tool per column rather than per query. The wire format is the same either way:</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">CREATE</span> <span style="color:#66d9ef">TABLE</span> events (
</span></span><span style="display:flex;"><span>  id            INT64,
</span></span><span style="display:flex;"><span>  event_at      <span style="color:#66d9ef">TIMESTAMP</span>,
</span></span><span style="display:flex;"><span>  payload       JSON,
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">INDEX</span> event_at_idx <span style="color:#66d9ef">USING</span> LEARNED <span style="color:#66d9ef">ON</span> event_at,
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">INDEX</span> id_pk     <span style="color:#66d9ef">USING</span> BTREE   <span style="color:#66d9ef">ON</span> id
</span></span><span style="display:flex;"><span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">SELECT</span> <span style="color:#66d9ef">count</span>(<span style="color:#f92672">*</span>), <span style="color:#66d9ef">avg</span>(latency_ms)
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">FROM</span> events
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">WHERE</span> event_at <span style="color:#66d9ef">BETWEEN</span> <span style="color:#e6db74">&#39;2026-07-01 00:00:00&#39;</span> <span style="color:#66d9ef">AND</span> <span style="color:#e6db74">&#39;2026-07-08 00:00:00&#39;</span>;
</span></span></code></pre></div><p>Same <code>WHERE</code> clause you would write for a B-tree, same planner, same transactional commit at the end of the statement, same WAL coverage for both index and row update on any <code>INSERT INTO events</code>. The learned-range index is the right shape for <code>event_at</code>, the B-tree is the right shape for <code>id</code>, and we ship both because they are the right tool for each column rather than because we had an academic result to justify.</p>
<h2 id="when-this-is-the-wrong-tradeoff">When this is the wrong tradeoff</h2>
<p>If your column is small enough to fit in cache, the B-tree wins anyway, because the random-read cost of the B-tree is small relative to the cache-hit latency of the learned-range index. If your column is wider than memory and your queries are equality lookups (<code>WHERE k = ?</code>) rather than range scans, the B-tree wins by a wider margin, because the learned-range index&rsquo;s prediction error buys you nothing when you are not sweeping across a key range. If your data has no recency structure at all (a uniformly-distributed synthetic key, an auto-increment surrogate id), the learned-range model&rsquo;s loss never improves past the prior, and the B-tree is the right answer with no hesitation. The learned-range index is a tool for one specific shape of query on one specific shape of data; the B-tree is the universal tool that covers the rest, and we ship both as first-class index options in the same engine, on the same wire format, with the same transaction model.</p>
]]></content:encoded></item><item><title>Pure PHP in 2026: A Database Client That Ships With Composer</title><link>https://www.mongreldb.com/articles/2026/07/pure-php-in-2026-a-database-client-that-ships-with-composer/</link><pubDate>Fri, 10 Jul 2026 09:00:00 -0500</pubDate><guid>https://www.mongreldb.com/articles/2026/07/pure-php-in-2026-a-database-client-that-ships-with-composer/</guid><description>The mongreldb-php client installs with composer require, needs no phpize, no PECL, and no native ABI matching, because pure PHP is still a feature in 2026.</description><content:encoded><![CDATA[<p>PHP runs everywhere, and everywhere has a different OS version, a different architecture, and a hosting provider whose control panel says &ldquo;Native extensions: contact support.&rdquo; That sentence is not hypothetical; it describes the deployment environment for a substantial fraction of PHP applications in production today, and if your database client requires a compile step or a PECL install, you either open a support ticket or you pick a different database. MongrelDB&rsquo;s PHP client ships as pure PHP, installs with <code>composer require visorcraft/mongreldb-php</code>, and speaks to a <code>mongreldb-server</code> daemon over plain HTTP, which means the only thing it requires of the host is a working PHP 8.4+ runtime and a network path to the server.</p>
<h2 id="what-pure-php-actually-means-in-2026">What pure PHP actually means in 2026</h2>
<p>The phrase &ldquo;pure PHP&rdquo; has accumulated some baggage over the years, partly because PHP&rsquo;s early reputation as a slow interpreted language made &ldquo;pure PHP&rdquo; sound like &ldquo;slow PHP,&rdquo; and partly because the PHP ecosystem spent a decade leaning on C extensions as proof of seriousness. Neither of those associations is accurate anymore, and the PHP client does not trade on either of them.</p>
<p>What pure PHP means here is concrete: the entire client is written in PHP 8.4 syntax, it ships as a Composer package, it has no native code dependencies, and it does not call any function that requires a C extension. HTTP is handled by <code>curl</code> via ext-curl when PHP 8.5 persistent handle sharing is available (PHP 8.5 being the newer of the two runtimes, so the stream-context path remains the common case for now), and by regular stream contexts when it is not; both are in the standard PHP distribution and neither requires a compile step. JSON is handled by <code>json_decode</code> and <code>json_encode</code>, which have been built into PHP since 5.2 and have been reliably fast since 7.0. There is no binary .so to load, no phpize to run, no ABI version to match against the server&rsquo;s architecture, and no risk that <code>pecl install mongrel</code> fails because your host has disabled exec() in php.ini.</p>
<h2 id="the-install-experience">The install experience</h2>
<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><span style="color:#a6e22e">composer</span> <span style="color:#66d9ef">require</span> <span style="color:#a6e22e">visorcraft</span><span style="color:#f92672">/</span><span style="color:#a6e22e">mongreldb</span><span style="color:#f92672">-</span><span style="color:#a6e22e">php</span>
</span></span></code></pre></div><p>That is the entire install. The package lands in <code>vendor/</code>, autoloading is configured, and you can instantiate the client in one line:</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><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">Visorcraft\MongrelDB\Client</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>$db <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Client</span>(<span style="color:#e6db74">&#39;https://db.example.com&#39;</span>, <span style="color:#a6e22e">token</span><span style="color:#f92672">:</span> <span style="color:#a6e22e">getenv</span>(<span style="color:#e6db74">&#39;MONGRELDB_TOKEN&#39;</span>));
</span></span></code></pre></div><p>No bootstrap scripts, no extension loading in php.ini, no running <code>mongreldb-php-setup</code> as a post-install script. The Composer package ships the client, the type stubs, and the error hierarchy; you require the package and you are talking to the database.</p>
<h2 id="why-http-is-the-right-transport-for-a-pure-php-client">Why HTTP is the right transport for a pure PHP client</h2>
<p>The PHP client could have used a Unix socket or a raw TCP protocol with a binary framing layer, and in a pure-server context those choices would make sense for latency. But PHP is not a long-running process by default; PHP-FPM spawns a process per request or per few requests, and the typical PHP application opens a fresh database connection on every web request, closes it at the end, and does not reuse it unless you have gone out of your way to implement connection pooling. An HTTP client reuses keep-alive connections across requests in the same process lifetime, and when PHP-FPM recycles a worker the next request starts fresh, which is the same behavior the database would have expected anyway.</p>
<p>HTTP also means the PHP client works through every HTTP-aware proxy, load balancer, and API gateway that already exists in your infrastructure, and it means TLS termination happens at the normal layer rather than requiring a special database-facing certificate setup. You are already doing HTTP for every other external service your application calls; the database does not need to be special.</p>
<h2 id="what-you-give-up-and-what-you-do-not">What you give up and what you do not</h2>
<p>You give up the single-digit-microsecond in-process path, because HTTP adds a header processing layer that a raw TCP protocol would not have (a few hundred microseconds of framing overhead per operation, not a category shift), and you give up the ability to operate without a network path to the server, which is the embedded-database use case that MongrelDB handles differently through its Kit SDK. For a PHP web application that talks to a <code>mongreldb-server</code> over a LAN or a VPC, those tradeoffs are not on the critical path; the query execution time inside the database dominates the HTTP overhead by two orders of magnitude.</p>
<p>You do not give up type safety, because the client speaks a typed wire format and deserializes responses into PHP native types with a full exception hierarchy that maps HTTP status codes to domain exceptions, so a <code>404</code> from the server becomes a <code>NotFoundException</code> in your application code rather than a generic runtime error. You do not give up connection pooling in the PHP-FPM context, because the client uses persistent curl handles when PHP 8.5 is available, which means the underlying TCP connection is reused across requests in the same worker process without the TLS handshake cost on every page load. And you do not give up SQL surface area, because the wire format carries the same query plans and constraint semantics as the Kit SDK and the Rust core, so the PHP client is not a reduced-surface wrapper; it is the full client in a different transport.</p>
<h2 id="who-benefits-from-this-in-practice">Who benefits from this in practice</h2>
<p>The obvious answer is the shared-hosting developer who is on a cPanel machine with no root access and no ability to run phpize, and that answer is correct but incomplete. The more interesting case is the CI pipeline: a <code>composer require</code> in a Dockerfile that uses <code>php:cli</code> as its base image does not need any system package installed beyond the PHP runtime and Composer itself, which means your test matrix can cover PHP 8.4 and PHP 8.5 on the same Docker image without an extension compilation step slowing down every build. The pure PHP client makes your database client a first-class Composer dependency rather than a system-level install, and that distinction matters when your CI minutes are metered and your container build time is on the critical path.</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><item><title>Twelve Languages, Two Tiers, One C ABI: MongrelDB Client Architecture</title><link>https://www.mongreldb.com/articles/2026/07/mongreldb-client-architecture/</link><pubDate>Wed, 08 Jul 2026 18:30:00 -0500</pubDate><guid>https://www.mongreldb.com/articles/2026/07/mongreldb-client-architecture/</guid><description>MongrelDB today ships in twelve languages. As of this week, eight are brand new. This article walks through the two-tier architecture that produced them, why each language ended up where it did, and what the new mongreldb-ffi C ABI enables next.</description><content:encoded><![CDATA[<p>MongrelDB today ships in twelve languages. As of this week, eight of those are brand new: Go, Java/Kotlin, C#/.NET, Ruby, Swift, Zig, D, and Nim. The other four, Rust, TypeScript, Python, and PHP, landed earlier.</p>
<p>It reflects an architectural decision we made before writing the eighth client, and it has visible tradeoffs.</p>
<h2 id="the-two-tier-model">The two-tier model</h2>
<p>MongrelDB clients come in two flavors:</p>
<ol>
<li><strong>Tier 1 (in-process native):</strong> the engine runs in your process. No daemon, no serialization, no network hop. You get the same sub-10µs single-row write path as the embedded Rust API itself.</li>
<li><strong>Tier 2 (HTTP):</strong> a pure-language client connects to a running <code>mongreldb-server</code> over HTTP. There is one round-trip per query, in the low-millisecond range.</li>
</ol>
<p>We have three Tier 1 entry points today: <strong>Rust</strong> (direct, native), <strong>Python</strong> (PyO3, via MongrelDB Kit), and <strong>TypeScript / Node</strong> (NAPI addon, via either Kit or <code>crates/mongreldb-node</code>). The other nine are Tier 2: PHP, Go, Java/Kotlin, C#/.NET, Ruby, Swift, Zig, D, and Nim.</p>
<p>This is not because we could not write native bindings for the other nine. It is because we chose not to.</p>
<h2 id="why-two-tiers-exist">Why two tiers exist</h2>
<p>Twenty-five years of SQLite have produced one clear lesson: the embedded path beats the network path every time, when you have a choice. Native bindings for an embedded database are the better-sqlite3 model, and it is hard to argue with. We picked that model for Rust, TypeScript, and Python.</p>
<p>The other nine do not fit cleanly. The C toolchain story in each of them has, at best, one platform with first-class support and a long tail of platforms with rough edges:</p>
<ul>
<li><strong>Ruby</strong> has <code>gem build</code> and Fiddle. Native extensions work on MRI/YARV, less cleanly on JRuby and TruffleRuby.</li>
<li><strong>PHP</strong> has PECL. PECL extensions work everywhere PHP installs, but every install means a separate Makefile, an <code>phpize</code>, and a <code>php-config</code> check. The pure-PHP client we shipped this month exists precisely because that build matrix is painful.</li>
<li><strong>Swift</strong> has SwiftPM on macOS. On Linux, the Swift toolchain is real but second-tier. The cost is per-platform build scripts.</li>
<li><strong>Java</strong> has JNI. JNI still works in 2026, but it is a generation-old API, and every JVM in the install path is another moving part.</li>
<li><strong>C#</strong> has P/Invoke. That part is fine. The complications come from cross-platform packaging, RID resolution, and what to do about self-contained versus framework-dependent deployments.</li>
<li><strong>Go, Zig, D, Nim</strong> all have C FFI stories that work, but each brings an extra build-time dependency on a Rust compiler or C toolchain that the language&rsquo;s users did not sign up for.</li>
</ul>
<p>Native bindings for those nine would mean nine more CI matrices, nine more sets of release artifacts, and a permanent maintenance cost on every engine change. We chose to ship pure-language HTTP clients instead, with a real latency tradeoff we will not pretend away.</p>
<h2 id="what-the-http-tier-looks-like">What the HTTP tier looks like</h2>
<p>A Tier 2 client speaks a JSON-over-HTTP protocol to <code>mongreldb-server</code>. The protocol mirrors the native API surface: writes land at <code>PUT /tables/{name}/put</code> (or batch through <code>POST /kit/txn</code>), hybrid queries at <code>POST /kit/query</code>. Encoding is plain UTF-8 JSON for most requests; binary values go over base64. It is not exotic.</p>
<p>Latency for a single-row write over HTTP on loopback is dominated by <code>axum</code>&rsquo;s handler dispatch, JSON parse/serialize, and the TCP round trip, not the engine.</p>
<p>To put a number on it: on a release build over loopback HTTP, single-row <code>PUT /tables/bench/put</code> measured p50 ~0.22 ms, p99 ~0.62 ms, with ~4,000 ops/s sustained. Single-row <code>commit</code> was nearly identical, p50 ~0.21 ms, p99 ~0.53 ms. <code>fsync</code> is in the commit path but does not dominate on this hardware.</p>
<p>That is fine for most PHP, Ruby, Java, and Go workloads, where the unit of work is rarely one row. It is not fine for Python and Node code that wants the sub-10µs native path, which is exactly why Tier 1 exists for those two.</p>
<h2 id="the-c-abi-and-why-it-exists-separately">The C ABI and why it exists separately</h2>
<p>The newest crate in the repository is <code>mongreldb-ffi</code>. It exposes a stable C ABI over the engine: a fixed set of <code>extern &quot;C&quot;</code> functions for opening a database, beginning a transaction, putting a row, committing, and a handful of query helpers. Public structs and enums are <code>#[repr(C)]</code>. Opaque handles (<code>mongreldb_database_t</code>, <code>mongreldb_transaction_t</code>) wrap <code>Arc&lt;Database&gt;</code> and friends so language runtimes can hold them by pointer.</p>
<p>It is not yet what the Tier 1 clients call into. Rust and PyO3 call into <code>mongreldb-core</code> directly. The NAPI addon also calls into <code>mongreldb-core</code> directly. The C ABI exists so that future Tier 1 clients can land on top of it.</p>
<p>Three reasons we did this:</p>
<ol>
<li><strong>Stability.</strong> A C ABI is a contract you can hold to across major versions of the engine. Rust API surfaces drift; the C ABI does not.</li>
<li><strong>Reuse across FFI stacks.</strong> Swift (<code>@_cdecl</code>), Ruby (Fiddle on MRI), Go (cgo), Java (JNI), Zig (<code>@cImport</code>), Nim (direct C import), and every other language with a working C FFI all consume C headers. One crate, many targets.</li>
<li><strong>Decoupling.</strong> The C ABI can change without the engine changing, and vice versa. That means we can ship and version the FFI surface independently of the engine, which we will need to do.</li>
</ol>
<p>The current <code>mongreldb-ffi</code> is small on purpose. It exposes the minimum surface needed for an &ldquo;open, write a few thousand rows, commit, query, close&rdquo; workflow, plus error and panic-safety plumbing (<code>mongreldb_last_error</code>, <code>mongreldb_last_error_code</code>, <code>mongreldb_free_error_string</code>, free functions for every allocated handle). We will extend it as we add Tier 1 bindings for more languages.</p>
<h2 id="picking-the-right-tier">Picking the right tier</h2>
<p>If you are writing Rust, Python, or TypeScript / Node against MongrelDB, use the <strong>Tier 1</strong> binding. You get in-process speed, no daemon to run, and the same <code>mongreldb-core</code> API surface used by our own server. The NAPI and PyO3 clients are not wrappers; they are first-class Rust compiled to a shared library and bound through standard FFI machinery. For Node specifically, the raw <code>mongreldb-node</code> NAPI addon (the better-sqlite3 model, <code>cd crates/mongreldb-node &amp;&amp; npm install &amp;&amp; npm run build</code>) is the lowest-overhead path. Kit wraps the same engine behind a typed object API if you would rather skip the build step.</p>
<p>If you are writing PHP, Go, Java/Kotlin, C#/.NET, Ruby, Swift, Zig, D, or Nim, use the <strong>Tier 2</strong> HTTP client. Run <code>mongreldb-server</code> alongside your app (systemd, container, sidecar), point the client at it, and you get the same engine underneath with a network in the middle.</p>
<p>If your language is not on the list, the C ABI is the on-ramp. Either we already have a Tier 1 prototype and we can wire you up, or you can ship your own binding on top of <code>mongreldb-ffi</code> and we will help you land it on the org.</p>
<h2 id="what-we-are-shipping-next">What we are shipping next</h2>
<p>We will keep extending Tier 2 to languages where maintaining a pure-language HTTP client is the right call, including maintenance and small feature work on the eight new clients.</p>
<p>We have no specific Tier 1 language conversion committed. <code>mongreldb-ffi</code> is the on-ramp; what we land on top of it will be picked per language when the engineering case is clear.</p>
<h2 id="where-to-find-each-client">Where to find each client</h2>
<table>
	<thead>
			<tr>
					<th>Language</th>
					<th>Tier</th>
					<th>Install</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Rust</td>
					<td>1</td>
					<td><code>cargo add mongreldb-core</code></td>
			</tr>
			<tr>
					<td>TypeScript / Node</td>
					<td>1</td>
					<td>Kit: <code>npm install @visorcraft/mongreldb-kit</code> · raw NAPI: build from <code>crates/mongreldb-node</code></td>
			</tr>
			<tr>
					<td>Python</td>
					<td>1</td>
					<td><code>pip install mongreldb-kit</code></td>
			</tr>
			<tr>
					<td>PHP</td>
					<td>2</td>
					<td><code>composer require visorcraft/mongreldb-php</code></td>
			</tr>
			<tr>
					<td>Go</td>
					<td>2</td>
					<td><code>go get github.com/visorcraft/mongreldb-go</code></td>
			</tr>
			<tr>
					<td>Java / Kotlin</td>
					<td>2</td>
					<td>Maven / Gradle: <code>com.visorcraft:mongreldb</code></td>
			</tr>
			<tr>
					<td>C# / .NET</td>
					<td>2</td>
					<td><code>dotnet add package Visorcraft.MongrelDB</code></td>
			</tr>
			<tr>
					<td>Ruby</td>
					<td>2</td>
					<td><code>gem install mongreldb</code></td>
			</tr>
			<tr>
					<td>Swift</td>
					<td>2</td>
					<td>SwiftPM: <code>https://github.com/visorcraft/MongrelDB-Swift</code></td>
			</tr>
			<tr>
					<td>Zig</td>
					<td>2</td>
					<td><code>zig fetch</code> from the repo</td>
			</tr>
			<tr>
					<td>D</td>
					<td>2</td>
					<td><code>dub add mongreldb</code></td>
			</tr>
			<tr>
					<td>Nim</td>
					<td>2</td>
					<td><code>nimble install mongreldb</code></td>
			</tr>
	</tbody>
</table>
<p>The wire protocol for Tier 2 is documented at <a href="https://www.mongreldb.com/docs/08-daemon/">docs/08-daemon</a>. The C ABI surface lives in <a href="https://github.com/visorcraft/MongrelDB/tree/main/crates/mongreldb-ffi"><code>crates/mongreldb-ffi</code></a>. For each client&rsquo;s install command and examples, see its repository.</p>
<p>If you maintain a language we do not yet support, file an issue with the language name. The C ABI makes the answer shorter than it used to be. For the release announcement and install commands for the eight new HTTP clients, see the <a href="/2026/07/eight-new-mongreldb-clients/">companion post</a>.</p>
]]></content:encoded></item></channel></rss>