|
| 1 | +<!DOCTYPE html> |
| 2 | +<html lang="en"> |
| 3 | +<head> |
| 4 | +<meta charset="utf-8"> |
| 5 | +<meta name="viewport" content="width=device-width, initial-scale=1"> |
| 6 | +<title>FDB go/no-go: the M4 metadata fault + contention battery (#442) — Wyrd</title> |
| 7 | +<meta name="description" content="FoundationDB was chosen partly for its simulation pedigree. But that pedigree"> |
| 8 | +<meta property="og:title" content="FDB go/no-go: the M4 metadata fault + contention battery (#442) — Wyrd"> |
| 9 | +<meta property="og:description" content="FoundationDB was chosen partly for its simulation pedigree. But that pedigree"> |
| 10 | +<meta property="og:type" content="article"> |
| 11 | +<link rel="preconnect" href="https://fonts.googleapis.com"> |
| 12 | +<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> |
| 13 | +<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Spectral:wght@300;400;500;600&family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap"> |
| 14 | +<link rel="stylesheet" href="/assets/style.css"> |
| 15 | +<link rel="icon" href="/favicon.ico" sizes="any"> |
| 16 | +<link rel="icon" href="/favicon.svg" type="image/svg+xml"> |
| 17 | +<link rel="apple-touch-icon" href="/apple-touch-icon.png"> |
| 18 | +<meta name="theme-color" content="#211e1a"> |
| 19 | +</head> |
| 20 | +<body> |
| 21 | + |
| 22 | +<div class="wrap"> |
| 23 | + <header class="topbar"> |
| 24 | + <div class="brand"><a href="/"><svg class="mark" viewBox="0 0 48 48" aria-hidden="true"><polygon class="lz-outer" points="24,3 45,24 24,45 3,24"/><polygon class="lz-inner" points="24,14 34,24 24,34 14,24"/></svg>Wyrd</a></div> |
| 25 | + <nav class="topnav" aria-label="primary"> |
| 26 | + <a href="/governance.html">governance</a> |
| 27 | + <a href="/architecture/">architecture</a> |
| 28 | + <a href="/adr/">decisions</a> |
| 29 | + <a href="https://github.com/getwyrd/wyrd">github</a> |
| 30 | + </nav> |
| 31 | + </header> |
| 32 | +</div> |
| 33 | + |
| 34 | +<!-- DOC_HEADER: doc-class + optional status/meta line. render_site.py emits the |
| 35 | + whole block, or nothing when there is neither a class label nor meta. --> |
| 36 | +<div class="wrap"> |
| 37 | + <header class="doc-header"> |
| 38 | + <p class="doc-meta">12.07.2026</p> |
| 39 | + </header> |
| 40 | +</div> |
| 41 | + |
| 42 | +<div class="wrap"> |
| 43 | + <main> |
| 44 | + <!-- CONTENT: rendered Markdown body (includes its own <h1>). --> |
| 45 | + <article class="prose"> |
| 46 | +<h1>FDB go/no-go: the M4 metadata fault + contention battery (#442)</h1> |
| 47 | +<blockquote> |
| 48 | +<p>The verdict artifact #442 asks for. It records what was run, what was found, and what it |
| 49 | +licenses. <strong>Verdict: GO on the FoundationDB driver's correctness under fault and |
| 50 | +contention</strong> — with two things it does <em>not</em> license, named explicitly below, because a |
| 51 | +gate that quietly overstates itself is worse than no gate.</p> |
| 52 | +</blockquote> |
| 53 | +<h2>Why this battery exists</h2> |
| 54 | +<p>FoundationDB was chosen (ADR-0042) partly <em>for</em> its simulation pedigree. But that pedigree |
| 55 | +validates <strong>their</strong> code. It says nothing about <strong>our mapping layer</strong> — precondition → |
| 56 | +read-conflict set, <code>1020 not_committed</code> → <code>Conflict</code>, the unknown-result rules, the retry |
| 57 | +policy — which is entirely ours and entirely new. This battery is a black-box check of that |
| 58 | +mapping against a real cluster being genuinely abused.</p> |
| 59 | +<h2>What was built, and the one decision that shaped it</h2> |
| 60 | +<p>#442 says to reuse #257's harness. <strong>It could not be reused</strong>: #257's Tier-1 scenario was |
| 61 | +written against the concrete <code>TikvMetadataStore</code>, and its fault-effect oracle asks <strong>PD</strong> for |
| 62 | +a store heartbeat — a concept FoundationDB does not have. Pointing it at FDB was new code, not |
| 63 | +a flag.</p> |
| 64 | +<p>So the scenario was <strong>lifted into a shared crate</strong> (<code>wyrd-metadata-fault-conformance</code>), generic |
| 65 | +over <code>MetadataStore</code>, with the two backend-shaped parts behind one seam (<code>ClusterFault</code>): <em>how |
| 66 | +you cut a node</em>, and <em>how you ask its peers whether the cut bit</em>. Everything that decides PASS |
| 67 | +or FAIL — the workload, the invariants, the signal arithmetic — is now <strong>the same code for both |
| 68 | +backends</strong>. That is the only basis on which their verdicts can be compared; a private FDB notion |
| 69 | +of "pass" would decide nothing. It is the discipline of the shared <code>metadata-conformance</code> suite |
| 70 | +(ADR-0016), applied to the fault battery.</p> |
| 71 | +<p>Runner: <code>WYRD_TIER1=1 cargo xtask fdb-metadata-tier1</code> — repeatable, self-contained (brings up |
| 72 | +<code>deploy/fdb-multi-replica</code>, configures <code>double ssd</code>, writes the cluster file, tears down |
| 73 | +unconditionally), and <strong>opt-in but never silently skipped</strong>: opted-in-with-Docker-missing is a |
| 74 | +hard error, because a battery that quietly did not run would be recorded as a "go" nobody |
| 75 | +earned.</p> |
| 76 | +<h2>The three legs, and what each found</h2> |
| 77 | +<h3>1. Contention — PASS</h3> |
| 78 | +<p>Three workloads, N=4 clients each, against the live 3-process cluster.</p> |
| 79 | +<table> |
| 80 | +<thead> |
| 81 | +<tr> |
| 82 | +<th>Workload</th> |
| 83 | +<th>The invariant</th> |
| 84 | +<th>Result</th> |
| 85 | +</tr> |
| 86 | +</thead> |
| 87 | +<tbody> |
| 88 | +<tr> |
| 89 | +<td>Rename races</td> |
| 90 | +<td>Exactly one of N concurrent renames of the same binding may win; losers are <code>Conflict</code>; the binding ends up in exactly one place — never duplicated, never lost</td> |
| 91 | +<td>pass</td> |
| 92 | +</tr> |
| 93 | +<tr> |
| 94 | +<td>Inode-allocator hot path</td> |
| 95 | +<td>N clients × 8 allocations through a CAS loop: <strong>every id unique</strong>, and the cell advances by exactly the number handed out</td> |
| 96 | +<td>pass</td> |
| 97 | +</tr> |
| 98 | +<tr> |
| 99 | +<td>Blind-batch storm</td> |
| 100 | +<td>A precondition-free batch is <strong>never</strong> <code>Conflict</code> (it has nothing to lose); no write vanishes while its caller was told it committed</td> |
| 101 | +<td>pass</td> |
| 102 | +</tr> |
| 103 | +</tbody> |
| 104 | +</table> |
| 105 | +<p>The allocator leg is the one with the sharpest teeth: a misclassified <code>Conflict</code> there — a lost |
| 106 | +race reported as success — hands <strong>two files the same inode</strong>, the worst outcome the metadata |
| 107 | +layer can produce. It did not happen.</p> |
| 108 | +<h3>2. Consistency under a symmetric cluster fault — PASS</h3> |
| 109 | +<p>The shared Tier-1 scenario, with the process holding FDB's <strong><code>master</code> role</strong> symmetrically |
| 110 | +isolated (bidirectional <code>iptables</code> DROP inside its own network namespace) while ≥2 contenders |
| 111 | +race the same compare-and-swap.</p> |
| 112 | +<p>Cutting an arbitrary node would have been <strong>outcome-neutral</strong> — FDB keeps quorum on the majority |
| 113 | +side, so a bystander cut proves nothing and every assertion goes green for free. That is the |
| 114 | +hollow flip #257's review caught in the TiKV leg. The target is therefore resolved at runtime |
| 115 | +from <code>status json</code> (the <code>master</code>), and the fault is confirmed <strong>from the peers' side</strong> (a |
| 116 | +<em>survivor's</em> <code>coordinators[].reachable</code> view of the cut node) — never by probing the dropped port |
| 117 | +ourselves, which would only prove our own packets are dropped.</p> |
| 118 | +<p>All four independent signals held: <code>read_after_commit</code>, <code>converged_once</code> (the contended CAS round |
| 119 | +advanced the version by exactly one), <code>fault_materialized</code> (the cluster provably lost the node), |
| 120 | +<code>no_lost_update</code> (exactly one contended winner; the stale-CAS probe rejected).</p> |
| 121 | +<h3>3. Mid-commit kill — PASS, and it induced real 1021s</h3> |
| 122 | +<p><code>SIGKILL</code> on the process holding the <strong><code>commit_proxy</code></strong> role — the process a commit RPC is |
| 123 | +actually sent to — fired from a background thread <strong>while commits were in flight</strong>.</p> |
| 124 | +<p><strong>The first draft of this leg was a hollow green, and its own honesty check said so</strong>: it killed |
| 125 | +the <em>master</em> at a round boundary and reported <em>"the kill perturbed no commit — 40 committed, 0 |
| 126 | +unknown results"</em>. Killing between commits interrupts nothing, and the master is not the process |
| 127 | +holding the RPC. Both corrections were load-bearing.</p> |
| 128 | +<p>The corrected leg, over 400 rounds of a single-writer CAS chain:</p> |
| 129 | +<pre><code>398 committed, 1 settled by re-read, 2 unknown-result commit(s), 0 plain fault(s), |
| 130 | +0 phantom conflict(s); final version 399 |
| 131 | +</code></pre> |
| 132 | +<p>Both unknown results were <strong>real <code>1021 commit_unknown_result</code></strong> from the live cluster, surfaced |
| 133 | +as the seam's <code>CommitUnknownResult</code> — never as <code>Conflict</code>, never silently retried. And <strong>every |
| 134 | +1021 was accounted for</strong> (#442's acceptance criterion): the writer did the one thing the contract |
| 135 | +permits — re-read — and settled each one. Of the two, <strong>one had landed and one had not</strong>, which |
| 136 | +is precisely why the contract forbids guessing.</p> |
| 137 | +<p>A second run, after the leg was hardened (below), reproduced it independently: <code>399 committed, 0 settled by re-read, 1 unknown-result commit(s), 0 phantom conflict(s); final version 399</code> — that |
| 138 | +1021's batch had <em>not</em> landed, and the accounting closes on the other side of the same rule.</p> |
| 139 | +<p><strong>An unperturbed run now FAILS.</strong> The leg originally <em>printed a note</em> when the kill perturbed no |
| 140 | +commit and passed anyway. That was the round-boundary hollow green one level up: honest in the |
| 141 | +log, wrong in the exit code — <code>xtask fdb-metadata-tier1</code> would have reported that FoundationDB |
| 142 | +"passed the battery" for a run in which the hard path never executed, and a gate that can record |
| 143 | +a GO on that is worth nothing. It now asserts <code>unknown_results + faults + settled_by_reread > 0</code> |
| 144 | +and fails as <strong>inconclusive</strong> otherwise, telling the operator to re-run (the kill window is |
| 145 | +timing-dependent). Caught by a <code>codex review</code> of #535 — a note is not a gate.</p> |
| 146 | +<p>Three properties this leg pins that nothing else could:</p> |
| 147 | +<ul> |
| 148 | +<li><strong>No phantom <code>Conflict</code>.</strong> With a single writer, nothing can legitimately lose a race, so an |
| 149 | +<code>Ok(Conflict)</code> would be a fault or an unknown result wearing the wrong hat — telling the caller |
| 150 | +<em>nothing was written</em> when something may have been. Zero occurred.</li> |
| 151 | +<li><strong>Atomicity across the kill.</strong> Markers present were exactly <code>{0..final_version}</code> — no half-landed |
| 152 | +batch.</li> |
| 153 | +<li><strong>No double-apply.</strong> 398 + 1 = 399 = the final version. Nothing landed that no caller was told |
| 154 | +about, and nothing was applied twice — which is the failure a silently-retried non-idempotent |
| 155 | +batch would produce.</li> |
| 156 | +</ul> |
| 157 | +<h2>What this verdict does NOT license</h2> |
| 158 | +<p><strong>1. It is not a TiKV-vs-FDB comparison, because the TiKV leg is currently red — and not because |
| 159 | +of this work.</strong> The shared scenario's standing guard (TiKV must stay green in the same change) |
| 160 | +could not be discharged: <code>WYRD_TIER1=1 cargo xtask metadata-tier1</code> fails at the rename, with |
| 161 | +tikv-client's own 2 s per-RPC timeout surfacing during the forced leader election |
| 162 | +(<code>PessimisticLockError { GrpcAPI(Cancelled, "Timeout expired") }</code>). This was <strong>reproduced on |
| 163 | +unmodified <code>main</code>, and again at <code>a4abb69</code> — before any of the #437/#515/#516/#517 work</strong> — so it |
| 164 | +is pre-existing and independent of the lift. It wants its own issue. Until it is green, the two |
| 165 | +backends have been held to the same <em>code</em>, but only FDB has actually passed it here.</p> |
| 166 | +<p><strong>2. It does not flip the production default.</strong> That is a separate, deliberate step: the CLI |
| 167 | +default is <code>redb</code> (<code>crates/server/src/cli.rs</code>), not TiKV, and <code>fdb</code> is an off-by-default cargo |
| 168 | +feature because it links <code>libfdb_c</code> — which ADR-0042 explicitly relies on. Flipping it is a |
| 169 | +build-system change plus a docs/canonical-stack change (<code>deploy/README.md</code>'s "currently |
| 170 | +canonical"), not a match arm, and it should be its own reviewable change.</p> |
| 171 | +<h2>Verdict</h2> |
| 172 | +<p><strong>GO</strong> on the question this battery actually asked: <em>does our FoundationDB mapping layer hold up |
| 173 | +under real faults and real contention?</em> It does. The commit classification is correct under a |
| 174 | +lost race, under a symmetric isolation of the master, and under a commit proxy dying with the RPC |
| 175 | +in flight; the unknown-result class is surfaced, distinguishable, never retried, and never |
| 176 | +mistaken for a <code>Conflict</code>; and no batch was ever lost, torn, duplicated, or double-applied.</p> |
| 177 | +<p>The two carve-outs above are not hedges — they are the parts a reader would otherwise assume were |
| 178 | +covered. The evidence for everything claimed here is reproducible with one command:</p> |
| 179 | +<pre><code>WYRD_TIER1=1 cargo xtask fdb-metadata-tier1 |
| 180 | +</code></pre> |
| 181 | + |
| 182 | + </article> |
| 183 | + </main> |
| 184 | +</div> |
| 185 | + |
| 186 | +<div class="wrap"> |
| 187 | + <footer> |
| 188 | + <div class="foot-row"> |
| 189 | + <nav class="foot-links" aria-label="footer"> |
| 190 | + <a href="https://github.com/getwyrd/wyrd">github</a> |
| 191 | + <a href="/architecture/">architecture</a> |
| 192 | + <a href="/adr/">decisions</a> |
| 193 | + <a href="/references.html">prior art</a> |
| 194 | + <a href="/name.html">the name</a> |
| 195 | + </nav> |
| 196 | + <div class="foot-meta">Apache-2.0 · built in Rust</div> |
| 197 | + </div> |
| 198 | + </footer> |
| 199 | +</div> |
| 200 | + |
| 201 | +<!-- SCRIPTS: render_site.py injects the Mermaid include only on pages that |
| 202 | + contain a diagram; empty otherwise. --> |
| 203 | + |
| 204 | +</body> |
| 205 | +</html> |
0 commit comments