<?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>Idempotency on MongrelDB</title><link>https://www.mongreldb.com/articles/tags/idempotency/</link><description>Recent content in Idempotency 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>Mon, 20 Jul 2026 09:00:00 -0500</lastBuildDate><atom:link href="https://www.mongreldb.com/articles/tags/idempotency/index.xml" rel="self" type="application/rss+xml"/><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></channel></rss>